diff --git a/.secrets.baseline b/.secrets.baseline index cab4734..0fa44ec 100755 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -231,7 +231,16 @@ "is_verified": false, "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" } diff --git a/migrations/versions/0002_ai_call_reasoning_and_rate_tier.py b/migrations/versions/0002_ai_call_reasoning_and_rate_tier.py new file mode 100644 index 0000000..e27509c --- /dev/null +++ b/migrations/versions/0002_ai_call_reasoning_and_rate_tier.py @@ -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") diff --git a/packages/llm/cost.py b/packages/llm/cost.py index 39dc6a1..74edf79 100755 --- a/packages/llm/cost.py +++ b/packages/llm/cost.py @@ -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 diff --git a/packages/llm/gateway.py b/packages/llm/gateway.py index 778eeb5..1adc36a 100755 --- a/packages/llm/gateway.py +++ b/packages/llm/gateway.py @@ -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, diff --git a/packages/llm/recording.py b/packages/llm/recording.py index 30507b7..734188d 100755 --- a/packages/llm/recording.py +++ b/packages/llm/recording.py @@ -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) diff --git a/packages/shared/models.py b/packages/shared/models.py index 76defcb..46a6a02 100755 --- a/packages/shared/models.py +++ b/packages/shared/models.py @@ -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) diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 67de599..5b7d424 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -115,7 +115,8 @@ PY bold "6/7 ob_ai_call row (成本归因的唯一数据源, 架构文档 §13)" ${COMPOSE} exec -T postgres psql -U meida -d meida -x -c \ "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 FROM ob_ai_call 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") [[ "${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)" ${COMPOSE} exec -T postgres psql -U meida -d meida -c \ "SELECT tx_id, delta, tx_type, status, quota_key, period_key, balance_after, diff --git a/tests/unit/test_gateway.py b/tests/unit/test_gateway.py index 42cf28a..967354a 100755 --- a/tests/unit/test_gateway.py +++ b/tests/unit/test_gateway.py @@ -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.cost_cents == result.cost_cents > Decimal("0") assert record.created_at is not None + assert record.rate_tier # 复核成本要知道当次单价档位 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.error_code == "llm_timeout" 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: diff --git a/tests/unit/test_peak_pricing.py b/tests/unit/test_peak_pricing.py index e936e30..d194670 100644 --- a/tests/unit/test_peak_pricing.py +++ b/tests/unit/test_peak_pricing.py @@ -12,6 +12,9 @@ from decimal import Decimal import pytest from packages.llm.cost import ( + TIER_FLAT, + TIER_OFF_PEAK, + TIER_PEAK, ModelRate, PeakAwareRateTable, RateTable, @@ -84,3 +87,15 @@ def test_unknown_model_still_costs_money_in_both_tiers() -> None: for hour in (10, 13): rate = table.for_model("some-new-model", datetime(2026, 8, 20, hour, tzinfo=BEIJING)) 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