- call_log._default_write: swallow any exception from the default file/ stdout sink and report to stderr, so a log-write failure (disk full, bad permissions, misconfigured CALL_LOG_PATH) never turns a successful narration into a 500 (charter §13). Injected write= sinks (used by tests) are left to surface their own errors. - ollama_client.chat: catch ValueError alongside httpx.HTTPError in the retry loop so a 200 response with a non-JSON body (JSONDecodeError is a ValueError subclass) counts as a failed attempt and falls through to ModelError after the one retry, instead of escaping chat() uncaught (charter §12 — one retry, then ModelError, nothing else escapes). - main.py: update stale docstrings — /dm/narrate is now fully wired (routing + model call + logging); the other four roles remain stubs. Regression tests added for both fixes (TDD: watched RED, then GREEN). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
51 lines
1.8 KiB
Python
51 lines
1.8 KiB
Python
"""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()
|