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/openai_compat/client.py
4
5"""
6Generic OpenAI-compatible streaming client with retry.
7
8Purpose
9-------
10:class:`OpenAICompatClient` is a :class:`BaseClient` for any lane that
11speaks the OpenAI chat-completions dialect. It is written FRESH on the
12shape the DeepSeek bundle proved (plan 215 P2) — this module imports
13nothing from ``providers.deepseek`` — and parameterises the two things
14that differ per lane: the base URL and the extra request headers three
15of the lanes need to pass their WAF or their attribution requirements.
16
17Responsibilities
18----------------
19- :class:`OpenAICompatClient` — ``stream_chat`` / ``one_shot`` /
20 ``list_models`` against one lane's endpoint.
21- :func:`_is_retryable` — auth errors fail fast; transient HTTP and
22 network errors retry.
23- :func:`_plain` — recursively coerce SDK/pydantic objects to plain
24 dicts so no model object leaks past this client.
25
26Diagnostics
27-----------
28Domain: OC_CHAT.CLIENT
29Levels:
30 L2 — lifecycle events
31 L3 — semantic details
32 L4 — deep tracing
33
34Contracts
35---------
36- Up to 3 attempts on transient errors, delays 1/2/4 seconds.
37- Authentication failures are NEVER retried — a wrong key does not
38 become right, and retrying burns a free lane's daily request budget.
39- ``stream_chat`` yields ``("reasoning", str)``, ``("content", str)``,
40 ``("tool_call", dict)``, ``("usage", dict)`` in arrival order, then a
41 terminal ``("finish_reason", str)`` — the CONTRACTS.md §5 tuple
42 contract, identical to the DeepSeek client's.
43- The SDK is constructed with ``max_retries=0`` (we own the backoff) and
44 an httpx client with ``trust_env=False``, so the transport-trust env
45 family (HTTP(S)_PROXY / ALL_PROXY / SSL_CERT_* / REQUESTS_CA_BUNDLE)
46 cannot MITM or redirect the provider transcript (M8.0 WU-4c, F72/F79).
47 This mirrors the DeepSeek client deliberately: a new egress path that
48 silently re-trusted the environment would be a security regression.
49- ``extra_headers`` carries lane metadata ONLY (User-Agent, attribution).
50 The API key travels via the SDK's own auth handling and is never
51 placed in a header dict here.
52
53Dependencies
54------------
55- oc_diagnostics (structured debug logging via _dbg)
56- openai (chat-completions client and streaming)
57- httpx (lazy; transport with trust_env disabled)
58- option_c_agent_contracts (lazy; SupportsCancellation type of the stream_chat cancellation token)
59"""
60from __future__ import annotations
61
62import time
63from collections import OrderedDict
64from collections.abc import Iterator, Mapping
65from typing import Any, TYPE_CHECKING
66
67from oc_diagnostics import _dbg
68
69from openai import OpenAI, AuthenticationError, APIStatusError
70
71if TYPE_CHECKING:
72 from option_c_agent_contracts import SupportsCancellation
73
74_RETRY_DELAYS = (1, 2, 4) # seconds between attempts (3 attempts total)
75_RETRYABLE_STATUS = {429, 500, 502, 503, 524}
76
77
78def _is_retryable(exc: Exception) -> bool:
79 """Return True if *exc* is a transient error worth retrying."""
80 if isinstance(exc, AuthenticationError):
81 return False # wrong key — retrying won't help
82 if isinstance(exc, APIStatusError):
83 return exc.status_code in _RETRYABLE_STATUS
84 return True # network errors, timeouts, etc.
85
86
87def _plain(value: Any) -> Any:
88 """Recursively convert pydantic / arbitrary objects to plain dicts and lists."""
89 func = "_plain"
90 if isinstance(value, dict):
91 return {k: _plain(v) for k, v in value.items()}
92 if isinstance(value, list):
93 return [_plain(v) for v in value]
94 if value is None or isinstance(value, (str, int, float, bool, bytes)):
95 return value
96 if hasattr(value, "model_dump"):
97 return _plain(value.model_dump())
98 try:
99 return _plain(value.__dict__)
100 except AttributeError as exc:
101 # VALIDATION_ERROR — slots-only or built-in object; coerce via str()
102 _dbg("OC_CHAT.CLIENT", func,
103 f"VALIDATION_ERROR: {type(value).__name__}: {type(exc).__name__}: {exc}", 3)
104 return str(value)
105
106
108 """Streaming chat client for one OpenAI-compatible lane."""
109
111 self,
112 api_key: str,
113 base_url: str,
114 *,
115 extra_headers: Mapping[str, str] | None = None,
116 lane_name: str = "openai_compat",
117 ) -> None:
118 func = "__init__"
119 import httpx
120
121 self.base_url = base_url
122 self.lane_name = lane_name
123 self._headers = dict(extra_headers or {})
124 # M8.0 WU-4c (F72/F79): trust_env=False — see the module Contracts block.
125 self._client = OpenAI(
126 api_key=api_key,
127 base_url=base_url,
128 max_retries=0,
129 default_headers=self._headers or None,
130 http_client=httpx.Client(trust_env=False),
131 )
132 _dbg("OC_CHAT.CLIENT", func,
133 f"OpenAICompatClient lane={lane_name} base_url={base_url} "
134 f"extra_headers={sorted(self._headers)}", 3)
135
136 # ── Model listing ────────────────────────────────────────────────────────
137
138 def list_models(self) -> list[str]:
139 """Return the lane's live model ids (the account's own catalogue)."""
140 return [m.id for m in self._client.models.list()]
141
142 # ── Streaming chat ───────────────────────────────────────────────────────
143
145 self,
146 messages: list[dict],
147 model: str,
148 effort: str | None = None,
149 extra_body: dict | None = None,
150 tools: list[dict] | None = None,
151 max_tokens: int | None = None,
152 *,
153 cancellation: "SupportsCancellation | None" = None,
154 ) -> Iterator[tuple[str, Any]]:
155 """Stream a chat completion, yielding typed ``(chunk_type, data)`` tuples.
156
157 See the module Contracts block for the tuple contract. *effort* is
158 forwarded as ``reasoning_effort`` only when the caller supplies it;
159 no lane in the shipped catalogue is configured for reasoning, so the
160 common path sends the model's as-shipped defaults.
161
162 If *cancellation* is provided the token is polled before yielding
163 each chunk; a cancelled token raises and the stream is abandoned.
164 """
165 func = "stream_chat"
166 kwargs: "OrderedDict[str, Any]" = OrderedDict()
167 kwargs["model"] = model
168 if tools:
169 kwargs["tools"] = tools
170 kwargs["stream"] = True
171 kwargs["messages"] = messages
172 if effort is not None:
173 kwargs["reasoning_effort"] = effort
174 if extra_body:
175 kwargs["extra_body"] = extra_body
176 if max_tokens is not None:
177 kwargs["max_tokens"] = max_tokens
178
179 stream = self._create_with_retry(kwargs)
180
181 tool_calls_acc: dict[int, dict] = {}
182 finish_reason: str | None = None
183
184 for chunk in stream:
185 if cancellation is not None:
186 cancellation.raise_if_cancelled()
187
188 usage = getattr(chunk, "usage", None)
189 if usage is not None:
190 yield ("usage", _plain(usage))
191
192 choices = getattr(chunk, "choices", None)
193 if not choices:
194 continue
195
196 choice = choices[0]
197 # Terminal-chunk only; keep the last non-empty value. This is the
198 # sole signal separating "the model finished" from "it ran out of
199 # budget", so a truncated tool_call is diagnosable downstream.
200 if getattr(choice, "finish_reason", None):
201 finish_reason = choice.finish_reason
202
203 delta = getattr(choice, "delta", None)
204 if delta is None:
205 continue
206
207 # Several lanes surface chain-of-thought on this non-standard
208 # field (the DeepSeek spelling); absent everywhere else.
209 reasoning = getattr(delta, "reasoning_content", None)
210 if reasoning:
211 yield ("reasoning", reasoning)
212
213 if getattr(delta, "content", None):
214 yield ("content", delta.content)
215
216 for tc in (getattr(delta, "tool_calls", None) or []):
217 idx = getattr(tc, "index", 0) or 0
218 acc = tool_calls_acc.setdefault(idx, {"id": "", "name": "", "arguments": ""})
219 if getattr(tc, "id", None):
220 acc["id"] = tc.id
221 fn = getattr(tc, "function", None)
222 if fn is not None:
223 if getattr(fn, "name", None):
224 acc["name"] = fn.name
225 if getattr(fn, "arguments", None):
226 acc["arguments"] += fn.arguments
227
228 for idx in sorted(tool_calls_acc):
229 yield ("tool_call", tool_calls_acc[idx])
230
231 # Last, so a consumer that reads it has the whole stream in hand.
232 if finish_reason is not None:
233 _dbg("OC_CHAT.CLIENT", func,
234 f"lane={self.lane_name} finish_reason={finish_reason}", 4)
235 yield ("finish_reason", finish_reason)
236
237 # ── One-shot (non-streaming) ─────────────────────────────────────────────
238
240 self,
241 messages: list[dict],
242 model: str,
243 *,
244 effort: str | None = None,
245 extra_body: dict | None = None,
246 max_tokens: int | None = None,
247 ) -> str:
248 """Non-streaming single call with retry (context-compression summaries)."""
249 kwargs: "OrderedDict[str, Any]" = OrderedDict()
250 kwargs["model"] = model
251 kwargs["stream"] = False
252 kwargs["messages"] = messages
253 if effort is not None:
254 kwargs["reasoning_effort"] = effort
255 if extra_body:
256 kwargs["extra_body"] = extra_body
257 if max_tokens is not None:
258 kwargs["max_tokens"] = max_tokens
259
260 resp = self._create_with_retry(kwargs)
261 return resp.choices[0].message.content or ""
262
263 # ── Retry ────────────────────────────────────────────────────────────────
264
265 def _create_with_retry(self, kwargs: "OrderedDict[str, Any]") -> Any:
266 """Call ``chat.completions.create`` with the backoff schedule.
267
268 Shared by both call paths so the "auth fails fast, transient
269 retries" rule cannot drift between streaming and one-shot.
270 """
271 func = "_create_with_retry"
272 last_exc: Exception | None = None
273 for delay in (*_RETRY_DELAYS, None):
274 try:
275 return self._client.chat.completions.create(**kwargs)
276 except Exception as exc:
277 last_exc = exc
278 if not _is_retryable(exc) or delay is None:
279 raise
280 # EXTERNAL_ERROR — transient upstream failure; back off and retry.
281 _dbg("OC_CHAT.CLIENT", func,
282 f"EXTERNAL_ERROR: lane={self.lane_name} retry in {delay}s: "
283 f"{type(exc).__name__}: {exc}", 2)
284 print(f"\n[Retrying in {delay}s - {type(exc).__name__}]", flush=True)
285 time.sleep(delay)
286 raise last_exc # unreachable: the delay=None arm re-raises
287
288
289__all__ = ["OpenAICompatClient"]
None __init__(self, str api_key, str base_url, *, Mapping[str, str]|None extra_headers=None, str lane_name="openai_compat")
Definition client.py:117
Any _create_with_retry(self, "OrderedDict[str, Any]" kwargs)
Definition client.py:265
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:247
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:154
bool _is_retryable(Exception exc)
Definition client.py:78