feat(cost): persist reasoning tokens and the peak/off-peak tier
CI / lint / typecheck / import-linter / tests / secrets (push) Canceled after 0s
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:
+10
-1
@@ -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")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user