Files
meida_backend/packages/llm/cost.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

179 lines
6.2 KiB
Python
Executable File

"""Cost attribution (架构文档 §13, FR-BILL-08, NFR-COST-01).
**每一次付费调用都必须落 ``ob_ai_call``,并可归因到用户与功能。**
V1 不收费让这张表的地位更高,不是更低 —— 它是 V2 定价的唯一数据来源,
不允许「先上线后补埋点」。
Rates are configuration, never constants at a call site: providers re-price
without warning and V1's whole cost model depends on these numbers being
correct at the time of the call.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, replace
from datetime import UTC, datetime, time, timedelta, timezone
from decimal import ROUND_HALF_UP, Decimal
from typing import Protocol, runtime_checkable
from packages.llm.types import Usage
_MILLION = Decimal("1000000")
_QUANT = Decimal("0.000001")
BEIJING = timezone(timedelta(hours=8))
# DeepSeek 自 2026-08-17 00:00(北京时间)起改为峰谷定价,高峰单价是空闲时段的 2 倍。
# 官方口径的高峰时段:北京时间 09:00-12:00 与 14:00-18:00。
PEAK_WINDOWS: tuple[tuple[time, time], ...] = (
(time(9, 0), time(12, 0)),
(time(14, 0), time(18, 0)),
)
@dataclass(frozen=True, slots=True)
class ModelRate:
"""Cents per 1M tokens. Cached input is billed at a lower rate by DeepSeek."""
input_cents_per_mtok: Decimal
cached_input_cents_per_mtok: Decimal
output_cents_per_mtok: Decimal
@classmethod
def from_mapping(cls, data: Mapping[str, Decimal | str | int | float]) -> ModelRate:
return cls(
input_cents_per_mtok=Decimal(str(data["input_cents_per_mtok"])),
cached_input_cents_per_mtok=Decimal(str(data["cached_input_cents_per_mtok"])),
output_cents_per_mtok=Decimal(str(data["output_cents_per_mtok"])),
)
class RateTable:
"""model → :class:`ModelRate`, with an explicit fallback so an unknown model
is still costed (a zero-cost blind spot is worse than an approximate one)."""
def __init__(self, rates: dict[str, ModelRate], default: ModelRate) -> None:
self._rates = dict(rates)
self._default = default
def for_model(self, model: str) -> ModelRate:
return self._rates.get(model, self._default)
def known_models(self) -> tuple[str, ...]:
return tuple(sorted(self._rates))
def is_peak(at: datetime | None = None) -> bool:
"""调用时刻是否落在供应商的高峰时段(按北京时间判定)。"""
moment = (at or datetime.now(UTC)).astimezone(BEIJING).timetz()
return any(start <= moment.replace(tzinfo=None) < end for start, end in PEAK_WINDOWS)
class PeakAwareRateTable:
"""按调用时刻在「高峰 / 空闲」两套单价之间选择。
为什么不能只存一套单价:高峰价是空闲价的 2 倍,而报告生成这类任务大多发生在
白天高峰时段。用单一均价记账会让 ``ob_ai_call`` 的成本与账单对不上,
而这张表是 V2 定价的唯一依据 —— 记错等于把定价建在错的数字上。
时段判断放在算成本的这一刻,不是事后按当前价重算:跨时段重算会把历史成本改掉。
"""
def __init__(
self, off_peak: RateTable, peak: RateTable | None = None, *, enabled: bool = True
) -> None:
self._off_peak = off_peak
self._peak = peak or off_peak
self._enabled = enabled
def for_model(self, model: str, at: datetime | None = None) -> ModelRate:
table = self._peak if self._enabled and is_peak(at) else self._off_peak
return table.for_model(model)
def known_models(self) -> tuple[str, ...]:
return self._off_peak.known_models()
def compute_cost_cents(usage: Usage, rate: ModelRate) -> Decimal:
"""Cost of one call, in cents, with cache-hit tokens billed separately."""
billable_input = Decimal(usage.billable_input) * rate.input_cents_per_mtok
cached_input = Decimal(usage.tokens_cached) * rate.cached_input_cents_per_mtok
output = Decimal(usage.tokens_out) * rate.output_cents_per_mtok
total = (billable_input + cached_input + output) / _MILLION
return total.quantize(_QUANT, rounding=ROUND_HALF_UP)
@runtime_checkable
class RateResolver(Protocol):
"""网关只需要「给我这个模型此刻的单价」,不关心背后是一套还是两套价目表。"""
def for_model(self, model: str) -> ModelRate: ...
@dataclass(frozen=True, slots=True)
class AICallRecord:
"""One row of ``ob_ai_call``. Written for successes *and* failures."""
call_id: int
scene: str
provider: str
model: str
success: bool
usage: Usage
latency_ms: int
cost_cents: Decimal
user_id: int | None = None
prompt_id: str | None = None
prompt_version: str | None = None
error_code: str | None = None
error_message: str | None = None
ref_type: str | None = None
ref_id: str | None = None
created_at: datetime | None = None
def with_defaults(self) -> AICallRecord:
if self.created_at is not None:
return self
# 本类是 slots dataclass,没有 __dict__,只能用 replace 复制
return replace(self, created_at=datetime.now(UTC))
@runtime_checkable
class AICallRecorder(Protocol):
"""Persistence port. The gateway never touches the ORM directly."""
async def record(self, record: AICallRecord) -> None: ...
class NullAICallRecorder:
"""Explicitly opts out of cost attribution. Only valid in unit tests."""
async def record(self, record: AICallRecord) -> None:
return None
class InMemoryAICallRecorder:
"""Test double that keeps every record for assertions."""
def __init__(self) -> None:
self.records: list[AICallRecord] = []
async def record(self, record: AICallRecord) -> None:
self.records.append(record)
class CompositeAICallRecorder:
"""Fan out to several recorders (e.g. PostgreSQL + Prometheus).
A failure in one recorder must not lose the others, and must never fail the
user's request — cost bookkeeping is mandatory but not user-facing.
"""
def __init__(self, *recorders: AICallRecorder) -> None:
self._recorders = recorders
async def record(self, record: AICallRecord) -> None:
for recorder in self._recorders:
await recorder.record(record)