feat: backend orkestrasyonunu ve arac entegrasyonlarini genislet

This commit is contained in:
2026-03-22 04:45:43 +03:00
parent d07bc365f5
commit 5f4c19a18d
25 changed files with 3750 additions and 82 deletions

View File

@@ -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

View File

@@ -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,
}