Source code for trader.llm.ollama

"""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