ADR-036: OllamaBackend Uses the Official ollama Client¶
Status: Accepted
Date: 2026-06-21
Related: agentic_v2/models/backends_local.py (OllamaBackend),
agentic_v2/models/backends.py, tests/models/test_ollama_canonical.py,
pyproject.toml. Supersedes the hand-rolled httpx transport introduced
alongside ADR-008; complements ADR-023 Phase 3 (canonical complete_chat
dict shape).
Context¶
OllamaBackend reached the local Ollama server by hand-rolling httpx
requests against the native /api/generate and /api/chat endpoints,
parsing the raw JSON, and manually navigating message.content /
message.thinking / message.tool_calls. That bespoke parsing duplicated
work the official ollama Python client already does, and it had drifted
from the two other Ollama code paths in the repo:
tools/llm/provider_adapters.py::call_ollama— a second hand-rolledurllibpath that does not handle the reasoningthinkingchannel.agentic_v2/langchain/model_builders.py::build_ollama_model— already useslangchain-ollama, which wraps the officialollamaclient.
As a result the official ollama package was already installed in every
resolved environment (transitively via langchain-ollama), yet the
always-on native backend ignored it and re-implemented its wire handling —
the worst of both worlds. The native backend also ignored OLLAMA_BASE_URL,
which the LangChain builder and the documented model layer (MODEL_LAYER.md)
both honour.
A repo-wide review of Ollama usage (EK + ARP) concluded that ARP — an
application that already ships the dependency — should adopt the SDK inside
the backend, while ExecutionKit (a deliberately zero-dependency, OpenAI-wire
library) should not. This ADR records the ARP half. The agreed scope was
"contained": the SDK stays inside OllamaBackend; the LLMBackend interface
and the canonical return shapes do not change.
Decision¶
Re-back OllamaBackend on ollama.AsyncClient:
complete()callsclient.generate(...);complete_chat()callsclient.chat(...). Both passoptions={"num_predict", "temperature"}and an opt-inthinkparameter.- The canonical
complete_chatdict (ADR-023 Phase 3) is preserved exactly: separate top-levelthinking,contentcarrying only the answer text,tool_callsas a JSON-ablelist[dict](orNone),finish_reason"stop", the requestedmodelname, and_raw_ollama._raw_ollamais nowChatResponse.model_dump()rather than the raw server JSON — a field-superset that still carries the full upstream message verbatim. - SDK
Message.ToolCallobjects are normalised back to dicts viamodel_dump(), keeping the{"function": {"name", "arguments"}}shape downstream consumers andek_adaptersalready expect. ollama.ResponseErroris mapped toRuntimeError, matching the existingcall_ollamaconvention (previously this path surfaced a rawhttpx.HTTPStatusError).base_urlnow honoursOLLAMA_BASE_URL(via the secrets layer), aligning the native backend with the LangChain builder and the documented contract.ollama>=0.5.1,<1is promoted from a transitive dependency to a direct core dependency, so the unconditionally-registered local backend no longer relies on the optionallangchainextra being installed.>=0.5.1is the floor that guarantees thethinkparameter exists.
Open decision: think policy¶
think defaults to None, which leaves the parameter unset and preserves
the pre-SDK behaviour (a model only emits thinking if it does so on its
own). OllamaBackend._resolve_think(model_name) is the seam for a future
per-model policy — e.g. enabling think only for known reasoning families
(qwen3 / deepseek-r1 / phi4-reasoning), or catching the "model does not
support thinking" error and retrying without it. Today it returns the
backend-level setting unchanged.
Consequences¶
- One transport, not two-and-a-half: the bespoke
httpxJSON parsing and the manualthinkingfold-out are gone; protocol changes track the SDK. - The reasoning
thinkingchannel, structuredtool_calls, and (via the SDK)format=-style structured outputs are now reachable without further wire-format work. - Behaviour change, intentional: HTTP error responses now raise
RuntimeErrorinstead ofhttpx.HTTPStatusError, andOLLAMA_BASE_URLis now honoured by the native backend. _raw_ollamashape changed from raw server JSON to the SDK model dump. Onlytests/models/test_ollama_canonical.pyasserts on it;ek_adaptersround-trips read the canonical fields, not_raw_ollama.backends_local.pyremains in the coverageomitset (it needs a live server); the canonical/contract behaviour is covered bytests/models/test_ollama_canonical.pywith the SDK client stubbed.tools/llm/provider_adapters.py::call_ollamais intentionally left as-is for this contained change; converging it onto the SDK is a follow-up.