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.
98 lines
3.2 KiB
Python
Executable File
98 lines
3.2 KiB
Python
Executable File
"""Cost computation and attribution (架构文档 §13, FR-BILL-08)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from decimal import Decimal
|
|
|
|
from packages.llm.cost import (
|
|
AICallRecord,
|
|
CompositeAICallRecorder,
|
|
InMemoryAICallRecorder,
|
|
ModelRate,
|
|
NullAICallRecorder,
|
|
RateTable,
|
|
compute_cost_cents,
|
|
)
|
|
from packages.llm.recording import PrometheusAICallRecorder
|
|
from packages.llm.types import Usage
|
|
|
|
RATE = ModelRate(
|
|
input_cents_per_mtok=Decimal("100"),
|
|
cached_input_cents_per_mtok=Decimal("10"),
|
|
output_cents_per_mtok=Decimal("200"),
|
|
)
|
|
|
|
|
|
def test_cached_tokens_are_billed_at_the_cheaper_rate() -> None:
|
|
usage = Usage(tokens_in=1_000_000, tokens_out=0, tokens_cached=1_000_000)
|
|
assert compute_cost_cents(usage, RATE) == Decimal("10.000000")
|
|
|
|
|
|
def test_uncached_input_is_billed_at_the_full_rate() -> None:
|
|
usage = Usage(tokens_in=1_000_000, tokens_out=0, tokens_cached=0)
|
|
assert compute_cost_cents(usage, RATE) == Decimal("100.000000")
|
|
|
|
|
|
def test_mixed_usage_splits_cached_and_uncached_input() -> None:
|
|
# 3.7k input of which 3k cached, 800 output — the §7.2 Agent token budget.
|
|
usage = Usage(tokens_in=3700, tokens_out=800, tokens_cached=3000)
|
|
expected = (
|
|
Decimal(700) * RATE.input_cents_per_mtok
|
|
+ Decimal(3000) * RATE.cached_input_cents_per_mtok
|
|
+ Decimal(800) * RATE.output_cents_per_mtok
|
|
) / Decimal(1_000_000)
|
|
assert compute_cost_cents(usage, RATE) == expected.quantize(Decimal("0.000001"))
|
|
|
|
|
|
def test_zero_usage_costs_nothing() -> None:
|
|
assert compute_cost_cents(Usage(), RATE) == Decimal("0.000000")
|
|
|
|
|
|
def test_billable_input_never_goes_negative() -> None:
|
|
usage = Usage(tokens_in=100, tokens_cached=500)
|
|
assert usage.billable_input == 0
|
|
|
|
|
|
def test_rate_table_falls_back_so_unknown_models_are_never_free() -> None:
|
|
default = ModelRate(Decimal("1"), Decimal("1"), Decimal("1"))
|
|
table = RateTable({"deepseek-v4-flash": RATE}, default=default)
|
|
assert table.for_model("deepseek-v4-flash") is RATE
|
|
assert table.for_model("some-new-model") is default
|
|
assert table.known_models() == ("deepseek-v4-flash",)
|
|
|
|
|
|
def _record(success: bool = True) -> AICallRecord:
|
|
return AICallRecord(
|
|
call_id=1,
|
|
scene="agent_chat",
|
|
provider="deepseek",
|
|
model="deepseek-v4-flash",
|
|
success=success,
|
|
usage=Usage(tokens_in=10, tokens_out=5, tokens_cached=2),
|
|
latency_ms=120,
|
|
cost_cents=Decimal("0.5"),
|
|
)
|
|
|
|
|
|
async def test_with_defaults_stamps_created_at() -> None:
|
|
stamped = _record().with_defaults()
|
|
assert stamped.created_at is not None
|
|
assert stamped.with_defaults() is stamped
|
|
|
|
|
|
async def test_in_memory_recorder_collects_records() -> None:
|
|
recorder = InMemoryAICallRecorder()
|
|
await recorder.record(_record())
|
|
assert len(recorder.records) == 1
|
|
|
|
|
|
async def test_null_recorder_is_a_no_op() -> None:
|
|
# Must not raise and must not persist anything; `record` returns None by contract.
|
|
await NullAICallRecorder().record(_record())
|
|
|
|
|
|
async def test_composite_recorder_fans_out() -> None:
|
|
first, second = InMemoryAICallRecorder(), InMemoryAICallRecorder()
|
|
await CompositeAICallRecorder(first, second, PrometheusAICallRecorder()).record(_record())
|
|
assert len(first.records) == len(second.records) == 1
|