6Ollama HTTP client — native /api/chat streaming.
10:class:`OllamaClient` implements :class:`BaseClient` against Ollama's
11native ``/api/chat`` endpoint (not the OpenAI-compat shim). Streaming is
12parsed via :func:`providers.ollama.ndjson.parse_ndjson_stream`.
16- :class:`OllamaClient` — :class:`BaseClient` for the Ollama provider.
17 ``stream_chat``, ``one_shot``, ``list_models``, ``show_model``.
18- :func:`_connect` — build an ``HTTPConnection`` / ``HTTPSConnection``
19 from the base URL (stdlib only, zero extra deps).
20- Friendly ``RuntimeError`` when the Ollama daemon is not running.
26 L2 — lifecycle events (connect, response status)
27 L3 — request details (model, endpoint, chunk counts)
32- ``stream_chat`` yields ``("content", str)``, ``("tool_call", dict)``,
33 and ``("usage", dict)`` tuples. ``"reasoning"`` is never yielded (local
34 models don't separate reasoning from content in the Ollama protocol).
35- ``tool_call`` dict: ``{"id": str, "name": str, "arguments": str}``
36 where *arguments* is a JSON string (matches the rest of OC Chat).
37- ``one_shot`` returns a plain ``str``; uses ``stream: false``.
38- ``list_models`` hits ``GET /api/tags``; ``show_model`` hits
40- Connection errors raise ``RuntimeError`` with a user-friendly message
41 suggesting ``ollama serve``.
45- oc_diagnostics (_dbg)
46- providers.ollama.ndjson (parse_ndjson_stream)
47- http.client (HTTP and HTTPS connections to the Ollama daemon)
48- json (encoding request bodies and tool-call arguments, decoding responses)
49- urllib.parse (splitting the base URL into scheme, host and port)
50- oc_chat.core.egress_guard (lazy; refusing a forbidden internal address as the Ollama host)
51- option_c_agent_contracts (lazy; SupportsCancellation type of the stream_chat cancellation token)
53from __future__
import annotations
58from collections.abc
import Iterator
61from typing
import TYPE_CHECKING
63from oc_diagnostics
import _dbg
65from .ndjson
import parse_ndjson_stream
68 from option_c_agent_contracts
import SupportsCancellation
71def _connect(base_url: str, timeout: int = 300) -> http.client.HTTPConnection:
72 """Return an ``HTTPConnection`` or ``HTTPSConnection`` for *base_url*."""
73 parsed = urllib.parse.urlparse(base_url)
74 host = parsed.hostname
or "localhost"
75 port = parsed.port
or (443
if parsed.scheme ==
"https" else 11434)
80 if is_forbidden_provider_host(host):
81 raise PermissionError(f
"Ollama base_url is a forbidden internal address: {base_url}")
82 if parsed.scheme ==
"https":
83 return http.client.HTTPSConnection(host, port, timeout=timeout)
84 return http.client.HTTPConnection(host, port, timeout=timeout)
91_ERROR_BODY_TRUNCATE = 2048
95 resp: http.client.HTTPResponse, base_url: str, method: str, path: str
97 """Raise :class:`RuntimeError` when *resp* is a 4xx/5xx HTTP response.
99 Plan 077: the Ollama daemon returns clear, useful error bodies on 4xx/5xx
100 (e.g. ``{"error":"model 'X' not found"}``). The previous implementation
101 handed the response straight to the NDJSON parser, which silently yielded
102 zero chunks for an error body, masquerading as "no response content
103 received". This helper surfaces the daemon's actual message instead.
105 Reads the body eagerly (truncated to :data:`_ERROR_BODY_TRUNCATE`) and
106 raises so callers never see a partially-consumed response on the error
107 path. Callers consuming success responses (200-299) get the response
110 func =
"_raise_for_status"
111 if resp.status < 400:
113 body = resp.read(_ERROR_BODY_TRUNCATE + 1)
114 if len(body) > _ERROR_BODY_TRUNCATE:
115 body_text = body[:_ERROR_BODY_TRUNCATE].decode(
"utf-8", errors=
"replace") +
"...[truncated]"
117 body_text = body.decode(
"utf-8", errors=
"replace")
121 f
"HTTP {resp.status} on {method} {path}: {body_text!r}",
126 f
"Ollama API error {resp.status} on {method} {base_url}{path}: {body_text}"
130def _post(base_url: str, path: str, body: dict, timeout: int = 300) -> http.client.HTTPResponse:
131 """POST *body* as JSON to *base_url + path*. Returns the response.
133 Raises :class:`RuntimeError` on connection failure (unreachable daemon)
134 and on HTTP 4xx/5xx responses — the latter via :func:`_raise_for_status`,
135 which embeds the daemon's response body in the exception message
136 (plan 077; previously the error body was silently swallowed).
139 _dbg(
"OC_CHAT.CLIENT", func, f
"POST {base_url}{path}", 3)
141 conn =
_connect(base_url, timeout=timeout)
142 data = json.dumps(body).encode()
147 headers={
"Content-Type":
"application/json"},
149 resp = conn.getresponse()
150 _dbg(
"OC_CHAT.CLIENT", func, f
"status={resp.status}", 3)
151 except (ConnectionRefusedError, OSError, TimeoutError)
as exc:
153 f
"Ollama unreachable at {base_url!r} — is 'ollama serve' running? ({exc})"
159def _get(base_url: str, path: str, timeout: int = 30) -> http.client.HTTPResponse:
160 """GET *base_url + path*. Returns the response.
162 Raises identically to :func:`_post` — see its docstring for the HTTP-error
163 semantics (plan 077). The same swallow-on-error bug applied here for
164 ``/api/tags`` / ``/api/show`` callers.
167 _dbg(
"OC_CHAT.CLIENT", func, f
"GET {base_url}{path}", 3)
169 conn =
_connect(base_url, timeout=timeout)
170 conn.request(
"GET", path)
171 resp = conn.getresponse()
172 _dbg(
"OC_CHAT.CLIENT", func, f
"status={resp.status}", 3)
173 except (ConnectionRefusedError, OSError, TimeoutError)
as exc:
175 f
"Ollama unreachable at {base_url!r} — is 'ollama serve' running? ({exc})"
182 """Native Ollama HTTP client implementing :class:`BaseClient`.
184 Uses Ollama's ``/api/chat`` endpoint directly (not the OpenAI-compat
185 shim at ``/v1/chat/completions``) to preserve Ollama-specific fields
186 like ``eval_count``, ``eval_duration``, and ``tool_calls`` in their
191 def __init__(self, api_key: str =
"", base_url: str =
"http://localhost:11434") ->
None:
195 _dbg(
"OC_CHAT.CLIENT", func, f
"OllamaClient base_url={self.base_url!r}", 3)
201 messages: list[dict],
204 effort: str |
None =
None,
205 extra_body: dict |
None =
None,
206 tools: list[dict] |
None =
None,
207 max_tokens: int |
None =
None,
208 cancellation:
"SupportsCancellation | None" =
None,
209 ) -> Iterator[tuple[str, Any]]:
210 """Stream a chat completion via ``POST /api/chat``.
212 Yields ``(chunk_type, data)`` tuples:
214 * ``("content", str)`` — one or more tokens of assistant text
215 * ``("tool_call", dict)`` — ``{id, name, arguments}`` where
216 *arguments* is a JSON *string* (matching the OC Chat contract)
217 * ``("usage", dict)`` — usage figures from the final chunk
218 * ``("finish_reason", str)`` — the final chunk's ``done_reason``;
219 ``"length"`` means generation stopped at ``num_predict``
221 If *cancellation* is provided, the token is polled before yielding
222 each chunk; a cancelled token raises :class:`CancellationError` and
223 the NDJSON stream is abandoned (Phase O3c).
226 payload: dict[str, Any] = {
228 "messages": messages,
232 payload[
"tools"] = tools
235 for k, v
in extra_body.items():
236 if k ==
"options" and "options" in payload
and isinstance(v, dict):
237 payload[
"options"] = {**payload[
"options"], **v}
241 if max_tokens
is not None:
242 payload.setdefault(
"options", {})[
"num_predict"] = max_tokens
244 _dbg(
"OC_CHAT.CLIENT", func,
245 f
"model={model} tools={'yes' if tools else 'no'} max_tokens={max_tokens}", 2)
250 for chunk
in parse_ndjson_stream(resp):
251 if cancellation
is not None:
252 cancellation.raise_if_cancelled()
254 msg = chunk.get(
"message")
or {}
255 content = msg.get(
"content")
or ""
258 yield (
"content", content)
261 if chunk.get(
"done")
and msg.get(
"tool_calls"):
262 for i, tc
in enumerate(msg[
"tool_calls"]):
263 fn = tc.get(
"function")
or {}
264 args = fn.get(
"arguments", {})
266 arguments_str = json.dumps(args)
if isinstance(args, dict)
else str(args)
267 yield (
"tool_call", {
268 "id": f
"ollama_tc_{i}",
269 "name": fn.get(
"name",
""),
270 "arguments": arguments_str,
274 if chunk.get(
"done"):
275 usage: dict[str, Any] = {
276 "prompt_tokens": chunk.get(
"prompt_eval_count", 0),
277 "completion_tokens": chunk.get(
"eval_count", 0),
279 chunk.get(
"prompt_eval_count", 0)
280 + chunk.get(
"eval_count", 0)
283 "prompt_eval_count": chunk.get(
"prompt_eval_count", 0),
284 "prompt_eval_duration": chunk.get(
"prompt_eval_duration", 0),
285 "eval_count": chunk.get(
"eval_count", 0),
286 "eval_duration": chunk.get(
"eval_duration", 0),
287 "total_duration": chunk.get(
"total_duration", 0),
289 yield (
"usage", usage)
294 if chunk.get(
"done_reason"):
295 yield (
"finish_reason", chunk[
"done_reason"])
297 _dbg(
"OC_CHAT.CLIENT", func, f
"stream done — {chunk_count} chunks", 3)
303 messages: list[dict],
306 effort: str |
None =
None,
307 extra_body: dict |
None =
None,
308 max_tokens: int |
None =
None,
310 """Non-streaming completion via ``POST /api/chat`` with ``stream: false``."""
312 payload: dict[str, Any] = {
314 "messages": messages,
317 if max_tokens
is not None:
318 payload[
"options"] = {
"num_predict": max_tokens}
320 _dbg(
"OC_CHAT.CLIENT", func, f
"model={model}", 2)
323 data = json.loads(raw)
324 return (data.get(
"message")
or {}).get(
"content")
or ""
329 """Return model names from ``GET /api/tags``."""
334 data = json.loads(raw)
335 names = [m[
"name"]
for m
in data.get(
"models", [])]
336 _dbg(
"OC_CHAT.CLIENT", func, f
"{len(names)} models", 3)
340 except Exception
as exc:
341 _dbg(
"OC_CHAT.CLIENT", func, f
"list_models failed: {exc}", 2)
347 """Return raw metadata from ``POST /api/show``."""
349 _dbg(
"OC_CHAT.CLIENT", func, f
"name={name!r}", 3)
350 resp =
_post(self.
base_url,
"/api/show", {
"name": name}, timeout=30)
352 return json.loads(raw)
str one_shot(self, list[dict] messages, str model, *, str|None effort=None, dict|None extra_body=None, int|None max_tokens=None)
list[str] list_models(self)
dict show_model(self, str name)
Iterator[tuple[str, Any]] stream_chat(self, list[dict] messages, str model, *, str|None effort=None, dict|None extra_body=None, list[dict]|None tools=None, int|None max_tokens=None, "SupportsCancellation | None" cancellation=None)
None __init__(self, str api_key="", str base_url="http://localhost:11434")
http.client.HTTPResponse _get(str base_url, str path, int timeout=30)
http.client.HTTPResponse _post(str base_url, str path, dict body, int timeout=300)
http.client.HTTPConnection _connect(str base_url, int timeout=300)
None _raise_for_status(http.client.HTTPResponse resp, str base_url, str method, str path)