Files
code_of_conquest_dnd/api/app/ollama_client.py
Phillip Tarrant f7c51b79da fix(api): best-effort call logging + non-JSON 200 → ModelError (final-review)
- 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>
2026-07-10 08:45:39 -05:00

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()