1226bc8365
## 问题原因 - 新版客户端通过服务端 harvest 落库,但完成路径没有触发 LLM 调用明细与 TOKEN 成本回填。 - 内部共享密钥不一致或 PriceBot 实例切换后,拉取失败只留下空值,后续没有自动补偿。 ## 本次改动 - harvest 完成后立即异步回填 LLM 调用、TOKEN 数及成本快照。 - 抽取统一、幂等的成本回填服务,并增加定时补偿 worker。 - 增加 PriceBot 内部鉴权预检、错误日志和多实例查找兜底。 - 仅回填当前价格配置生效后的终态记录,避免用现价误算更早历史数据。 - 补充环境配置、部署说明和单元测试。 ## 验证 - 相关测试:36 passed。 - 静态检查通过,diff check 通过。 - 本地页面显示 ¥0.0139,与数据库精确均值 0.013916 的四舍五入结果一致。 ## 上线注意 部署时需确保 app-server 与 PriceBot 的 INTERNAL_API_SECRET 完全一致并重启两个服务;worker 启动后会自动补齐符合条件的历史空值。 --------- Co-authored-by: guke <guke@wonderable.ai> Co-authored-by: unknown <798648091@qq.com> Reviewed-on: #182 Co-authored-by: linkeyu <linkeyu@wonderable.ai> Co-committed-by: linkeyu <linkeyu@wonderable.ai>
191 lines
7.8 KiB
Python
191 lines
7.8 KiB
Python
"""LLM 调用成本计算 compute_llm_cost:按模型分桶累加 token × 单价;error/无 usage 跳过。"""
|
||
from __future__ import annotations
|
||
|
||
from app.services.llm_cost import compute_llm_cost
|
||
|
||
_PRICE = {
|
||
"per_model": {"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0}},
|
||
"default": {"input_per_1m": 3.0, "output_per_1m": 15.0},
|
||
}
|
||
|
||
|
||
def test_sums_per_model_single_model():
|
||
# 真实样本:4 次 qwen3.5-flash;Σprompt=6888、Σcompletion=337
|
||
calls = [
|
||
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1512, "completion_tokens": 22}},
|
||
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 2111, "completion_tokens": 160}},
|
||
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1940, "completion_tokens": 142}},
|
||
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1325, "completion_tokens": 13}},
|
||
]
|
||
cost, snapshot = compute_llm_cost(calls, _PRICE)
|
||
# 6888/1e6*0.8 + 337/1e6*2.0 = 0.0055104 + 0.000674 = 0.0061844 → round(6)
|
||
assert cost == 0.006184
|
||
assert snapshot == {
|
||
"mode": "per_model",
|
||
"prices": {
|
||
"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0, "_source": "per_model"},
|
||
},
|
||
}
|
||
|
||
|
||
def test_multi_model_prices_each_bucket_separately():
|
||
calls = [
|
||
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 0}},
|
||
{"model": "gpt-x", "error": None, "usage": {"prompt_tokens": 0, "completion_tokens": 1_000_000}},
|
||
]
|
||
price = {
|
||
"per_model": {
|
||
"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0},
|
||
"gpt-x": {"input_per_1m": 10.0, "output_per_1m": 30.0},
|
||
},
|
||
"default": {"input_per_1m": 3.0, "output_per_1m": 15.0},
|
||
}
|
||
cost, snap = compute_llm_cost(calls, price)
|
||
assert cost == 30.8 # qwen 1M入×0.8=0.8 + gpt-x 1M出×30=30.0
|
||
assert set(snap["prices"]) == {"qwen3.5-flash", "gpt-x"}
|
||
|
||
|
||
def test_unknown_model_falls_back_to_default():
|
||
calls = [{"model": "mystery", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 0}}]
|
||
price = {"per_model": {}, "default": {"input_per_1m": 3.0, "output_per_1m": 15.0}}
|
||
cost, snap = compute_llm_cost(calls, price)
|
||
assert cost == 3.0
|
||
assert snap["prices"]["mystery"]["_source"] == "default"
|
||
|
||
|
||
def test_unpriced_model_marked_and_zero_cost():
|
||
calls = [{"model": "mystery", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 999}}]
|
||
cost, snap = compute_llm_cost(calls, {"per_model": {}}) # 无 default
|
||
assert cost == 0.0
|
||
assert snap["prices"]["mystery"]["unpriced"] is True
|
||
|
||
|
||
def test_error_and_missing_usage_calls_skipped():
|
||
calls = [
|
||
{"model": "qwen3.5-flash", "error": "boom", "usage": {"prompt_tokens": 9_999_999, "completion_tokens": 9_999_999}},
|
||
{"model": "qwen3.5-flash", "error": None, "usage": None}, # 无 usage
|
||
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 0}},
|
||
]
|
||
cost, _ = compute_llm_cost(calls, _PRICE)
|
||
assert cost == 0.8 # 只有第 3 条计入
|
||
|
||
|
||
def test_empty_or_all_error_returns_none():
|
||
assert compute_llm_cost([], _PRICE) == (None, None)
|
||
assert compute_llm_cost(None, _PRICE) == (None, None)
|
||
all_error = [{"model": "x", "error": "boom", "usage": {"prompt_tokens": 100, "completion_tokens": 100}}]
|
||
assert compute_llm_cost(all_error, _PRICE) == (None, None)
|
||
|
||
|
||
def test_malformed_price_entry_is_treated_as_unpriced_not_raised():
|
||
# 手改配置页可能存出残缺/非法单价(缺 output_per_1m、非 dict);不能抛异常连累 token 回填。
|
||
calls = [
|
||
{"model": "bad-a", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 5}},
|
||
{"model": "bad-b", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 5}},
|
||
{"model": "ok", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 0}},
|
||
]
|
||
price = {
|
||
"per_model": {
|
||
"bad-a": {"input_per_1m": 0.8}, # 缺 output_per_1m
|
||
"bad-b": 5, # 非 dict
|
||
"ok": {"input_per_1m": 3.0, "output_per_1m": 15.0},
|
||
},
|
||
}
|
||
cost, snap = compute_llm_cost(calls, price) # 不得抛异常
|
||
assert cost == 3.0 # 只有 ok(1M 入 × 3.0)计入;两个残缺项按 unpriced
|
||
assert snap["prices"]["bad-a"].get("unpriced") is True
|
||
assert snap["prices"]["bad-b"].get("unpriced") is True
|
||
|
||
|
||
def test_get_llm_prices_falls_back_to_default_then_uses_override():
|
||
from app.db.session import SessionLocal
|
||
from app.models.app_config import AppConfig
|
||
from app.repositories import app_config
|
||
from app.services.llm_cost import get_llm_prices
|
||
|
||
db = SessionLocal()
|
||
try:
|
||
# 无 override → CONFIG_DEFS 默认(含 per_model / default)
|
||
prices = get_llm_prices(db)
|
||
assert "per_model" in prices and "default" in prices
|
||
# 有 override → 用 DB 值
|
||
app_config.set_value(
|
||
db, "llm_token_price",
|
||
{"per_model": {"m": {"input_per_1m": 1.0, "output_per_1m": 2.0}},
|
||
"default": {"input_per_1m": 0.0, "output_per_1m": 0.0}},
|
||
admin_id=1,
|
||
)
|
||
assert get_llm_prices(db)["per_model"]["m"]["input_per_1m"] == 1.0
|
||
finally:
|
||
row = db.get(AppConfig, "llm_token_price")
|
||
if row is not None:
|
||
db.delete(row)
|
||
db.commit()
|
||
db.close()
|
||
|
||
|
||
def test_backfill_llm_calls_stores_cost_and_snapshot(monkeypatch):
|
||
from datetime import UTC, datetime
|
||
|
||
from app.api.v1 import compare_record
|
||
from app.db.session import SessionLocal
|
||
from app.models.app_config import AppConfig
|
||
from app.models.comparison import ComparisonRecord
|
||
from app.repositories import app_config
|
||
from app.services import comparison_llm_backfill
|
||
|
||
sample = [
|
||
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1512, "completion_tokens": 22}},
|
||
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 2111, "completion_tokens": 160}},
|
||
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1940, "completion_tokens": 142}},
|
||
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1325, "completion_tokens": 13}},
|
||
]
|
||
monkeypatch.setattr(
|
||
comparison_llm_backfill, "fetch_llm_calls", lambda trace_id: sample
|
||
)
|
||
monkeypatch.setattr(
|
||
comparison_llm_backfill.settings, "INTERNAL_API_SECRET", "test-secret"
|
||
)
|
||
|
||
db = SessionLocal()
|
||
try:
|
||
app_config.set_value(
|
||
db, "llm_token_price",
|
||
{"per_model": {"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0}},
|
||
"default": {"input_per_1m": 3.0, "output_per_1m": 15.0}},
|
||
admin_id=1,
|
||
)
|
||
rec = ComparisonRecord(
|
||
trace_id="llmcost-bf-1", status="success",
|
||
created_at=datetime.now(UTC).replace(tzinfo=None),
|
||
)
|
||
db.add(rec)
|
||
db.commit()
|
||
rid = rec.id
|
||
finally:
|
||
db.close()
|
||
|
||
compare_record._backfill_llm_calls(rid, "llmcost-bf-1") # 独立 session 内回填
|
||
|
||
db = SessionLocal()
|
||
try:
|
||
rec = db.get(ComparisonRecord, rid)
|
||
assert rec.llm_cost_yuan == 0.006184
|
||
assert rec.llm_price_snapshot["prices"]["qwen3.5-flash"]["input_per_1m"] == 0.8
|
||
assert rec.input_tokens == 6888 # 现有 token 派生仍在
|
||
finally:
|
||
db.delete(db.get(ComparisonRecord, rid))
|
||
row = db.get(AppConfig, "llm_token_price")
|
||
if row is not None:
|
||
db.delete(row)
|
||
db.commit()
|
||
db.close()
|
||
|
||
|
||
def test_admin_detail_schema_exposes_llm_cost_fields():
|
||
from app.admin.schemas.comparison import AdminComparisonDetail
|
||
|
||
fields = AdminComparisonDetail.model_fields
|
||
assert "llm_cost_yuan" in fields
|
||
assert "llm_price_snapshot" in fields
|