oc_chat
Loading...
Searching...
No Matches
provider.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# providers/deepseek/provider.py
4
5"""
6DeepSeek provider implementation.
7
8Purpose
9-------
10Concrete :class:`oc_chat.core.provider_plugin.ProviderPlugin` for the
11DeepSeek API. Encapsulates everything provider-specific: client
12construction, the model/tier registry, the ``extra_body`` shape for
13thinking, the prefix-cache sentinel embedding, and prompt-cache
14hit/miss extraction.
15
16Importing the parent package (``import providers.deepseek``) triggers
17self-registration via ``DeepSeekProvider.register()``. The rest of
18the app retrieves it via
19``oc_chat.core.provider_plugin.registry.get("deepseek")``.
20
21Responsibilities
22----------------
23- :class:`DeepSeekProvider` — implement every abstract method on
24 :class:`ProviderPlugin` for DeepSeek.
25- :meth:`build_client` — construct a :class:`DeepSeekClient` with
26 the supplied API key and base URL.
27- :meth:`build_extra_body` — produce the
28 ``{"thinking": {"type": "enabled"}}`` shape when the target model
29 supports thinking.
30- :meth:`transform_outgoing_messages` — deprecated no-op since plan 135
31 (the dump is gone; ``render_request`` is the seam now).
32- :meth:`parse_usage` — convert raw usage dicts to :class:`UsageStats`
33 via the shared cache-hit/miss helper.
34- :meth:`tier` / :meth:`models` — expose the static registries.
35- :meth:`fetch_live_models` — list live models, dropping retired ids.
36- :meth:`migrate_model_id` — map a retired alias to its V4 replacement
37 for saved-session migration (plan 135 §4.6).
38
39Diagnostics
40-----------
41Domain: OC_CHAT.CLIENT
42Levels:
43 L2 — lifecycle events
44 L3 — semantic details
45 L4 — deep tracing
46
47Contracts
48---------
49- ``transform_outgoing_messages`` returns its input unchanged (plan 135
50 neutered the dump; it must never re-embed).
51- ``build_extra_body`` returns ``None`` for models whose ``ModelCaps``
52 do not advertise ``supports_thinking``.
53- ``tier`` returns ``None`` (not a model id) when the requested tier
54 is not in :data:`TIER_MAP`.
55- ``migrate_model_id`` returns ``None`` for live ids; for a retired alias
56 it returns ``(replacement, notice)`` with a distinct per-alias target.
57- All abstract surface lives here; the bundle's other modules are
58 pure data / SDK plumbing.
59
60Dependencies
61------------
62- oc_diagnostics (structured debug logging via _dbg)
63- option_c_agent_contracts (ProviderPlugin base class and the ModelCaps, UsageStats and BaseClient types)
64- providers.deepseek.client (DeepSeekClient returned by build_client)
65- providers.deepseek.cache_usage (prompt-cache hit and miss extraction for parse_usage)
66- providers.deepseek.models (MODELS, TIER_MAP and RETIRED_ALIASES registries)
67"""
68from __future__ import annotations
69
70from oc_diagnostics import _dbg
71
72from option_c_agent_contracts import (
73 ProviderPlugin,
74 ModelCaps,
75 UsageStats,
76 BaseClient,
77)
78
79from .client import DeepSeekClient
80from .cache_usage import usage_prompt_cache_hit_miss
81from .models import MODELS, TIER_MAP, RETIRED_ALIASES
82
83
84# DeepSeek API base URL. Override by setting ``DEEPSEEK_BASE_URL`` in the
85# environment — ClientInterface picks the env value over this default.
86_DEFAULT_BASE_URL = "https://api.deepseek.com"
87
88
89# DeepSeek prefix-cache sentinel — duplicated here from
90# oc_chat/core/session.py during the C.2 transition. session.py is still
91# the authority for the current session-mutating helper; once C.3 routes
92# message transformation through the provider, this becomes the canonical
93# location and session.py imports from here.
94TOOL_SCHEMA_SENTINEL = "<!--dc:embedded-tool-schemas-->"
95
96
97class DeepSeekProvider(ProviderPlugin):
98 name = "deepseek"
99 # OCT-LINT: disable=no_hardcoded_secrets reason="Env var NAME to read the key from, not a credential"
100 api_key_env_var = "DEEPSEEK_API_KEY"
101 base_url_env_var = "DEEPSEEK_BASE_URL"
102 default_base_url = _DEFAULT_BASE_URL
103 # Plan 135 §4.6 (operator-confirmed): the default is the reasoning tier — a
104 # deliberate capability↑/cost↑/latency↑ change from the old deepseek-chat.
105 default_model = "deepseek-v4-pro"
106
107 # ── Client construction ──────────────────────────────────────────────────
108
109 def build_client(self, api_key: str, base_url: str) -> BaseClient:
110 func = "build_client"
111 _dbg("OC_CHAT.CLIENT", func, f"DeepSeekProvider base_url={base_url}", 2)
112 return DeepSeekClient(api_key=api_key, base_url=base_url)
113
114 # ── Model registry ───────────────────────────────────────────────────────
115
116 def models(self) -> dict[str, ModelCaps]:
117 return MODELS
118
119 def fetch_live_models(self, client: BaseClient) -> list[str]:
120 # Plan 135 §4.6: drop retired ids so live discovery cannot re-surface
121 # deepseek-chat / deepseek-reasoner in /models after their removal.
122 return [m for m in client.list_models() if m not in RETIRED_ALIASES]
123
124 def default_caps(self) -> ModelCaps:
125 # Permissive default for newly-discovered DeepSeek models: tools yes,
126 # 64K context to be safe. Specific known models override via MODELS.
127 return ModelCaps(
128 supports_thinking=False,
129 supports_reasoning=False,
130 supports_tools=True,
131 max_context_tokens=65_536,
132 )
133
134 def migrate_model_id(self, model_id: str) -> tuple[str, str] | None:
135 func = "migrate_model_id"
136 replacement = RETIRED_ALIASES.get(model_id)
137 if replacement is None:
138 return None
139 # Distinct per-alias notice (plan 135 §4.6) — never collapse to one target.
140 tier_desc = "reasoning tier" if replacement == "deepseek-v4-pro" else "cheap tier"
141 notice = (
142 f"[Model '{model_id}' is retired — migrated this session to "
143 f"'{replacement}' ({tier_desc}).]"
144 )
145 _dbg("OC_CHAT.CLIENT", func, f"{model_id} -> {replacement}", 2)
146 return replacement, notice
147
148 # ── Tier mapping ─────────────────────────────────────────────────────────
149
150 def tier(self, name: str) -> str | None:
151 return TIER_MAP.get(name)
152
153 # ── Per-call shaping ─────────────────────────────────────────────────────
154
156 self,
157 model: str,
158 *,
159 enable_thinking: bool,
160 effort: str | None, # noqa: ARG002 — DeepSeek puts effort in `reasoning_effort` kwarg, not extra_body
161 ) -> dict | None:
162 """Return the ``extra_body`` payload that toggles thinking on
163 DeepSeek's thinking-capable models (e.g. deepseek-v4-pro).
164
165 Effort is *not* part of ``extra_body`` for DeepSeek — it's a
166 top-level ``reasoning_effort`` SDK kwarg, handled by the client
167 directly. This method only owns the thinking flag.
168 """
169 if not enable_thinking:
170 return None
171 caps = MODELS.get(model)
172 if caps is None or not caps.supports_thinking:
173 return None
174 return {"thinking": {"type": "enabled"}}
175
177 self,
178 messages: list[dict],
179 tool_schemas: list[dict] | None,
180 ) -> list[dict]:
181 """Return *messages* unchanged (plan 135 §4.6 — neutered).
182
183 Deprecated: this hook used to embed the full tool-schema JSON dump into
184 ``messages[0]`` for DeepSeek's disk prefix cache. Plan 135 removed the
185 dump everywhere — ``session.messages[0]`` already carries the slim
186 policy + compact tool map (the full schema travels via the API ``tools``
187 parameter), and :meth:`render_request` is the provider seam now. This is
188 kept as a no-op for interface stability; it must never re-embed.
189 """
190 return messages
191
192 # ── Optional extension hooks ─────────────────────────────────────────────
193
194 _warned_opts: set = set()
195
196 def translate_generation_options(self, opts: "GenerationOptions") -> dict | None: # type: ignore[override]
197 from option_c_agent_contracts import GenerationOptions
198 func = "translate_generation_options"
199 _SUPPORTED = {"temperature", "top_p", "seed"}
200 d: dict = {}
201 for fname, val in opts.model_dump(exclude_none=True).items():
202 if fname in _SUPPORTED:
203 d[fname] = val
204 elif fname not in self._warned_opts:
205 self._warned_opts.add(fname)
206 _dbg("OC_CHAT.CLIENT", func,
207 f"DeepSeek ignores option {fname!r}", 2)
208 return d if d else None
209
210 # ── Response parsing ─────────────────────────────────────────────────────
211
212 def parse_usage(self, usage: dict) -> UsageStats:
213 if not isinstance(usage, dict):
214 return UsageStats()
215 hit, miss = usage_prompt_cache_hit_miss(usage)
216 details = usage.get("completion_tokens_details") or {}
217 return UsageStats(
218 prompt_tokens=int(usage.get("prompt_tokens") or 0),
219 completion_tokens=int(usage.get("completion_tokens") or 0),
220 total_tokens=int(usage.get("total_tokens") or 0),
221 cache_hit_tokens=hit,
222 cache_miss_tokens=miss,
223 # Plan 135 §3.9: separately-billed thinking tokens when reported.
224 reasoning_tokens=int((details.get("reasoning_tokens") if isinstance(details, dict) else 0) or 0),
225 )
tuple[str, str]|None migrate_model_id(self, str model_id)
Definition provider.py:134
dict|None translate_generation_options(self, "GenerationOptions" opts)
Definition provider.py:196
UsageStats parse_usage(self, dict usage)
Definition provider.py:212
list[dict] transform_outgoing_messages(self, list[dict] messages, list[dict]|None tool_schemas)
Definition provider.py:180
dict|None build_extra_body(self, str model, *, bool enable_thinking, str|None effort)
Definition provider.py:161
BaseClient build_client(self, str api_key, str base_url)
Definition provider.py:109
list[str] fetch_live_models(self, BaseClient client)
Definition provider.py:119