Files
app/backend/fluksio/ai/client.py
T
stroblme 7240b7b823 Add the assistant: a chat that drives the MCP tools in-process
A provider set under Settings (OpenAI-compatible; key encrypted in
flow-data/ai.json) turns on an Assistant panel in the sidebar. Each turn runs
the FastMCP tools in-process as the person chatting, streams the rounds over
SSE and keeps one transcript per user. The panel floats on any screen and
shares the editor's lane: a settings panel keeps the top half, the chat the
bottom. The MCP client is now built once, for the endpoint and the chat.
2026-09-16 23:28:23 +02:00

77 lines
2.5 KiB
Python

"""One OpenAI-compatible provider call, and the model listing beside it.
Chat completions with function tools is the dialect every hosted provider and
every local server speaks — OpenAI, Anthropic's compatibility endpoint,
OpenRouter, Ollama, vLLM — so one client covers them without a provider SDK.
The admin types the URL; the model never chooses where a request goes.
"""
from __future__ import annotations
from typing import Any
from urllib.parse import urlparse
import httpx
class ProviderError(Exception):
"""The provider answered, and the answer was a refusal."""
def check_base(api_base: str) -> None:
parsed = urlparse(api_base)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise ValueError("The provider URL must be an http(s) URL")
def _headers(api_key: str) -> dict[str, str]:
return {"Authorization": f"Bearer {api_key}"}
def _detail(response: httpx.Response) -> str:
try:
error = response.json().get("error")
except ValueError:
error = None
if isinstance(error, dict):
error = error.get("message")
text = str(error or response.text or response.reason_phrase)
return f"{response.status_code}: {text[:300]}"
async def chat_completion(
*,
api_base: str,
api_key: str,
model: str,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
timeout: float = 120.0,
) -> dict[str, Any]:
"""``POST {api_base}/chat/completions``, the whole answer at once."""
body: dict[str, Any] = {"model": model, "messages": messages}
if tools:
body["tools"] = tools
async with httpx.AsyncClient(timeout=timeout) as http:
response = await http.post(
api_base.rstrip("/") + "/chat/completions",
json=body,
headers=_headers(api_key),
)
if response.is_error:
raise ProviderError(_detail(response))
result: dict[str, Any] = response.json()
return result
async def list_models(api_base: str, api_key: str, timeout: float = 15.0) -> list[str]:
"""The ids ``GET {api_base}/models`` offers, for the settings screen."""
async with httpx.AsyncClient(timeout=timeout) as http:
response = await http.get(
api_base.rstrip("/") + "/models", headers=_headers(api_key)
)
if response.is_error:
raise ProviderError(_detail(response))
data = response.json().get("data") or []
return sorted(str(m["id"]) for m in data if isinstance(m, dict) and "id" in m)