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
+10 -1
View File
@@ -231,7 +231,16 @@
"is_verified": false, "is_verified": false,
"line_number": 49 "line_number": 49
} }
],
"tests/unit/test_thinking_mode.py": [
{
"type": "Secret Keyword",
"filename": "tests/unit/test_thinking_mode.py",
"hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f",
"is_verified": false,
"line_number": 48
}
] ]
}, },
"generated_at": "2026-08-13T16:53:15Z" "generated_at": "2026-08-13T17:02:10Z"
} }
@@ -0,0 +1,44 @@
"""ob_ai_call: record reasoning tokens and the peak/off-peak tier
两列都是为了让 ``ob_ai_call`` 的成本可复核 —— 这张表是 V2 定价的唯一依据
(架构文档 §13),少一列就少一块无法事后补齐的事实。
``tokens_reasoning``
思考模式产生的 token,按输出价计费。不并入 ``tokens_out``:混在一起就看不出
开关思考模式的成本差异,而 ``deepseek-v4-flash`` 默认开启该模式(T-P1-24a)。
``rate_tier``
该次调用适用的峰谷档位。DeepSeek 自 2026-08-17 起高峰单价为空闲时段的 2 倍,
成本在调用当时算好落库;没有档位列,事后就无法判断某行的 ``cost_cents``
是按哪套单价算出来的(T-P1-25c)。
Reversible: 是。降级只丢这两列的数据,不影响既有行。
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0002"
down_revision: str | None = "0001"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# server_default 让历史行取 0 而不是 NULL:成本聚合里 NULL 会把整个 SUM 变成 NULL。
op.add_column(
"ob_ai_call",
sa.Column("tokens_reasoning", sa.Integer(), nullable=False, server_default="0"),
)
# 允许 NULL:0001 之后写入的历史行确实无法判定档位,
# 用 NULL 表达「未知」比塞一个可能错的 'flat' 更诚实。
op.add_column("ob_ai_call", sa.Column("rate_tier", sa.String(length=16), nullable=True))
def downgrade() -> None:
op.drop_column("ob_ai_call", "rate_tier")
op.drop_column("ob_ai_call", "tokens_reasoning")
+18
View File
@@ -31,6 +31,11 @@ PEAK_WINDOWS: tuple[tuple[time, time], ...] = (
(time(14, 0), time(18, 0)), (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) @dataclass(frozen=True, slots=True)
class ModelRate: class ModelRate:
@@ -63,6 +68,9 @@ class RateTable:
def known_models(self) -> tuple[str, ...]: def known_models(self) -> tuple[str, ...]:
return tuple(sorted(self._rates)) 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: def is_peak(at: datetime | None = None) -> bool:
"""调用时刻是否落在供应商的高峰时段(按北京时间判定)。""" """调用时刻是否落在供应商的高峰时段(按北京时间判定)。"""
@@ -94,6 +102,11 @@ class PeakAwareRateTable:
def known_models(self) -> tuple[str, ...]: def known_models(self) -> tuple[str, ...]:
return self._off_peak.known_models() 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: def compute_cost_cents(usage: Usage, rate: ModelRate) -> Decimal:
"""Cost of one call, in cents, with cache-hit tokens billed separately.""" """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 for_model(self, model: str) -> ModelRate: ...
def current_tier(self) -> str:
"""此刻适用的档位,随成本一起落库以便事后复核。"""
...
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class AICallRecord: class AICallRecord:
@@ -126,6 +143,7 @@ class AICallRecord:
user_id: int | None = None user_id: int | None = None
prompt_id: str | None = None prompt_id: str | None = None
prompt_version: str | None = None prompt_version: str | None = None
rate_tier: str | None = None
error_code: str | None = None error_code: str | None = None
error_message: str | None = None error_message: str | None = None
ref_type: str | None = None ref_type: str | None = None
+4
View File
@@ -254,6 +254,7 @@ class LLMGateway:
user_id=user_id, user_id=user_id,
prompt_id=prompt_id, prompt_id=prompt_id,
prompt_version=prompt_version, prompt_version=prompt_version,
rate_tier=self._rates.current_tier(),
error_code=exc.code, error_code=exc.code,
error_message=str(exc)[:1000], error_message=str(exc)[:1000],
ref_type=ref_type, ref_type=ref_type,
@@ -263,6 +264,8 @@ class LLMGateway:
raise raise
latency_ms = self._elapsed_ms(started) latency_ms = self._elapsed_ms(started)
# 单价与档位在同一刻取,成本落库后不再回算 —— 跨时段重算会改掉历史成本。
rate_tier = self._rates.current_tier()
cost = compute_cost_cents(response.usage, self._rates.for_model(response.model)) cost = compute_cost_cents(response.usage, self._rates.for_model(response.model))
parsed: BaseModel | None = None parsed: BaseModel | None = None
@@ -289,6 +292,7 @@ class LLMGateway:
user_id=user_id, user_id=user_id,
prompt_id=prompt_id, prompt_id=prompt_id,
prompt_version=prompt_version, prompt_version=prompt_version,
rate_tier=rate_tier,
error_code=error_code, error_code=error_code,
error_message=last_detail if not success else None, error_message=last_detail if not success else None,
ref_type=ref_type, ref_type=ref_type,
+4
View File
@@ -36,8 +36,10 @@ class SqlAlchemyAICallRecorder:
tokens_in=record.usage.tokens_in, tokens_in=record.usage.tokens_in,
tokens_out=record.usage.tokens_out, tokens_out=record.usage.tokens_out,
tokens_cached=record.usage.tokens_cached, tokens_cached=record.usage.tokens_cached,
tokens_reasoning=record.usage.tokens_reasoning,
latency_ms=record.latency_ms, latency_ms=record.latency_ms,
cost_cents=record.cost_cents, cost_cents=record.cost_cents,
rate_tier=record.rate_tier,
success=record.success, success=record.success,
error_code=record.error_code, error_code=record.error_code,
error_message=record.error_message, 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, "in").inc(record.usage.tokens_in)
LLM_TOKENS.labels(*labels, "out").inc(record.usage.tokens_out) LLM_TOKENS.labels(*labels, "out").inc(record.usage.tokens_out)
LLM_TOKENS.labels(*labels, "cached").inc(record.usage.tokens_cached) 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_in: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
tokens_out: 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) 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) latency_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
cost_cents: Mapped[Decimal] = mapped_column( cost_cents: Mapped[Decimal] = mapped_column(
Numeric(14, 6), nullable=False, default=Decimal("0") 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) success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
error_code: Mapped[str | None] = mapped_column(String(64)) error_code: Mapped[str | None] = mapped_column(String(64))
error_message: Mapped[str | None] = mapped_column(Text) error_message: Mapped[str | None] = mapped_column(Text)
+15 -1
View File
@@ -115,7 +115,8 @@ PY
bold "6/7 ob_ai_call row (成本归因的唯一数据源, 架构文档 §13)" bold "6/7 ob_ai_call row (成本归因的唯一数据源, 架构文档 §13)"
${COMPOSE} exec -T postgres psql -U meida -d meida -x -c \ ${COMPOSE} exec -T postgres psql -U meida -d meida -x -c \
"SELECT call_id, user_id, scene, provider, model, prompt_id, prompt_version, "SELECT call_id, user_id, scene, provider, model, prompt_id, prompt_version,
tokens_in, tokens_out, tokens_cached, latency_ms, cost_cents, tokens_in, tokens_out, tokens_cached, tokens_reasoning,
latency_ms, cost_cents, rate_tier,
success, error_code, ref_type, created_at success, error_code, ref_type, created_at
FROM ob_ai_call FROM ob_ai_call
WHERE user_id = ${USER_ID} WHERE user_id = ${USER_ID}
@@ -124,6 +125,19 @@ ROWS=$(${COMPOSE} exec -T postgres psql -U meida -d meida -tAc \
"SELECT count(*) FROM ob_ai_call WHERE user_id = ${USER_ID} AND success") "SELECT count(*) FROM ob_ai_call WHERE user_id = ${USER_ID} AND success")
[[ "${ROWS// /}" -ge 1 ]] || fail "no successful ob_ai_call row was written" [[ "${ROWS// /}" -ge 1 ]] || fail "no successful ob_ai_call row was written"
# 思考模式若被上游默认打开,这里会立刻变成非 0 —— 延迟与输出侧成本会一起涨(T-P1-24a)。
REASONING=$(${COMPOSE} exec -T postgres psql -U meida -d meida -tAc \
"SELECT coalesce(max(tokens_reasoning), 0) FROM ob_ai_call WHERE user_id = ${USER_ID}")
[[ "${REASONING// /}" == "0" ]] \
|| fail "思考模式 token 非 0(${REASONING}):V1 场景应显式关闭 thinking,请检查 routing 配置"
# 档位必须落库,否则事后无法复核 cost_cents 是按哪套单价算的(T-P1-25c)。
TIER=$(${COMPOSE} exec -T postgres psql -U meida -d meida -tAc \
"SELECT coalesce(rate_tier, '') FROM ob_ai_call
WHERE user_id = ${USER_ID} ORDER BY created_at DESC LIMIT 1")
[[ -n "${TIER// /}" ]] || fail "ob_ai_call.rate_tier 为空:成本无法事后复核"
printf 'rate_tier=%s tokens_reasoning=%s\n' "${TIER// /}" "${REASONING// /}"
bold "7/7 bl_ledger rows (预扣三段式, 架构文档 §7.4)" bold "7/7 bl_ledger rows (预扣三段式, 架构文档 §7.4)"
${COMPOSE} exec -T postgres psql -U meida -d meida -c \ ${COMPOSE} exec -T postgres psql -U meida -d meida -c \
"SELECT tx_id, delta, tx_type, status, quota_key, period_key, balance_after, "SELECT tx_id, delta, tx_type, status, quota_key, period_key, balance_after,
+3
View File
@@ -392,6 +392,7 @@ async def test_every_call_records_a_cost_row(prompts_dir: Path) -> None:
assert record.usage.tokens_cached == 800 assert record.usage.tokens_cached == 800
assert record.cost_cents == result.cost_cents > Decimal("0") assert record.cost_cents == result.cost_cents > Decimal("0")
assert record.created_at is not None assert record.created_at is not None
assert record.rate_tier # 复核成本要知道当次单价档位
async def test_failed_calls_are_recorded_too(prompts_dir: Path) -> None: async def test_failed_calls_are_recorded_too(prompts_dir: Path) -> None:
@@ -405,6 +406,8 @@ async def test_failed_calls_are_recorded_too(prompts_dir: Path) -> None:
assert record.success is False assert record.success is False
assert record.error_code == "llm_timeout" assert record.error_code == "llm_timeout"
assert record.cost_cents == Decimal("0") assert record.cost_cents == Decimal("0")
# 失败行也要带档位:排查「为什么这天成本高」时失败重试也是成本的一部分
assert record.rate_tier
async def test_a_broken_recorder_never_breaks_the_user_request(prompts_dir: Path) -> None: async def test_a_broken_recorder_never_breaks_the_user_request(prompts_dir: Path) -> None:
+15
View File
@@ -12,6 +12,9 @@ from decimal import Decimal
import pytest import pytest
from packages.llm.cost import ( from packages.llm.cost import (
TIER_FLAT,
TIER_OFF_PEAK,
TIER_PEAK,
ModelRate, ModelRate,
PeakAwareRateTable, PeakAwareRateTable,
RateTable, RateTable,
@@ -84,3 +87,15 @@ def test_unknown_model_still_costs_money_in_both_tiers() -> None:
for hour in (10, 13): for hour in (10, 13):
rate = table.for_model("some-new-model", datetime(2026, 8, 20, hour, tzinfo=BEIJING)) rate = table.for_model("some-new-model", datetime(2026, 8, 20, hour, tzinfo=BEIJING))
assert rate.input_cents_per_mtok > 0 assert rate.input_cents_per_mtok > 0
def test_tier_is_reported_so_cost_can_be_rechecked_later() -> None:
# 没有档位,事后就无法判断某行 cost_cents 是按哪套单价算的
table = _table()
assert table.current_tier(datetime(2026, 8, 20, 10, 0, tzinfo=BEIJING)) == TIER_PEAK
assert table.current_tier(datetime(2026, 8, 20, 13, 0, tzinfo=BEIJING)) == TIER_OFF_PEAK
def test_single_rate_tables_report_a_flat_tier() -> None:
assert RateTable({MODEL: OFF_PEAK}, default=OFF_PEAK).current_tier() == TIER_FLAT
assert _table(enabled=False).current_tier() == TIER_FLAT