"""Call Ollama's /api/chat (non-streaming) with a single retry. Callers see success or ModelError (charter §12/§13 — one retry, then fail; the client degrades on any non-200). Built so a streaming mode is an additive path later. """ import httpx from . import config class ModelError(Exception): """The model call failed after one retry (transport / timeout / non-2xx / empty).""" def chat( model: str, messages: list[dict], options: dict, *, think: bool | None = None, client: httpx.Client | None = None, ) -> str: payload: dict = {"model": model, "messages": messages, "stream": False, "options": options} if think is not None: payload["think"] = think owns = client is None http = client or httpx.Client( base_url=config.ollama_base_url(), timeout=config.ollama_timeout_seconds() ) try: last = "no attempt made" for _ in range(2): # one try + one retry (§12: never more than once) try: resp = http.post("/api/chat", json=payload) resp.raise_for_status() content = resp.json().get("message", {}).get("content", "") if content.strip(): return content last = "empty response from model" except (httpx.HTTPError, ValueError) as exc: # httpx.HTTPError: TimeoutException, TransportError, HTTPStatusError. # ValueError: resp.json() raises json.JSONDecodeError (a ValueError # subclass) on a 200 with a non-JSON body (e.g. a proxy error page) — # treat that as a failed attempt too, not an uncaught escape (§12/§13). last = f"{type(exc).__name__}: {exc}" raise ModelError(last) finally: if owns: http.close()