Files
meida_backend/packages/llm/providers/deepseek.py
T
xsl f46ecc5eb0 fix(llm): price by call time and stop paying for thinking tokens
Two defects that only surface against the real DeepSeek API, both of which
break the cost model rather than the code:

deepseek-v4-flash enables thinking mode by default. The same single-turn
question took 13.5s with it on and 1.8s with it off, and the thinking tokens
are billed at the output rate. That misses NFR-PERF-02b (15s per turn) and
inflates every conversational call. SceneConfig now carries an explicit
`thinking` flag, all four V1 scenes set it to false, and the provider always
sends the parameter rather than relying on the upstream default. Reasoning
tokens are recorded separately in Usage.tokens_reasoning so the cost of ever
turning it back on is visible.

DeepSeek moves to peak/off-peak pricing on 2026-08-17, with peak (Beijing
09:00-12:00 and 14:00-18:00) at twice the off-peak rate. Reports are generated
mostly during peak hours, so a single blended rate would leave ob_ai_call --
the only input to V2 pricing -- off by nearly a factor of two.
PeakAwareRateTable resolves the rate at call time; cost is computed and stored
then, never recomputed later at whatever the current rate happens to be.

Also here:
- Replace the live API key in the redaction test with a synthetic one. The
  test that proves we redact secrets was itself a secret leak waiting for its
  first commit.
- Correct LLM_COST_RATES_JSON, which held placeholder rates.
- Make ids.SnowflakeGenerator take an injectable clock so rollover and
  clock-rollback are testable without patching a slots class.
- Rename exceptions to the -Error suffix (N818) and clear the remaining ruff,
  mypy and import-linter findings, including two dead lines: a duplicate
  pytestmark assignment and a no-op assert on a None return.

Gates: ruff, mypy (96 files incl. tests), 4/4 import contracts,
detect-secrets, 166 unit + 3 integration tests, migration up/down round-trip.
2026-08-14 00:54:40 +08:00

147 lines
5.3 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,
# 思考模式一律显式声明。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"]