"""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. The call is streamed, so the answer reaches the browser as it is written. """ from __future__ import annotations import json from collections.abc import AsyncIterator 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]}" class Assembler: """The streamed chunks put back together into the message a plain call returns. The providers stream the same ``data:`` lines and differ only in the margins: OpenRouter interleaves ``:`` comment lines, a thinking model's reasoning arrives in a field of its own and is left out, OpenAI and Anthropic send a tool call's arguments in pieces where Ollama sends the call whole, and some leave out the call's ``index``. """ def __init__(self) -> None: self.content = "" self.calls: list[dict[str, Any]] = [] def feed(self, line: str) -> str: """One line of the stream; returns the text it adds to the answer.""" if not line.startswith("data:"): return "" data = line[5:].strip() if not data or data == "[DONE]": return "" try: chunk = json.loads(data) except ValueError as exc: raise ProviderError(f"sent {data[:300]}") from exc error = chunk.get("error") if error: if isinstance(error, dict): error = error.get("message", error) raise ProviderError(str(error)[:300]) choices = chunk.get("choices") or [{}] delta = choices[0].get("delta") or {} for piece in delta.get("tool_calls") or []: self._merge(piece) text: str = delta.get("content") or "" self.content += text return text def _merge(self, piece: dict[str, Any]) -> None: index = piece.get("index", len(self.calls)) while len(self.calls) <= index: self.calls.append( { "id": "", "type": "function", "function": {"name": "", "arguments": ""}, } ) call = self.calls[index] call["id"] = piece.get("id") or call["id"] function = piece.get("function") or {} call["function"]["name"] = function.get("name") or call["function"]["name"] call["function"]["arguments"] += function.get("arguments") or "" def message(self) -> dict[str, Any]: return { "role": "assistant", "content": self.content or None, "tool_calls": self.calls or None, } async def stream_completion( *, api_base: str, api_key: str, model: str, messages: list[dict[str, Any]], tools: list[dict[str, Any]], timeout: float = 120.0, ) -> AsyncIterator[str | dict[str, Any]]: """``POST {api_base}/chat/completions`` streamed. Yields each piece of the answer's text as it arrives, then the whole message. The timeout is per chunk, so a long answer is not cut off. """ body: dict[str, Any] = {"model": model, "messages": messages, "stream": True} if tools: body["tools"] = tools assembler = Assembler() async with ( httpx.AsyncClient(timeout=timeout) as http, http.stream( "POST", api_base.rstrip("/") + "/chat/completions", json=body, headers=_headers(api_key), ) as response, ): if response.is_error: await response.aread() raise ProviderError(_detail(response)) async for line in response.aiter_lines(): text = assembler.feed(line) if text: yield text yield assembler.message() 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)