oc_chat
Loading...
Searching...
No Matches
client.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# providers/ollama/client.py
4
5"""
6Ollama HTTP client — native /api/chat streaming.
7
8Purpose
9-------
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`.
13
14Responsibilities
15----------------
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.
21
22Diagnostics
23-----------
24Domain: OC_CHAT.CLIENT
25Levels:
26 L2 — lifecycle events (connect, response status)
27 L3 — request details (model, endpoint, chunk counts)
28 L4 — deep tracing
29
30Contracts
31---------
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
39 ``POST /api/show``.
40- Connection errors raise ``RuntimeError`` with a user-friendly message
41 suggesting ``ollama serve``.
42
43Dependencies
44------------
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)
52"""
53from __future__ import annotations
54
55import http.client
56import json
57import urllib.parse
58from collections.abc import Iterator
59from typing import Any
60
61from typing import TYPE_CHECKING
62
63from oc_diagnostics import _dbg
64
65from .ndjson import parse_ndjson_stream
66
67if TYPE_CHECKING:
68 from option_c_agent_contracts import SupportsCancellation
69
70
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)
76 # M8.0 WU-4c (F72): refuse a forbidden internal address (IMDS / link-local /
77 # multicast / reserved). Ollama on loopback/LAN stays legal. Defense-in-depth:
78 # ClientInterface already refuses such a base_url at bind time.
79 from oc_chat.core.egress_guard import is_forbidden_provider_host
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)
85
86
87#: Maximum bytes of error-body to include in raised exception messages.
88#: 5xx error pages from a reverse-proxy in front of Ollama can be huge HTML;
89#: 2 KB is enough for the daemon's "what went wrong" hint (which is always
90#: at the head of the body) without blowing out logs or tracebacks.
91_ERROR_BODY_TRUNCATE = 2048
92
93
95 resp: http.client.HTTPResponse, base_url: str, method: str, path: str
96) -> None:
97 """Raise :class:`RuntimeError` when *resp* is a 4xx/5xx HTTP response.
98
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.
104
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
108 untouched.
109 """
110 func = "_raise_for_status"
111 if resp.status < 400:
112 return
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]"
116 else:
117 body_text = body.decode("utf-8", errors="replace")
118 _dbg(
119 "OC_CHAT.CLIENT",
120 func,
121 f"HTTP {resp.status} on {method} {path}: {body_text!r}",
122 1,
123 override=True,
124 )
125 raise RuntimeError(
126 f"Ollama API error {resp.status} on {method} {base_url}{path}: {body_text}"
127 )
128
129
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.
132
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).
137 """
138 func = "_post"
139 _dbg("OC_CHAT.CLIENT", func, f"POST {base_url}{path}", 3)
140 try:
141 conn = _connect(base_url, timeout=timeout)
142 data = json.dumps(body).encode()
143 conn.request(
144 "POST",
145 path,
146 body=data,
147 headers={"Content-Type": "application/json"},
148 )
149 resp = conn.getresponse()
150 _dbg("OC_CHAT.CLIENT", func, f"status={resp.status}", 3)
151 except (ConnectionRefusedError, OSError, TimeoutError) as exc:
152 raise RuntimeError(
153 f"Ollama unreachable at {base_url!r} — is 'ollama serve' running? ({exc})"
154 ) from exc
155 _raise_for_status(resp, base_url, "POST", path)
156 return resp
157
158
159def _get(base_url: str, path: str, timeout: int = 30) -> http.client.HTTPResponse:
160 """GET *base_url + path*. Returns the response.
161
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.
165 """
166 func = "_get"
167 _dbg("OC_CHAT.CLIENT", func, f"GET {base_url}{path}", 3)
168 try:
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:
174 raise RuntimeError(
175 f"Ollama unreachable at {base_url!r} — is 'ollama serve' running? ({exc})"
176 ) from exc
177 _raise_for_status(resp, base_url, "GET", path)
178 return resp
179
180
182 """Native Ollama HTTP client implementing :class:`BaseClient`.
183
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
187 native shape.
188 """
189
190 # OCT-LINT: disable=no_hardcoded_secrets reason="Empty-string default for optional api_key param, not a credential"
191 def __init__(self, api_key: str = "", base_url: str = "http://localhost:11434") -> None:
192 func = "__init__"
193 self.api_key = api_key # unused by Ollama; kept for BaseClient compat
194 self.base_url = base_url.rstrip("/")
195 _dbg("OC_CHAT.CLIENT", func, f"OllamaClient base_url={self.base_url!r}", 3)
196
197 # ── Streaming chat ──────────────────────────────────────────────────────
198
200 self,
201 messages: list[dict],
202 model: str,
203 *,
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``.
211
212 Yields ``(chunk_type, data)`` tuples:
213
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``
220
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).
224 """
225 func = "stream_chat"
226 payload: dict[str, Any] = {
227 "model": model,
228 "messages": messages,
229 "stream": True,
230 }
231 if tools:
232 payload["tools"] = tools
233 # Merge extra_body first (provider generation options like temperature, top_p)
234 if extra_body:
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}
238 else:
239 payload[k] = v
240 # max_tokens overrides num_predict specifically
241 if max_tokens is not None:
242 payload.setdefault("options", {})["num_predict"] = max_tokens
243
244 _dbg("OC_CHAT.CLIENT", func,
245 f"model={model} tools={'yes' if tools else 'no'} max_tokens={max_tokens}", 2)
246
247 resp = _post(self.base_url, "/api/chat", payload)
248 chunk_count = 0
249
250 for chunk in parse_ndjson_stream(resp):
251 if cancellation is not None:
252 cancellation.raise_if_cancelled()
253 chunk_count += 1
254 msg = chunk.get("message") or {}
255 content = msg.get("content") or ""
256
257 if content:
258 yield ("content", content)
259
260 # Tool calls arrive in the final (done=True) chunk
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", {})
265 # Ollama arguments is a dict; convert to JSON string for OC Chat
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,
271 })
272
273 # Emit usage from the final chunk
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),
278 "total_tokens": (
279 chunk.get("prompt_eval_count", 0)
280 + chunk.get("eval_count", 0)
281 ),
282 # Pass through raw Ollama perf fields for O5 parse_performance
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),
288 }
289 yield ("usage", usage)
290 # Ollama's terminal-chunk ``done_reason`` uses the same
291 # vocabulary as the OpenAI-compatible ``finish_reason``
292 # ("stop" / "length" / "tool_calls"), so it crosses the
293 # provider seam untranslated. "length" == num_predict hit.
294 if chunk.get("done_reason"):
295 yield ("finish_reason", chunk["done_reason"])
296
297 _dbg("OC_CHAT.CLIENT", func, f"stream done — {chunk_count} chunks", 3)
298
299 # ── One-shot (non-streaming) ────────────────────────────────────────────
300
302 self,
303 messages: list[dict],
304 model: str,
305 *,
306 effort: str | None = None,
307 extra_body: dict | None = None,
308 max_tokens: int | None = None,
309 ) -> str:
310 """Non-streaming completion via ``POST /api/chat`` with ``stream: false``."""
311 func = "one_shot"
312 payload: dict[str, Any] = {
313 "model": model,
314 "messages": messages,
315 "stream": False,
316 }
317 if max_tokens is not None:
318 payload["options"] = {"num_predict": max_tokens}
319
320 _dbg("OC_CHAT.CLIENT", func, f"model={model}", 2)
321 resp = _post(self.base_url, "/api/chat", payload)
322 raw = resp.read()
323 data = json.loads(raw)
324 return (data.get("message") or {}).get("content") or ""
325
326 # ── Model listing ───────────────────────────────────────────────────────
327
328 def list_models(self) -> list[str]:
329 """Return model names from ``GET /api/tags``."""
330 func = "list_models"
331 try:
332 resp = _get(self.base_url, "/api/tags")
333 raw = resp.read()
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)
337 return names
338 except RuntimeError:
339 raise
340 except Exception as exc:
341 _dbg("OC_CHAT.CLIENT", func, f"list_models failed: {exc}", 2)
342 return []
343
344 # ── Model info ──────────────────────────────────────────────────────────
345
346 def show_model(self, name: str) -> dict:
347 """Return raw metadata from ``POST /api/show``."""
348 func = "show_model"
349 _dbg("OC_CHAT.CLIENT", func, f"name={name!r}", 3)
350 resp = _post(self.base_url, "/api/show", {"name": name}, timeout=30)
351 raw = resp.read()
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)
Definition client.py:309
dict show_model(self, str name)
Definition client.py:346
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)
Definition client.py:209
None __init__(self, str api_key="", str base_url="http://localhost:11434")
Definition client.py:191
http.client.HTTPResponse _get(str base_url, str path, int timeout=30)
Definition client.py:159
http.client.HTTPResponse _post(str base_url, str path, dict body, int timeout=300)
Definition client.py:130
http.client.HTTPConnection _connect(str base_url, int timeout=300)
Definition client.py:71
None _raise_for_status(http.client.HTTPResponse resp, str base_url, str method, str path)
Definition client.py:96