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.
77 lines
2.5 KiB
Python
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)
|