Files
meida_backend/packages/llm/providers/deepseek.py
T
xsl 54dc741811 feat(llm): provider-abstract gateway with cost and failure controls
DeepSeek (OpenAI-compatible) as primary with a 智谱 GLM slot that is a real
routing path but raises until the 算法备案 model list is confirmed. Adds scene
routing, failover, a per-provider circuit breaker, structured output with
retry-on-validation-failure, prompts loaded by id+version from prompts/, and
cost attribution written to ob_ai_call for every attempt -- successes,
retries and failures alike.

Two controls exist because they are cheap now and expensive later: a
cache-prefix validator that rejects timestamps, uuids and nicknames in the
cached system prefix (a volatile prefix silently multiplies input cost), and
a hard platform-wide daily budget that rejects non-critical scenes instead of
merely alerting, since a retry storm outruns any human on-call.
2026-08-14 00:26:39 +08:00

138 lines
4.7 KiB
Python
Executable File

"""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,
}
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
)
usage = Usage(
tokens_in=int(usage_raw.get("prompt_tokens", 0)),
tokens_out=int(usage_raw.get("completion_tokens", 0)),
tokens_cached=cached,
)
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"]