feat(cost): persist reasoning tokens and the peak/off-peak tier
CI / lint / typecheck / import-linter / tests / secrets (push) Canceled after 0s

ob_ai_call is the only input to V2 pricing, and it was dropping the two
numbers needed to interpret its own cost column.

tokens_reasoning was captured from the provider response but never written to
the table, so the cost of thinking mode was invisible once it left the
process. Folding it into tokens_out was not an option either -- it is billed
at the output rate, and mixing the two hides exactly the difference we now
have to watch.

rate_tier records which price list applied. Without it a row's cost_cents
cannot be rechecked: DeepSeek's peak rate is double off-peak from 2026-08-17,
so recomputing an old row at "the current rate" silently produces a different
answer than what was actually billed.

Migration 0002 adds both columns. tokens_reasoning defaults to 0 rather than
NULL so cost aggregates do not collapse to NULL on historical rows;
rate_tier stays nullable because rows written before this change genuinely
have an unknown tier, and NULL says that more honestly than a guessed 'flat'.

The smoke script now asserts both: reasoning tokens must be 0 (a non-zero
value means a provider default re-enabled thinking mode behind our backs) and
rate_tier must be present. Verified against the real API -- tokens_reasoning=0,
rate_tier=off_peak, 3.9s round trip.
This commit is contained in:
xsl
2026-08-14 01:02:13 +08:00
parent f46ecc5eb0
commit c97f8ae3e1
9 changed files with 119 additions and 2 deletions
+18
View File
@@ -31,6 +31,11 @@ PEAK_WINDOWS: tuple[tuple[time, time], ...] = (
(time(14, 0), time(18, 0)),
)
# 记入 ob_ai_call.rate_tier,用于事后复核 cost_cents。
TIER_PEAK = "peak"
TIER_OFF_PEAK = "off_peak"
TIER_FLAT = "flat" # 只有一套单价(未启用峰谷,或该 provider 不分时段)
@dataclass(frozen=True, slots=True)
class ModelRate:
@@ -63,6 +68,9 @@ class RateTable:
def known_models(self) -> tuple[str, ...]:
return tuple(sorted(self._rates))
def current_tier(self, at: datetime | None = None) -> str:
return TIER_FLAT
def is_peak(at: datetime | None = None) -> bool:
"""调用时刻是否落在供应商的高峰时段(按北京时间判定)。"""
@@ -94,6 +102,11 @@ class PeakAwareRateTable:
def known_models(self) -> tuple[str, ...]:
return self._off_peak.known_models()
def current_tier(self, at: datetime | None = None) -> str:
if not self._enabled:
return TIER_FLAT
return TIER_PEAK if is_peak(at) else TIER_OFF_PEAK
def compute_cost_cents(usage: Usage, rate: ModelRate) -> Decimal:
"""Cost of one call, in cents, with cache-hit tokens billed separately."""
@@ -110,6 +123,10 @@ class RateResolver(Protocol):
def for_model(self, model: str) -> ModelRate: ...
def current_tier(self) -> str:
"""此刻适用的档位,随成本一起落库以便事后复核。"""
...
@dataclass(frozen=True, slots=True)
class AICallRecord:
@@ -126,6 +143,7 @@ class AICallRecord:
user_id: int | None = None
prompt_id: str | None = None
prompt_version: str | None = None
rate_tier: str | None = None
error_code: str | None = None
error_message: str | None = None
ref_type: str | None = None
+4
View File
@@ -254,6 +254,7 @@ class LLMGateway:
user_id=user_id,
prompt_id=prompt_id,
prompt_version=prompt_version,
rate_tier=self._rates.current_tier(),
error_code=exc.code,
error_message=str(exc)[:1000],
ref_type=ref_type,
@@ -263,6 +264,8 @@ class LLMGateway:
raise
latency_ms = self._elapsed_ms(started)
# 单价与档位在同一刻取,成本落库后不再回算 —— 跨时段重算会改掉历史成本。
rate_tier = self._rates.current_tier()
cost = compute_cost_cents(response.usage, self._rates.for_model(response.model))
parsed: BaseModel | None = None
@@ -289,6 +292,7 @@ class LLMGateway:
user_id=user_id,
prompt_id=prompt_id,
prompt_version=prompt_version,
rate_tier=rate_tier,
error_code=error_code,
error_message=last_detail if not success else None,
ref_type=ref_type,
+4
View File
@@ -36,8 +36,10 @@ class SqlAlchemyAICallRecorder:
tokens_in=record.usage.tokens_in,
tokens_out=record.usage.tokens_out,
tokens_cached=record.usage.tokens_cached,
tokens_reasoning=record.usage.tokens_reasoning,
latency_ms=record.latency_ms,
cost_cents=record.cost_cents,
rate_tier=record.rate_tier,
success=record.success,
error_code=record.error_code,
error_message=record.error_message,
@@ -59,3 +61,5 @@ class PrometheusAICallRecorder:
LLM_TOKENS.labels(*labels, "in").inc(record.usage.tokens_in)
LLM_TOKENS.labels(*labels, "out").inc(record.usage.tokens_out)
LLM_TOKENS.labels(*labels, "cached").inc(record.usage.tokens_cached)
# 思考模式 token 单独出一条曲线:它按输出价计费,是最容易悄悄涨上去的一项。
LLM_TOKENS.labels(*labels, "reasoning").inc(record.usage.tokens_reasoning)
+6
View File
@@ -229,10 +229,16 @@ class AiCall(Base):
tokens_in: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
tokens_out: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
tokens_cached: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# 思考模式 token 单独记,不并入 tokens_out:它按输出价计费,混在一起就看不出
# 开关思考模式的成本差异,而这正是 T-P1-24a 要长期盯的数。
tokens_reasoning: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
latency_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
cost_cents: Mapped[Decimal] = mapped_column(
Numeric(14, 6), nullable=False, default=Decimal("0")
)
# 该次调用适用的峰谷档位。没有它就无法事后复核 cost_cents ——
# 单价随时刻变化,事后按「当前价」回算会得出不同结果 (T-P1-25c)。
rate_tier: Mapped[str | None] = mapped_column(String(16))
success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
error_code: Mapped[str | None] = mapped_column(String(64))
error_message: Mapped[str | None] = mapped_column(Text)