"""Ollama over its HTTP API.
Verified against Ollama 0.32.5 with `qwen2.5:32b` on 2026-07-31: passing a JSON
Schema as `format` to `POST /api/chat` returns exactly the requested keys with
enums respected — no prose wrapper, no code fence. That is a decode-time
constraint rather than a polite request, and it is why this design does not
need a "strip the markdown fence" step.
**Stdlib `urllib` on purpose.** Adding `httpx` for one POST would be a new
runtime dependency, and leaning on `requests` — present only transitively via
yfinance — would be worse.
"""
import json
import logging
import urllib.error
import urllib.request
from trader.errors import ConfigError
from trader.llm.base import LlmError
__all__ = ["OllamaProvider"]
_logger = logging.getLogger("trader.llm")
#: What Ollama returns when the model name is not pulled, as distinct from any
#: other HTTP failure — the one status worth a specific message, because
#: "pull the model" is actionable and "the server is unhappy" is not.
_MODEL_NOT_FOUND = 404
#: Measured 2026-08-13: the largest real prompt this app has sent is ~1,516
#: tokens and the median is 1,089, yet `OllamaProvider` never set `num_ctx`,
#: so qwen2.5:32b loaded at its 32,768-token default — a KV cache sized for
#: 22x more context than any prompt uses, and the likely cause of a constantly
#: loaded ~24GB resident model on a 32GB machine. 4096 is still 2.7x the
#: largest measured prompt, leaving headroom for prompt growth without
#: reverting to the 32k default.
DEFAULT_NUM_CTX = 4096
#: A floor, not a tuned value. `num_ctx` bounds the model's whole context
#: window — system prompt, user prompt, and the JSON-schema response budget
#: together — and Ollama truncates silently on overflow rather than raising,
#: which would corrupt a decision with no error. There is no dynamic check
#: against the actual prompt at construction time (the prompt does not exist
#: yet), so this is a cheap, static tripwire against a fat-fingered config
#: value (e.g. `num_ctx: 4` from confusing it with a small unrelated
#: setting) rather than a promise that any accepted value is large enough for
#: every prompt this app will ever send.
_MIN_NUM_CTX = 512
[docs]
class OllamaProvider:
"""Talks to a local Ollama server."""
def __init__(
self,
*,
model: str,
base_url: str = "http://localhost:11434",
timeout_seconds: int = 120,
temperature: float = 0.0,
seed: int = 7,
num_ctx: int = DEFAULT_NUM_CTX,
) -> None:
# `bool` is an `int` subclass and would sail past an `isinstance(...,
# int)` check — the same trap `max_news_age_hours` guards against in
# `LlmStrategy.__init__`.
if isinstance(num_ctx, bool) or not isinstance(num_ctx, int):
raise ConfigError(f"num_ctx must be an int, got {num_ctx!r}.")
if num_ctx < _MIN_NUM_CTX:
raise ConfigError(
f"num_ctx must be at least {_MIN_NUM_CTX}, got {num_ctx!r}. A "
"context window this small would silently truncate any real "
"prompt this app sends, corrupting a decision with no error."
)
self.model = model
self._base_url = base_url.rstrip("/")
self._timeout = timeout_seconds
self._temperature = temperature
self._seed = seed
self._num_ctx = num_ctx
def _open(self, url: str, payload: dict, timeout: int):
"""POST JSON. A seam, so tests need no server."""
request = urllib.request.Request(
url,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
)
return urllib.request.urlopen(request, timeout=timeout)
[docs]
def generate_json(
self, system: str, user: str, schema: dict[str, object]
) -> dict[str, object]:
"""Ask the model and return parsed JSON matching `schema`."""
payload = {
"model": self.model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"stream": False,
"format": schema,
# Temperature 0 and a fixed seed for as much repeatability as a
# local model offers. Not a guarantee — quantised inference is not
# bit-reproducible — but it removes deliberate sampling variance.
# `num_ctx` bounds the KV cache Ollama allocates for this model;
# see `DEFAULT_NUM_CTX` for why it is set at all.
"options": {
"temperature": self._temperature,
"seed": self._seed,
"num_ctx": self._num_ctx,
},
}
try:
with self._open(f"{self._base_url}/api/chat", payload, self._timeout) as r:
body = json.loads(r.read())
except urllib.error.HTTPError as exc:
if exc.code == _MODEL_NOT_FOUND:
_logger.warning("ollama has no model %s pulled", self.model)
raise LlmError(
f"Ollama does not have the model {self.model!r}. "
f"Run: ollama pull {self.model}"
) from exc
_logger.warning("ollama returned HTTP %s for model %s", exc.code, self.model)
raise LlmError(f"Ollama returned HTTP {exc.code}: {exc}") from exc
except urllib.error.URLError as exc:
_logger.warning("ollama unreachable at %s: %s", self._base_url, exc.reason)
raise LlmError(
f"No Ollama server at {self._base_url} ({exc.reason}). "
"Start it with: brew services start ollama (or: ollama serve)"
) from exc
except TimeoutError as exc:
_logger.warning(
"ollama timed out after %ss for model %s", self._timeout, self.model
)
raise LlmError(
f"Ollama timed out after {self._timeout}s for model "
f"{self.model}. Raise timeout_seconds, or use a smaller model."
) from exc
except Exception as exc: # noqa: BLE001 - transport raises many types
_logger.warning("ollama call failed: %s", exc)
raise LlmError(f"Ollama call failed: {exc}") from exc
content = (body.get("message") or {}).get("content")
if not isinstance(content, str):
_logger.warning("ollama response for %s had no message content", self.model)
raise LlmError(f"Ollama response had no message content: {body!r}")
try:
parsed = json.loads(content)
except json.JSONDecodeError as exc:
# Content itself is not logged: it is model output, potentially
# large, and this project keeps prompts/completions out of
# durable logs on principle.
_logger.warning("ollama response for %s was not valid JSON", self.model)
raise LlmError(
f"Ollama returned content that is not valid JSON: {content[:200]!r}"
) from exc
if not isinstance(parsed, dict):
_logger.warning(
"ollama response for %s was a JSON %s, not an object",
self.model,
type(parsed).__name__,
)
raise LlmError(f"Expected a JSON object, got {type(parsed).__name__}")
return parsed
[docs]
def health(self) -> str:
"""Round-trip a trivial prompt. Returns the model name."""
self.generate_json(
"Reply with an empty JSON object.",
"ping",
{"type": "object", "properties": {}},
)
return self.model