"""DeepSeek provider — primary LLM supplier (TD-04, 架构文档 §5). DeepSeek exposes an OpenAI-compatible API at ``https://api.deepseek.com/v1``. Its usage block reports ``prompt_cache_hit_tokens`` / ``prompt_cache_miss_tokens``, which is what makes the cache-prefix discipline in :mod:`packages.llm.prompts` measurable (NFR-COST-03). """ from __future__ import annotations from typing import Any import httpx from packages.llm.errors import ( ProviderAuthError, ProviderBadRequestError, ProviderError, ProviderRateLimitedError, ProviderServerError, ProviderTimeoutError, ) from packages.llm.types import ProviderRequest, ProviderResponse, Usage class DeepSeekProvider: """OpenAI-compatible chat-completions client.""" def __init__( self, *, api_key: str, base_url: str = "https://api.deepseek.com/v1", default_model: str = "deepseek-v4-flash", client: httpx.AsyncClient | None = None, name: str = "deepseek", ) -> None: self._api_key = api_key self._base_url = base_url.rstrip("/") self._default_model = default_model self._client = client or httpx.AsyncClient(timeout=httpx.Timeout(60.0)) self._name = name @property def name(self) -> str: return self._name @property def default_model(self) -> str: return self._default_model def _payload(self, request: ProviderRequest) -> dict[str, Any]: payload: dict[str, Any] = { "model": request.model, "messages": [{"role": m.role, "content": m.content} for m in request.messages], "temperature": request.temperature, "max_tokens": request.max_tokens, "stream": request.stream, # 思考模式一律显式声明。V4 的默认值是「开启」,漏传就会拿到 # 慢一个数量级、且思考 token 也按输出价计费的行为。 "thinking": {"type": "enabled" if request.thinking else "disabled"}, } if request.json_mode: payload["response_format"] = {"type": "json_object"} return payload async def complete(self, request: ProviderRequest) -> ProviderResponse: url = f"{self._base_url}/chat/completions" headers = { "Authorization": f"Bearer {self._api_key}", "Content-Type": "application/json", } try: response = await self._client.post( url, json=self._payload(request), headers=headers, timeout=request.timeout_seconds, ) except httpx.TimeoutException as exc: raise ProviderTimeoutError( self._name, f"request timed out after {request.timeout_seconds}s" ) from exc except httpx.HTTPError as exc: raise ProviderServerError(self._name, f"transport error: {exc}") from exc self._raise_for_status(response) return self._parse(response.json(), fallback_model=request.model) def _raise_for_status(self, response: httpx.Response) -> None: status = response.status_code if status < 400: return detail = _short_body(response) if status == 429: raise ProviderRateLimitedError(self._name, detail, status_code=status) if status in (401, 403): raise ProviderAuthError(self._name, detail, status_code=status) if status >= 500: raise ProviderServerError(self._name, detail, status_code=status) raise ProviderBadRequestError(self._name, detail, status_code=status) def _parse(self, data: dict[str, Any], *, fallback_model: str) -> ProviderResponse: try: choice = data["choices"][0] text = choice["message"]["content"] or "" except (KeyError, IndexError, TypeError) as exc: raise ProviderServerError(self._name, f"malformed response: {data!r}") from exc usage_raw: dict[str, Any] = data.get("usage") or {} cached = int( usage_raw.get("prompt_cache_hit_tokens") or (usage_raw.get("prompt_tokens_details") or {}).get("cached_tokens") or 0 ) # 思考 token 已包含在 completion_tokens 里,单独记下来是为了能回答 # 「这个场景的输出成本里有多少花在用户看不见的推理上」 reasoning = int( (usage_raw.get("completion_tokens_details") or {}).get("reasoning_tokens") or 0 ) usage = Usage( tokens_in=int(usage_raw.get("prompt_tokens", 0)), tokens_out=int(usage_raw.get("completion_tokens", 0)), tokens_cached=cached, tokens_reasoning=reasoning, ) return ProviderResponse( text=text, model=str(data.get("model") or fallback_model), usage=usage, finish_reason=choice.get("finish_reason"), ) async def aclose(self) -> None: await self._client.aclose() def _short_body(response: httpx.Response, limit: int = 300) -> str: try: body = response.text except (UnicodeDecodeError, httpx.ResponseNotRead): # pragma: no cover return f"HTTP {response.status_code}" return f"HTTP {response.status_code}: {body[:limit]}" __all__ = ["DeepSeekProvider", "ProviderError"]