feat: backend orkestrasyonunu ve arac entegrasyonlarini genislet
This commit is contained in:
@@ -1,37 +1,323 @@
|
||||
import httpx
|
||||
from httpx import HTTPError
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from httpx import HTTPError, HTTPStatusError, ReadTimeout
|
||||
|
||||
from app.models import ModelProvider, OllamaStatus
|
||||
|
||||
from app.models import OllamaStatus
|
||||
|
||||
class OllamaClient:
|
||||
def __init__(self, base_url: str) -> None:
|
||||
def __init__(self, base_url: str, provider: ModelProvider = "local", api_key: str = "") -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.provider = provider
|
||||
self.api_key = api_key
|
||||
|
||||
async def health(self) -> bool:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(f"{self.base_url}/api/tags")
|
||||
return response.is_success
|
||||
try:
|
||||
await self._fetch_models()
|
||||
except HTTPError:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def status(self, model: str) -> OllamaStatus:
|
||||
if self.provider == "zai" and not self.api_key.strip():
|
||||
return OllamaStatus(
|
||||
reachable=False,
|
||||
provider=self.provider,
|
||||
base_url=self.base_url,
|
||||
model=model,
|
||||
message="Z.AI API key is not configured.",
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(f"{self.base_url}/api/tags")
|
||||
response.raise_for_status()
|
||||
installed_models = await self._fetch_models()
|
||||
except HTTPError as exc:
|
||||
return OllamaStatus(
|
||||
reachable=False,
|
||||
provider=self.provider,
|
||||
base_url=self.base_url,
|
||||
model=model,
|
||||
message=f"Ollama unreachable: {exc}",
|
||||
message=f"LLM endpoint unreachable: {exc}",
|
||||
)
|
||||
|
||||
payload = response.json()
|
||||
installed_models = [item.get("name", "") for item in payload.get("models", []) if item.get("name")]
|
||||
has_model = model in installed_models
|
||||
return OllamaStatus(
|
||||
reachable=True,
|
||||
provider=self.provider,
|
||||
base_url=self.base_url,
|
||||
model=model,
|
||||
installed_models=installed_models,
|
||||
message="Model found." if has_model else "Ollama reachable but model is not installed.",
|
||||
message="Model found." if has_model else "LLM endpoint reachable but model is not installed.",
|
||||
)
|
||||
|
||||
async def chat(self, model: str, system_prompt: str, user_message: str) -> str:
|
||||
result = await self.chat_completion(
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_message},
|
||||
],
|
||||
)
|
||||
if result["tool_calls"]:
|
||||
raise HTTPError("Chat completion requested tools in plain chat mode.")
|
||||
payload = result["content"].strip()
|
||||
if not payload:
|
||||
raise HTTPError("Chat completion returned empty content.")
|
||||
return payload
|
||||
|
||||
async def chat_completion(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict[str, object]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
self._ensure_provider_ready()
|
||||
if self.provider == "zai":
|
||||
return await self._anthropic_chat_completion(model, messages, tools)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": 0.3,
|
||||
}
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
payload["tool_choice"] = tool_choice or "auto"
|
||||
|
||||
endpoint = f"{self.base_url}/chat/completions" if self.provider == "zai" else f"{self.base_url}/v1/chat/completions"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||
response = await self._post_with_retry(client, endpoint, payload)
|
||||
except ReadTimeout as exc:
|
||||
raise HTTPError("LLM request timed out after 180 seconds.") from exc
|
||||
|
||||
data = response.json()
|
||||
choices = data.get("choices", [])
|
||||
if not choices:
|
||||
raise HTTPError("Chat completion returned no choices.")
|
||||
|
||||
message = choices[0].get("message", {})
|
||||
content = message.get("content", "")
|
||||
if isinstance(content, list):
|
||||
text_parts = [part.get("text", "") for part in content if isinstance(part, dict)]
|
||||
content = "".join(text_parts)
|
||||
tool_calls = []
|
||||
for call in message.get("tool_calls", []) or []:
|
||||
function = call.get("function", {})
|
||||
raw_arguments = function.get("arguments", "{}")
|
||||
try:
|
||||
arguments = json.loads(raw_arguments) if isinstance(raw_arguments, str) else raw_arguments
|
||||
except json.JSONDecodeError:
|
||||
arguments = {"raw": raw_arguments}
|
||||
tool_calls.append(
|
||||
{
|
||||
"id": call.get("id", ""),
|
||||
"name": function.get("name", ""),
|
||||
"arguments": arguments,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"content": str(content or ""),
|
||||
"tool_calls": tool_calls,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
async def _anthropic_chat_completion(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict[str, object]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
system_prompt, anthropic_messages = self._to_anthropic_messages(messages)
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"max_tokens": 2048,
|
||||
"messages": anthropic_messages,
|
||||
}
|
||||
if system_prompt:
|
||||
payload["system"] = system_prompt
|
||||
anthropic_tools = self._to_anthropic_tools(tools or [])
|
||||
if anthropic_tools:
|
||||
payload["tools"] = anthropic_tools
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||
response = await self._post_with_retry(client, f"{self.base_url}/v1/messages", payload)
|
||||
except ReadTimeout as exc:
|
||||
raise HTTPError("LLM request timed out after 180 seconds.") from exc
|
||||
|
||||
data = response.json()
|
||||
blocks = data.get("content", []) or []
|
||||
text_parts: list[str] = []
|
||||
tool_calls: list[dict[str, Any]] = []
|
||||
for block in blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
block_type = block.get("type")
|
||||
if block_type == "text":
|
||||
text_parts.append(str(block.get("text", "")))
|
||||
if block_type == "tool_use":
|
||||
tool_calls.append(
|
||||
{
|
||||
"id": str(block.get("id", "")),
|
||||
"name": str(block.get("name", "")),
|
||||
"arguments": block.get("input", {}) if isinstance(block.get("input"), dict) else {},
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"content": "".join(text_parts).strip(),
|
||||
"tool_calls": tool_calls,
|
||||
"message": data,
|
||||
}
|
||||
|
||||
async def _fetch_models(self) -> list[str]:
|
||||
self._ensure_provider_ready()
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
if self.provider == "zai":
|
||||
response = await client.get(f"{self.base_url}/v1/models", headers=self._headers())
|
||||
if response.is_success:
|
||||
payload = response.json()
|
||||
return [item.get("id", "") for item in payload.get("data", []) if item.get("id")]
|
||||
return ["glm-4.7", "glm-5"]
|
||||
|
||||
response = await client.get(f"{self.base_url}/api/tags")
|
||||
if response.is_success:
|
||||
payload = response.json()
|
||||
if isinstance(payload, dict) and "models" in payload:
|
||||
return [item.get("name", "") for item in payload.get("models", []) if item.get("name")]
|
||||
|
||||
response = await client.get(f"{self.base_url}/v1/models")
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
return [item.get("id", "") for item in payload.get("data", []) if item.get("id")]
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
if self.provider != "zai":
|
||||
return {}
|
||||
return {
|
||||
"x-api-key": self.api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
|
||||
def _ensure_provider_ready(self) -> None:
|
||||
if self.provider == "zai" and not self.api_key.strip():
|
||||
raise HTTPError("Z.AI API key is not configured.")
|
||||
|
||||
async def _post_with_retry(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
endpoint: str,
|
||||
payload: dict[str, Any],
|
||||
) -> httpx.Response:
|
||||
delays = [0.0, 1.5, 4.0]
|
||||
last_exc: HTTPStatusError | None = None
|
||||
|
||||
for attempt, delay in enumerate(delays, start=1):
|
||||
if delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
response = await client.post(endpoint, json=payload, headers=self._headers())
|
||||
try:
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except HTTPStatusError as exc:
|
||||
last_exc = exc
|
||||
if response.status_code != 429 or attempt == len(delays):
|
||||
raise self._translate_status_error(exc) from exc
|
||||
|
||||
if last_exc is not None:
|
||||
raise self._translate_status_error(last_exc) from last_exc
|
||||
raise HTTPError("LLM request failed.")
|
||||
|
||||
def _translate_status_error(self, exc: HTTPStatusError) -> HTTPError:
|
||||
status = exc.response.status_code
|
||||
if status == 429:
|
||||
provider = "Z.AI" if self.provider == "zai" else "LLM endpoint"
|
||||
return HTTPError(f"{provider} rate limit reached. Please wait a bit and try again.")
|
||||
if status == 401:
|
||||
provider = "Z.AI" if self.provider == "zai" else "LLM endpoint"
|
||||
return HTTPError(f"{provider} authentication failed. Check the configured API key.")
|
||||
if status == 404:
|
||||
return HTTPError("Configured LLM endpoint path was not found.")
|
||||
return HTTPError(f"LLM request failed with HTTP {status}.")
|
||||
|
||||
def _to_anthropic_tools(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
anthropic_tools: list[dict[str, Any]] = []
|
||||
for tool in tools:
|
||||
function = tool.get("function", {}) if isinstance(tool, dict) else {}
|
||||
if not isinstance(function, dict):
|
||||
continue
|
||||
anthropic_tools.append(
|
||||
{
|
||||
"name": str(function.get("name", "")),
|
||||
"description": str(function.get("description", "")),
|
||||
"input_schema": function.get("parameters", {"type": "object", "properties": {}}),
|
||||
}
|
||||
)
|
||||
return [tool for tool in anthropic_tools if tool["name"]]
|
||||
|
||||
def _to_anthropic_messages(self, messages: list[dict[str, object]]) -> tuple[str, list[dict[str, object]]]:
|
||||
system_parts: list[str] = []
|
||||
anthropic_messages: list[dict[str, object]] = []
|
||||
|
||||
for message in messages:
|
||||
role = str(message.get("role", "user"))
|
||||
if role == "system":
|
||||
content = str(message.get("content", "")).strip()
|
||||
if content:
|
||||
system_parts.append(content)
|
||||
continue
|
||||
|
||||
if role == "tool":
|
||||
content = str(message.get("content", ""))
|
||||
tool_use_id = str(message.get("tool_call_id", ""))
|
||||
tool_result_block = {
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_use_id,
|
||||
"content": content,
|
||||
}
|
||||
if anthropic_messages and anthropic_messages[-1]["role"] == "user":
|
||||
existing = anthropic_messages[-1]["content"]
|
||||
if isinstance(existing, list):
|
||||
existing.append(tool_result_block)
|
||||
continue
|
||||
anthropic_messages.append({"role": "user", "content": [tool_result_block]})
|
||||
continue
|
||||
|
||||
content_blocks: list[dict[str, object]] = []
|
||||
content = message.get("content", "")
|
||||
if isinstance(content, str) and content.strip():
|
||||
content_blocks.append({"type": "text", "text": content})
|
||||
|
||||
raw_tool_calls = message.get("tool_calls", [])
|
||||
if isinstance(raw_tool_calls, list):
|
||||
for call in raw_tool_calls:
|
||||
if not isinstance(call, dict):
|
||||
continue
|
||||
function = call.get("function", {})
|
||||
if not isinstance(function, dict):
|
||||
continue
|
||||
arguments = function.get("arguments", {})
|
||||
if isinstance(arguments, str):
|
||||
try:
|
||||
arguments = json.loads(arguments)
|
||||
except json.JSONDecodeError:
|
||||
arguments = {}
|
||||
content_blocks.append(
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": str(call.get("id", "")),
|
||||
"name": str(function.get("name", "")),
|
||||
"input": arguments if isinstance(arguments, dict) else {},
|
||||
}
|
||||
)
|
||||
|
||||
if not content_blocks:
|
||||
continue
|
||||
anthropic_messages.append({"role": "assistant" if role == "assistant" else "user", "content": content_blocks})
|
||||
|
||||
return "\n\n".join(part for part in system_parts if part), anthropic_messages
|
||||
|
||||
@@ -1,15 +1,48 @@
|
||||
from datetime import datetime
|
||||
|
||||
from app.models import RuntimeSettings
|
||||
|
||||
|
||||
def build_prompt_context(message: str, runtime: RuntimeSettings, memory: list[str]) -> dict[str, object]:
|
||||
def build_prompt_context(
|
||||
message: str,
|
||||
runtime: RuntimeSettings,
|
||||
memory: list[str],
|
||||
workspace_root: str,
|
||||
profile_preferences: str = "",
|
||||
second_brain_context: str = "",
|
||||
) -> dict[str, object]:
|
||||
tool_names = [tool.name for tool in runtime.tools if tool.enabled]
|
||||
memory_lines = "\n".join(f"- {item}" for item in memory) if memory else "- No recent memory."
|
||||
profile_lines = profile_preferences or "- No saved profile preferences."
|
||||
second_brain_lines = second_brain_context or "- No second-brain context retrieved for this request."
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
return {
|
||||
"system": (
|
||||
"You are WiseClaw, a local-first assistant running on macOS. "
|
||||
"Use tools carefully and obey terminal safety mode."
|
||||
"Keep replies concise, practical, and safe. "
|
||||
f"Enabled tools: {', '.join(tool_names) if tool_names else 'none'}.\n"
|
||||
f"Today's date: {today}\n"
|
||||
f"Current workspace root: {workspace_root}\n"
|
||||
"Relative file paths are relative to the workspace root.\n"
|
||||
"When the user asks for current information such as today's price, exchange rate, latest news, or current status, do not invent or shift the year. Use today's date above and prefer tools for fresh data.\n"
|
||||
"If the user asks for the working directory, use the terminal tool with `pwd`.\n"
|
||||
"If the user names a local file such as README.md, try that relative path first with the files tool.\n"
|
||||
"If the user asks you to create or update files, use the files tool with action `write`.\n"
|
||||
"If the user asks you to create a note in Apple Notes, use apple_notes with action `create_note`.\n"
|
||||
"If the user asks about their saved notes, documents, archive, workspace knowledge, or second brain, use second_brain or the injected second-brain context before answering.\n"
|
||||
"For a static HTML/CSS/JS app, write the files first, then use the terminal tool to run a local server in the background with a command like `python3 -m http.server 9990 -d <folder>`.\n"
|
||||
"If the user asks you to open, inspect, interact with, or extract information from a website in a real browser, use browser_use.\n"
|
||||
"If the user asks you to inspect files, browse the web, or run terminal commands, use the matching tool instead of guessing. "
|
||||
"If a required tool fails or is unavailable, say that clearly and do not pretend you completed the action.\n"
|
||||
"Retrieved second-brain context for this request:\n"
|
||||
f"{second_brain_lines}\n"
|
||||
"Saved user profile preferences:\n"
|
||||
f"{profile_lines}\n"
|
||||
"Recent memory:\n"
|
||||
f"{memory_lines}"
|
||||
),
|
||||
"message": message,
|
||||
"model": runtime.default_model,
|
||||
"model": runtime.local_model if runtime.model_provider == "local" else runtime.zai_model,
|
||||
"memory": memory,
|
||||
"available_tools": [tool.name for tool in runtime.tools if tool.enabled],
|
||||
"available_tools": tool_names,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user