6DeepSeek API client with streaming chat and automatic retry logic.
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.
14Moved from ``oc_chat/core/client.py`` in Phase C.2b. The old path
15remains as a compat re-export shim until C.3f.
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)``.
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
44- Usage objects are always converted to plain dicts before being
45 yielded (no pydantic models leak past this client).
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)
56oc_chat/docs/conventions/CONTRACTS.md §5 Streaming chunk tuples — concrete impl
57of the stream_chat tuple contract.
59from __future__
import annotations
62from collections.abc
import Iterator
63from typing
import Any, TYPE_CHECKING
65from oc_diagnostics
import _dbg
67from openai
import OpenAI, AuthenticationError, APIStatusError
70 from option_c_agent_contracts
import SupportsCancellation
72_RETRY_DELAYS = (1, 2, 4)
73_RETRYABLE_STATUS = {429, 500, 502, 503, 524}
77 """Return True if *exc* is a transient error worth retrying."""
78 if isinstance(exc, AuthenticationError):
80 if isinstance(exc, APIStatusError):
81 return exc.status_code
in _RETRYABLE_STATUS
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.
91 This prevents silent ``isinstance(field, dict)`` failures in consumers.
94 if hasattr(usage,
"model_dump"):
95 raw = usage.model_dump()
105 """Recursively convert pydantic / non-primitive objects to plain dicts/lists."""
106 func =
"_ensure_plain"
107 if isinstance(value, dict):
109 if isinstance(value, list):
111 if value
is None or isinstance(value, (str, int, float, bool, bytes)):
114 if hasattr(value,
"model_dump"):
119 except AttributeError
as e:
121 _dbg(
"OC_CHAT.CLIENT", func, f
"VALIDATION_ERROR: {type(value).__name__}: {type(e).__name__}: {e}", 3)
126 """Streaming client for the DeepSeek chat-completion API.
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).
134 def __init__(self, api_key: str, base_url: str =
"https://api.deepseek.com"):
141 api_key=api_key, base_url=base_url, max_retries=0,
142 http_client=httpx.Client(trust_env=
False),
144 _dbg(
"OC_CHAT.CLIENT", func, f
"DeepSeekClient base_url={base_url}", 3)
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]
153 messages: list[dict],
155 effort: str |
None =
None,
156 extra_body: dict |
None =
None,
157 tools: list[dict] |
None =
None,
158 max_tokens: int |
None =
None,
160 cancellation:
"SupportsCancellation | None" =
None,
161 ) -> Iterator[tuple[str, Any]]:
162 """Stream a chat completion, yielding typed ``(chunk_type, data)`` tuples.
164 Yields ``(chunk_type, data)`` tuples:
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.
174 Retries up to 3 times with exponential backoff on transient
175 errors. Raises immediately on authentication errors (HTTP 401).
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).
181 from collections
import OrderedDict
182 kwargs = OrderedDict()
183 kwargs[
"model"] = model
185 kwargs[
"tools"] = tools
186 kwargs[
"stream"] =
True
187 kwargs[
"messages"] = messages
188 if effort
is not None:
189 kwargs[
"reasoning_effort"] = effort
191 kwargs[
"extra_body"] = extra_body
192 if max_tokens
is not None:
193 kwargs[
"max_tokens"] = max_tokens
196 for attempt, delay
in enumerate((*_RETRY_DELAYS,
None)):
198 stream = self.
_client.chat.completions.create(**kwargs)
200 except Exception
as exc:
204 print(f
"\n[Retrying in {delay}s — {type(exc).__name__}]", flush=
True)
210 tool_calls_acc: dict[int, dict] = {}
211 finish_reason: str |
None =
None
214 if cancellation
is not None:
215 cancellation.raise_if_cancelled()
217 if hasattr(chunk,
"usage")
and chunk.usage
is not None:
220 if not chunk.choices:
223 choice = chunk.choices[0]
227 if getattr(choice,
"finish_reason",
None):
228 finish_reason = choice.finish_reason
233 if hasattr(delta,
"reasoning_content")
and delta.reasoning_content:
234 yield (
"reasoning", delta.reasoning_content)
237 yield (
"content", delta.content)
240 if hasattr(delta,
"tool_calls")
and delta.tool_calls:
241 for tc
in delta.tool_calls:
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
249 tool_calls_acc[idx][
"name"] = tc.function.name
250 if tc.function.arguments:
251 tool_calls_acc[idx][
"arguments"] += tc.function.arguments
254 for idx
in sorted(tool_calls_acc):
255 yield (
"tool_call", tool_calls_acc[idx])
260 if finish_reason
is not None:
261 yield (
"finish_reason", finish_reason)
269 extra_body: dict =
None,
270 max_tokens: int =
None,
272 """Non-streaming single call with retry (used for context compression summaries).
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
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
287 kwargs[
"extra_body"] = extra_body
288 if max_tokens
is not None:
289 kwargs[
"max_tokens"] = max_tokens
292 for attempt, delay
in enumerate((*_RETRY_DELAYS,
None)):
294 resp = self.
_client.chat.completions.create(**kwargs)
295 return resp.choices[0].message.content
or ""
296 except Exception
as exc:
list[str] list_models(self)
__init__(self, str api_key, str base_url="https://api.deepseek.com")
str one_shot(self, list messages, str model, *, str effort=None, dict extra_body=None, int 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)
dict _deep_usage_dict(usage)
bool _is_retryable(Exception exc)