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/deepseek/client.py
4
5"""
6DeepSeek API client with streaming chat and automatic retry logic.
7
8Purpose
9-------
10Provides :class:`DeepSeekClient` which wraps the OpenAI-compatible
11SDK, handles streaming of reasoning content, text, tool calls, and
12token usage, and retries transient errors with exponential backoff.
13
14Moved from ``oc_chat/core/client.py`` in Phase C.2b. The old path
15remains as a compat re-export shim until C.3f.
16
17Responsibilities
18----------------
19- :class:`DeepSeekClient` — concrete :class:`BaseClient` for DeepSeek;
20 exposes ``stream_chat``, ``one_shot``, ``list_models``.
21- :func:`_is_retryable` — decide whether an SDK exception is worth
22 retrying (auth errors no, transient HTTP yes).
23- :func:`_deep_usage_dict` — defensively normalise pydantic
24 ``CompletionUsage`` objects into plain nested dicts so consumers
25 can rely on ``isinstance(field, dict)``.
26- Streaming chunk shaping: yield ``("reasoning", str)``,
27 ``("content", str)``, ``("tool_call", dict)``, ``("usage", dict)``
28 in API arrival order, then a terminal ``("finish_reason", str)``.
29
30Diagnostics
31-----------
32Domain: OC_CHAT.CLIENT
33Levels:
34 L2 — lifecycle events
35 L3 — semantic details
36 L4 — deep tracing
37
38Contracts
39---------
40- Up to 3 attempts on transient errors with delays of 1/2/4 seconds.
41- Authentication failures fail fast — never retried.
42- ``stream_chat`` is a generator; iteration completes when the SDK's
43 stream ends.
44- Usage objects are always converted to plain dicts before being
45 yielded (no pydantic models leak past this client).
46
47Dependencies
48------------
49- oc_diagnostics (structured debug logging via _dbg)
50- openai (API client for chat completions and streaming)
51- option_c_agent_contracts (lazy; SupportsCancellation type of the stream_chat cancellation token)
52- httpx (lazy; HTTP client with trust_env off, handed to the OpenAI SDK)
53
54See Also
55--------
56oc_chat/docs/conventions/CONTRACTS.md §5 Streaming chunk tuples — concrete impl
57of the stream_chat tuple contract.
58"""
59from __future__ import annotations
60
61import time
62from collections.abc import Iterator
63from typing import Any, TYPE_CHECKING
64
65from oc_diagnostics import _dbg
66
67from openai import OpenAI, AuthenticationError, APIStatusError
68
69if TYPE_CHECKING:
70 from option_c_agent_contracts import SupportsCancellation
71
72_RETRY_DELAYS = (1, 2, 4) # seconds between attempts (3 attempts total)
73_RETRYABLE_STATUS = {429, 500, 502, 503, 524}
74
75
76def _is_retryable(exc: Exception) -> bool:
77 """Return True if *exc* is a transient error worth retrying."""
78 if isinstance(exc, AuthenticationError):
79 return False # wrong key — retrying won't help
80 if isinstance(exc, APIStatusError):
81 return exc.status_code in _RETRYABLE_STATUS
82 return True # network errors, timeouts, etc.
83
84
85def _deep_usage_dict(usage) -> dict:
86 """Convert a pydantic ``CompletionUsage`` (or any object) to a
87 plain nested dict, recursively ensuring pydantic model fields
88 (``prompt_tokens_details``, ``completion_tokens_details``, etc.)
89 become plain dicts rather than remaining as pydantic model objects.
90
91 This prevents silent ``isinstance(field, dict)`` failures in consumers.
92 """
93 # Preferred: pydantic v2's ``model_dump`` already deep-converts.
94 if hasattr(usage, "model_dump"):
95 raw = usage.model_dump()
96 else:
97 # Fallback: ``dict()`` may leave nested pydantic models as-is.
98 raw = dict(usage)
99
100 # Recursively walk the result and coerce any remaining model objects.
101 return _ensure_plain(raw)
102
103
104def _ensure_plain(value):
105 """Recursively convert pydantic / non-primitive objects to plain dicts/lists."""
106 func = "_ensure_plain"
107 if isinstance(value, dict):
108 return {k: _ensure_plain(v) for k, v in value.items()}
109 if isinstance(value, list):
110 return [_ensure_plain(v) for v in value]
111 if value is None or isinstance(value, (str, int, float, bool, bytes)):
112 return value
113 # Pydantic model (model_dump wasn't called at a deeper level)
114 if hasattr(value, "model_dump"):
115 return _ensure_plain(value.model_dump())
116 # Try __dict__ as last resort
117 try:
118 return _ensure_plain(value.__dict__)
119 except AttributeError as e:
120 # VALIDATION_ERROR — slots-only or built-in object; coerce via str()
121 _dbg("OC_CHAT.CLIENT", func, f"VALIDATION_ERROR: {type(value).__name__}: {type(e).__name__}: {e}", 3)
122 return str(value)
123
124
126 """Streaming client for the DeepSeek chat-completion API.
127
128 Handles retries internally (``max_retries=0`` on the SDK so we
129 control backoff), yields typed chunks via :meth:`stream_chat`, and
130 provides a convenience :meth:`one_shot` for non-streaming requests
131 (e.g. context compression summaries).
132 """
133
134 def __init__(self, api_key: str, base_url: str = "https://api.deepseek.com"):
135 func = "__init__"
136 import httpx
137 # M8.0 WU-4c (F72/F79): inject an httpx client with trust_env=False so the
138 # transport-trust env family (HTTP(S)_PROXY/ALL_PROXY/SSL_CERT_*/
139 # REQUESTS_CA_BUNDLE) cannot MITM or redirect the provider transcript.
140 self._client = OpenAI(
141 api_key=api_key, base_url=base_url, max_retries=0,
142 http_client=httpx.Client(trust_env=False),
143 )
144 _dbg("OC_CHAT.CLIENT", func, f"DeepSeekClient base_url={base_url}", 3)
145
146 def list_models(self) -> list[str]:
147 """Return a list of model IDs available from the API."""
148 models = self._client.models.list()
149 return [m.id for m in models]
150
152 self,
153 messages: list[dict],
154 model: str,
155 effort: str | None = None,
156 extra_body: dict | None = None,
157 tools: list[dict] | None = None,
158 max_tokens: int | None = None,
159 *,
160 cancellation: "SupportsCancellation | None" = None,
161 ) -> Iterator[tuple[str, Any]]:
162 """Stream a chat completion, yielding typed ``(chunk_type, data)`` tuples.
163
164 Yields ``(chunk_type, data)`` tuples:
165
166 * ``("reasoning", str)`` — chain-of-thought token
167 * ``("content", str)`` — answer token
168 * ``("tool_call", dict)`` — completed tool call ``{id, name, arguments}``
169 * ``("usage", dict)`` — token usage (final chunk)
170 * ``("finish_reason", str)`` — why generation stopped, yielded last.
171 ``"length"`` means the model was cut off at ``max_tokens``; without
172 it a truncation is indistinguishable from a normal completion.
173
174 Retries up to 3 times with exponential backoff on transient
175 errors. Raises immediately on authentication errors (HTTP 401).
176
177 If *cancellation* is provided, the token is polled before yielding
178 each chunk; a cancelled token raises :class:`CancellationError` and
179 the stream is abandoned (Phase O3c).
180 """
181 from collections import OrderedDict
182 kwargs = OrderedDict()
183 kwargs["model"] = model
184 if tools:
185 kwargs["tools"] = tools
186 kwargs["stream"] = True
187 kwargs["messages"] = messages
188 if effort is not None:
189 kwargs["reasoning_effort"] = effort
190 if extra_body:
191 kwargs["extra_body"] = extra_body
192 if max_tokens is not None:
193 kwargs["max_tokens"] = max_tokens
194
195 last_exc = None
196 for attempt, delay in enumerate((*_RETRY_DELAYS, None)):
197 try:
198 stream = self._client.chat.completions.create(**kwargs)
199 break
200 except Exception as exc:
201 last_exc = exc
202 if not _is_retryable(exc) or delay is None:
203 raise
204 print(f"\n[Retrying in {delay}s — {type(exc).__name__}]", flush=True)
205 time.sleep(delay)
206 else:
207 raise last_exc # all retries exhausted
208
209 # Accumulate tool call fragments (arrive across multiple chunks)
210 tool_calls_acc: dict[int, dict] = {}
211 finish_reason: str | None = None
212
213 for chunk in stream:
214 if cancellation is not None:
215 cancellation.raise_if_cancelled()
216 # Usage is usually on the last (empty) chunk
217 if hasattr(chunk, "usage") and chunk.usage is not None:
218 yield ("usage", _deep_usage_dict(chunk.usage))
219
220 if not chunk.choices:
221 continue
222
223 choice = chunk.choices[0]
224 # Carried on the terminal chunk only. Keep the last non-empty value:
225 # the stream ends with it, and it is the sole signal distinguishing
226 # "the model finished" from "the model ran out of budget".
227 if getattr(choice, "finish_reason", None):
228 finish_reason = choice.finish_reason
229
230 delta = choice.delta
231
232 # DeepSeek chain-of-thought field
233 if hasattr(delta, "reasoning_content") and delta.reasoning_content:
234 yield ("reasoning", delta.reasoning_content)
235
236 if delta.content:
237 yield ("content", delta.content)
238
239 # Tool call fragments
240 if hasattr(delta, "tool_calls") and delta.tool_calls:
241 for tc in delta.tool_calls:
242 idx = tc.index
243 if idx not in tool_calls_acc:
244 tool_calls_acc[idx] = {"id": "", "name": "", "arguments": ""}
245 if getattr(tc, "id", None):
246 tool_calls_acc[idx]["id"] = tc.id
247 if tc.function:
248 if tc.function.name:
249 tool_calls_acc[idx]["name"] = tc.function.name
250 if tc.function.arguments:
251 tool_calls_acc[idx]["arguments"] += tc.function.arguments
252
253 # Yield completed tool calls in index order
254 for idx in sorted(tool_calls_acc):
255 yield ("tool_call", tool_calls_acc[idx])
256
257 # Last, so a consumer that reads it has the full stream in hand — in
258 # particular whether the truncation landed mid-tool_call (leaving
259 # ``arguments`` an unparseable JSON fragment).
260 if finish_reason is not None:
261 yield ("finish_reason", finish_reason)
262
264 self,
265 messages: list,
266 model: str,
267 *,
268 effort: str = None,
269 extra_body: dict = None,
270 max_tokens: int = None,
271 ) -> str:
272 """Non-streaming single call with retry (used for context compression summaries).
273
274 Accepts the same shaping kwargs as :meth:`stream_chat` so callers
275 (e.g. context compression in ``oc_chat/core/context.py``) can pass
276 ``effort="low"`` and a small ``max_tokens`` cap for cheap summary
277 calls.
278 """
279 from collections import OrderedDict
280 kwargs: "OrderedDict[str, object]" = OrderedDict()
281 kwargs["model"] = model
282 kwargs["stream"] = False
283 kwargs["messages"] = messages
284 if effort is not None:
285 kwargs["reasoning_effort"] = effort
286 if extra_body:
287 kwargs["extra_body"] = extra_body
288 if max_tokens is not None:
289 kwargs["max_tokens"] = max_tokens
290
291 last_exc = None
292 for attempt, delay in enumerate((*_RETRY_DELAYS, None)):
293 try:
294 resp = self._client.chat.completions.create(**kwargs)
295 return resp.choices[0].message.content or ""
296 except Exception as exc:
297 last_exc = exc
298 if not _is_retryable(exc) or delay is None:
299 raise
300 time.sleep(delay)
301 raise last_exc
__init__(self, str api_key, str base_url="https://api.deepseek.com")
Definition client.py:134
str one_shot(self, list messages, str model, *, str effort=None, dict extra_body=None, int max_tokens=None)
Definition client.py:271
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:161
dict _deep_usage_dict(usage)
Definition client.py:85
bool _is_retryable(Exception exc)
Definition client.py:76