6Generic OpenAI-compatible streaming client with retry.
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.
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
23- :func:`_plain` — recursively coerce SDK/pydantic objects to plain
24 dicts so no model object leaks past this client.
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.
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)
60from __future__
import annotations
63from collections
import OrderedDict
64from collections.abc
import Iterator, Mapping
65from typing
import Any, TYPE_CHECKING
67from oc_diagnostics
import _dbg
69from openai
import OpenAI, AuthenticationError, APIStatusError
72 from option_c_agent_contracts
import SupportsCancellation
74_RETRY_DELAYS = (1, 2, 4)
75_RETRYABLE_STATUS = {429, 500, 502, 503, 524}
79 """Return True if *exc* is a transient error worth retrying."""
80 if isinstance(exc, AuthenticationError):
82 if isinstance(exc, APIStatusError):
83 return exc.status_code
in _RETRYABLE_STATUS
88 """Recursively convert pydantic / arbitrary objects to plain dicts and lists."""
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)):
96 if hasattr(value,
"model_dump"):
97 return _plain(value.model_dump())
99 return _plain(value.__dict__)
100 except AttributeError
as exc:
102 _dbg(
"OC_CHAT.CLIENT", func,
103 f
"VALIDATION_ERROR: {type(value).__name__}: {type(exc).__name__}: {exc}", 3)
108 """Streaming chat client for one OpenAI-compatible lane."""
115 extra_headers: Mapping[str, str] |
None =
None,
116 lane_name: str =
"openai_compat",
129 default_headers=self.
_headers or None,
130 http_client=httpx.Client(trust_env=
False),
132 _dbg(
"OC_CHAT.CLIENT", func,
133 f
"OpenAICompatClient lane={lane_name} base_url={base_url} "
134 f
"extra_headers={sorted(self._headers)}", 3)
139 """Return the lane's live model ids (the account's own catalogue)."""
140 return [m.id
for m
in self.
_client.models.list()]
146 messages: list[dict],
148 effort: str |
None =
None,
149 extra_body: dict |
None =
None,
150 tools: list[dict] |
None =
None,
151 max_tokens: int |
None =
None,
153 cancellation:
"SupportsCancellation | None" =
None,
154 ) -> Iterator[tuple[str, Any]]:
155 """Stream a chat completion, yielding typed ``(chunk_type, data)`` tuples.
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.
162 If *cancellation* is provided the token is polled before yielding
163 each chunk; a cancelled token raises and the stream is abandoned.
166 kwargs:
"OrderedDict[str, Any]" = OrderedDict()
167 kwargs[
"model"] = model
169 kwargs[
"tools"] = tools
170 kwargs[
"stream"] =
True
171 kwargs[
"messages"] = messages
172 if effort
is not None:
173 kwargs[
"reasoning_effort"] = effort
175 kwargs[
"extra_body"] = extra_body
176 if max_tokens
is not None:
177 kwargs[
"max_tokens"] = max_tokens
181 tool_calls_acc: dict[int, dict] = {}
182 finish_reason: str |
None =
None
185 if cancellation
is not None:
186 cancellation.raise_if_cancelled()
188 usage = getattr(chunk,
"usage",
None)
189 if usage
is not None:
190 yield (
"usage",
_plain(usage))
192 choices = getattr(chunk,
"choices",
None)
200 if getattr(choice,
"finish_reason",
None):
201 finish_reason = choice.finish_reason
203 delta = getattr(choice,
"delta",
None)
209 reasoning = getattr(delta,
"reasoning_content",
None)
211 yield (
"reasoning", reasoning)
213 if getattr(delta,
"content",
None):
214 yield (
"content", delta.content)
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):
221 fn = getattr(tc,
"function",
None)
223 if getattr(fn,
"name",
None):
224 acc[
"name"] = fn.name
225 if getattr(fn,
"arguments",
None):
226 acc[
"arguments"] += fn.arguments
228 for idx
in sorted(tool_calls_acc):
229 yield (
"tool_call", tool_calls_acc[idx])
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)
241 messages: list[dict],
244 effort: str |
None =
None,
245 extra_body: dict |
None =
None,
246 max_tokens: int |
None =
None,
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
256 kwargs[
"extra_body"] = extra_body
257 if max_tokens
is not None:
258 kwargs[
"max_tokens"] = max_tokens
261 return resp.choices[0].message.content
or ""
266 """Call ``chat.completions.create`` with the backoff schedule.
268 Shared by both call paths so the "auth fails fast, transient
269 retries" rule cannot drift between streaming and one-shot.
271 func =
"_create_with_retry"
272 last_exc: Exception |
None =
None
273 for delay
in (*_RETRY_DELAYS,
None):
275 return self.
_client.chat.completions.create(**kwargs)
276 except Exception
as exc:
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)
289__all__ = [
"OpenAICompatClient"]
None __init__(self, str api_key, str base_url, *, Mapping[str, str]|None extra_headers=None, str lane_name="openai_compat")
Any _create_with_retry(self, "OrderedDict[str, Any]" kwargs)
list[str] list_models(self)
str one_shot(self, list[dict] messages, str model, *, str|None effort=None, dict|None extra_body=None, int|None max_tokens=None)
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)
bool _is_retryable(Exception exc)