Docs / docs (push) Successful in 42s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m42s
Playwright Tests / test-playwright (2, 2) (push) Failing after 10s
pre-commit / pre-commit (push) Failing after 2m33s
Test Backend / test-backend (push) Failing after 2m48s
Compose Smoke Test / test-compose (push) Failing after 9s
Playwright Tests / merge-reports (push) Failing after 2m17s
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. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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)
|