diff --git a/alembic/versions/comparison_llm_cost.py b/alembic/versions/comparison_llm_cost.py new file mode 100644 index 0000000..ef0bfdd --- /dev/null +++ b/alembic/versions/comparison_llm_cost.py @@ -0,0 +1,33 @@ +"""comparison_record: llm_cost_yuan + llm_price_snapshot(比价 LLM 调用成本 + 当时单价快照) + +回填 llm_calls 时按「当时的价」逐模型算出本次比价 LLM 总成本(元),连同所用单价快照一起冻结到 +记录上;admin 比价记录详情展示实际成本(旧记录 NULL → 前端回退估算)。见 services/llm_cost.py。 + +Revision ID: comparison_llm_cost +Revises: ad_ecpm_trace_id +Create Date: 2026-07-13 +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision: str = "comparison_llm_cost" +down_revision: str | Sequence[str] | None = "ad_ecpm_trace_id" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_JSONB = sa.JSON().with_variant(postgresql.JSONB(), "postgresql") + + +def upgrade() -> None: + # 均可空、无索引;SQLite 原生支持 ADD COLUMN,无需 batch_alter_table(同 comparison_debug_fields)。 + op.add_column("comparison_record", sa.Column("llm_cost_yuan", sa.Float(), nullable=True)) + op.add_column("comparison_record", sa.Column("llm_price_snapshot", _JSONB, nullable=True)) + + +def downgrade() -> None: + op.drop_column("comparison_record", "llm_price_snapshot") + op.drop_column("comparison_record", "llm_cost_yuan") diff --git a/app/admin/schemas/comparison.py b/app/admin/schemas/comparison.py index 3a5ae19..ec1d3aa 100644 --- a/app/admin/schemas/comparison.py +++ b/app/admin/schemas/comparison.py @@ -36,6 +36,8 @@ class AdminComparisonListItem(BaseModel): retry_count: int | None = None input_tokens: int | None = None # Σ usage.prompt_tokens(server 派生) output_tokens: int | None = None # Σ usage.completion_tokens(server 派生) + # 本次比价 LLM 总成本(元,按当时价冻结);旧记录/未回填为 None → 前端「成本」列回退估算。见 services/llm_cost.py。 + llm_cost_yuan: float | None = None device_model: str | None = None rom_vendor: str | None = None rom_name: str | None = None @@ -72,3 +74,5 @@ class AdminComparisonDetail(AdminComparisonListItem): # 原始上报全量;「卡在哪一步」从 raw_payload.platform_results[*].status 读 # (store_not_found/items_not_found/below_minimum/unsupported = 卡在 找店/加菜/起送/读价)。 raw_payload: dict | None = None + # 算成本所用单价快照 {mode, prices:{model:{...}}}(llm_cost_yuan 继承自列表项)。见 services/llm_cost.py。 + llm_price_snapshot: dict | None = None diff --git a/app/api/v1/compare_record.py b/app/api/v1/compare_record.py index 2677dad..1c34215 100644 --- a/app/api/v1/compare_record.py +++ b/app/api/v1/compare_record.py @@ -27,6 +27,7 @@ from app.schemas.compare_record import ( ComparisonRecordOut, ComparisonRecordPage, ) +from app.services.llm_cost import compute_llm_cost, get_llm_prices from app.services.pricebot_llm_calls import fetch_llm_calls logger = logging.getLogger("shagua.compare_record") @@ -81,6 +82,8 @@ def _backfill_llm_calls(record_id: int, trace_id: str) -> None: # error 的调用 usage 可能为 None,or {} 兜底) rec.input_tokens = sum((c.get("usage") or {}).get("prompt_tokens") or 0 for c in calls) rec.output_tokens = sum((c.get("usage") or {}).get("completion_tokens") or 0 for c in calls) + # 本次比价 LLM 成本(元)+ 当时单价快照:按 app_config 现价逐模型算好冻结(services/llm_cost.py)。 + rec.llm_cost_yuan, rec.llm_price_snapshot = compute_llm_cost(calls, get_llm_prices(db)) db.commit() logger.info( "backfill llm_calls trace=%s n=%d in_tok=%d out_tok=%d", diff --git a/app/core/config_schema.py b/app/core/config_schema.py index afc5dd5..9edb043 100644 --- a/app/core/config_schema.py +++ b/app/core/config_schema.py @@ -96,4 +96,19 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = { "group": "首页轮播", "type": "enum", "hidden": True, "help": "mixed=真实优先+种子补位(默认);real=只用真实比价记录;seed=只用种子/合成(演示)。", }, + # 比价 LLM 调用成本计价。值是嵌套 JSON(非 str→int),借 dict_str_int 类型在配置页走原始 JSON + # 编辑框;set_value 不校验类型,嵌套 JSON 照存。 + "llm_token_price": { + "default": { + "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}, + "currency": "CNY", "unit": "per_1m_tokens", + }, + "label": "LLM 模型单价(元/百万 token)", + "group": "LLM 成本", "type": "dict_str_int", + "help": ( + "比价 LLM 调用成本计价。JSON:per_model 按模型配 input/output 单价(元/1M token)," + "default 兜底未登记的模型。改价只影响之后回填的新记录,历史记录用当时价格快照。" + ), + }, } diff --git a/app/models/comparison.py b/app/models/comparison.py index d21a201..d4078e3 100644 --- a/app/models/comparison.py +++ b/app/models/comparison.py @@ -137,6 +137,12 @@ class ComparisonRecord(Base): # 每次 LLM 调用明细 [{scene,model,input_messages,output,usage,latency_ms,error}]; # server 收上报后按 trace_id 同机拉 pricebot 落库(见 compare_record 端点)。旧记录/未采集为 None。 llm_calls: Mapped[list | None] = mapped_column(_JSON, nullable=True) + # 本次比价 LLM 总成本(元):回填时按「当时的价」逐模型算好冻结(见 services/llm_cost.py)。 + # 单次亚分级 → float「元」(不用 *_cents)。旧记录/未回填为 None,前端回退「估算成本」。 + llm_cost_yuan: Mapped[float | None] = mapped_column(Float, nullable=True) + # 算成本所用单价快照 {mode, prices:{model:{input_per_1m,output_per_1m,_source}}}:app_config 只存 + # 当前价、不留历史,故把当时价冻结进来供审计/复算。 + llm_price_snapshot: Mapped[dict | None] = mapped_column(_JSON, nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), index=True, nullable=False diff --git a/app/services/llm_cost.py b/app/services/llm_cost.py new file mode 100644 index 0000000..fcf4edf --- /dev/null +++ b/app/services/llm_cost.py @@ -0,0 +1,53 @@ +"""LLM 调用成本计算(纯逻辑,无 DB):按 model 分桶累加 token × 单价,返回总成本(元)+ 价格快照。 + +用量取自 comparison_record.llm_calls[].usage(pricebot 已归一为 prompt/completion_tokens); +error / 无 usage 的调用跳过。price_cfg = {per_model:{model:{input_per_1m,output_per_1m}}, default:{...}}。 +成本单位「元」——单次亚分级,用 float(不用 *_cents);snapshot 只含本次用到的模型的价(审计用, +不存整张价表)。用到但没配价(既无 per_model 又无 default)的模型 → 快照标 unpriced,成本按 0 计。 +""" +from __future__ import annotations + +_PRICE_KEY = "llm_token_price" + + +def get_llm_prices(db) -> dict: + """读 LLM 单价配置(app_config;表内无则回退 CONFIG_DEFS 默认)。返回 compute_llm_cost 的 price_cfg。""" + from app.repositories import app_config # 延迟 import:compute_llm_cost 纯逻辑不牵连 DB 层 + return app_config.get_value(db, _PRICE_KEY) + + +def compute_llm_cost(calls: list[dict], price_cfg: dict) -> tuple[float | None, dict | None]: + """遍历 calls 按 model 分桶,cost = Σ(入/1e6*入价 + 出/1e6*出价);无有效调用 → (None, None)。""" + if not calls: + return None, None + per_model = price_cfg.get("per_model") or {} + default = price_cfg.get("default") + buckets: dict[str, list[int]] = {} # model -> [Σprompt_tokens, Σcompletion_tokens] + for c in calls: + if c.get("error"): + continue + usage = c.get("usage") or {} + model = c.get("model") or "unknown" + b = buckets.setdefault(model, [0, 0]) + b[0] += usage.get("prompt_tokens") or 0 + b[1] += usage.get("completion_tokens") or 0 + if not buckets: # 全是 error / 无 usage + return None, None + total = 0.0 + prices: dict[str, dict] = {} + for model, (tin, tout) in buckets.items(): + price = per_model.get(model, default) + in_p = price.get("input_per_1m") if isinstance(price, dict) else None + out_p = price.get("output_per_1m") if isinstance(price, dict) else None + # 没配价 / 无 default / 单价残缺或非法(配置页手改 JSON 可能存出脏数据)→ 标记待补价、 + # 不计入成本;绝不抛异常,以免连累同一回填里的 token/llm_calls 落库。 + if not isinstance(in_p, (int, float)) or not isinstance(out_p, (int, float)): + prices[model] = {"input_per_1m": in_p, "output_per_1m": out_p, "unpriced": True} + continue + total += tin / 1e6 * in_p + tout / 1e6 * out_p + prices[model] = { + "input_per_1m": in_p, + "output_per_1m": out_p, + "_source": "per_model" if model in per_model else "default", + } + return round(total, 6), {"mode": "per_model", "prices": prices} diff --git a/docs/database/comparison_record.md b/docs/database/comparison_record.md index 5ea3064..0545631 100644 --- a/docs/database/comparison_record.md +++ b/docs/database/comparison_record.md @@ -40,6 +40,8 @@ | `raw_payload` | JSON(PG: JSONB) | nullable | 客户端原始上报全量(calibration + done.params),取数兜底 | | `input_tokens` | Integer | nullable | 本次 LLM 累计输入 token = Σ `llm_calls[].usage.prompt_tokens`(server 收上报后从 `llm_calls` 累加;旧记录/未采集为 null) | | `output_tokens` | Integer | nullable | 本次 LLM 累计输出 token = Σ `llm_calls[].usage.completion_tokens`(同上) | +| `llm_cost_yuan` | Float | nullable | 本次比价 LLM 总成本(元),回填时按「当时价」逐模型算好冻结(见 `services/llm_cost.py`);旧记录/未回填为 null → 前端回退「估算成本」 | +| `llm_price_snapshot` | JSON(PG: JSONB) | nullable | 算成本所用单价快照 `{mode, prices:{model:{input_per_1m,output_per_1m,_source}}}`;`app_config` 只存当前价、不留历史,故冻结当时价供审计/复算 | | `created_at` | DateTime(tz) | server_default now(), index | 时间 | > `ordered`(已下单)是**瞬态字段**,不在表里:`list_records` 读取时按 `store_name ∈ 该用户 source='compare' 的 savings_record.shop_name 集合` 现挂到实例上供出参用。 diff --git a/scripts/seed_mock_llm_cost.py b/scripts/seed_mock_llm_cost.py new file mode 100644 index 0000000..3102a67 --- /dev/null +++ b/scripts/seed_mock_llm_cost.py @@ -0,0 +1,248 @@ +"""一次性 mock:造带 LLM token 成本的比价记录 + 配好 app_config 模型单价,用于测「管理后端」LLM 成本展示。 + +覆盖 admin「比价记录」详情抽屉的「LLM 成本」展示分支: + • app_config.llm_token_price ← 写一条多模型单价(= 配置页「LLM 成本」卡片「已改」态,get_llm_prices 读它) + • comparison_record ← 造 5 条,逐条**复用生产的 compute_llm_cost + 与 _backfill_llm_calls 同款派生** + (llm_call_count/retry_count/input_tokens/output_tokens/llm_cost_yuan/llm_price_snapshot), + 确保 mock 行 = 真实回填产出。5 条刻意覆盖: + ① 单模型真实样本(qwen3.5-flash ×4) → ¥0.006184(核对精确值) + ② 多模型(flash + plus) → 快照含两个模型、各自 _source=per_model + ③ 未登记模型(deepseek-v3) → 走 default,快照 _source=default + ④ 旧记录(有 token、无 cost) → llm_cost_yuan=NULL → 前端回退「估算成本」 + ⑤ 含 error 调用 → error 那次跳过计费、retry_count+1 + +记录挂到库里第一个真实用户(admin 列表能显示手机号);无用户则 user_id=NULL(孤儿行,admin 照样全看)。 +created_at 用北京 naive、最近几分钟内错开,详情列表倒序即 ①→⑤ 置顶。 + +幂等:重跑先按 trace_id 前缀「MOCKLLM-」清旧再建。app_config 单价是 upsert(不随 --clean-only 删, +因该 key 本就是本需求新增、无历史真实值;要改价直接去配置页或重跑本脚本)。 + + python -m scripts.seed_mock_llm_cost # 造价格 + 5 条记录 + python -m scripts.seed_mock_llm_cost --clean-only # 只清 MOCKLLM- 记录(保留单价) + +验收:admin「比价记录」→ 找 trace「MOCKLLM-」的 5 条 → 点开详情看「LLM 成本」: + ①②③⑤ 显示「实际·当时价」+ 价格快照;④ 显示「估算」。 +""" +from __future__ import annotations + +import argparse +import sys +from datetime import datetime, timedelta, timezone + +from sqlalchemy import delete, select + +from app.db.session import SessionLocal +from app.models.comparison import ComparisonRecord +from app.models.user import User +from app.repositories import app_config +from app.services.llm_cost import compute_llm_cost + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") # Windows 控制台输出中文/¥ + +_BJ = timezone(timedelta(hours=8)) +ID_PREFIX = "MOCKLLM-" + +# ── 写进 app_config 的模型单价(get_llm_prices 读它;配置页「LLM 成本」卡片可再改)── +PRICE_CFG = { + "per_model": { + "qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0}, + "qwen3.5-plus": {"input_per_1m": 4.0, "output_per_1m": 12.0}, + }, + "default": {"input_per_1m": 3.0, "output_per_1m": 15.0}, + "currency": "CNY", + "unit": "per_1m_tokens", +} + + +def _c(scene: str, model: str, pin: int, cout: int, error: str | None = None) -> dict: + """一条 llm_calls 明细,结构对齐真实 pricebot 归一后契约: + {scene, model, input_messages:[{role,content}], output, usage:{prompt/completion/total_tokens}, + latency_ms, error}(详情抽屉会遍历 input_messages,缺了会崩)。error 的调用无 usage/output。""" + return { + "scene": scene, + "model": model, + "error": error, + "input_messages": [ + {"role": "system", "content": f"你是比价助手,负责 {scene} 环节。"}, + {"role": "user", "content": f"[mock] 请处理本次比价的 {scene} 任务。"}, + ], + "output": None if error else f"[mock] {scene} 环节完成。", + "usage": None if error else { + "prompt_tokens": pin, "completion_tokens": cout, "total_tokens": pin + cout, + }, + "latency_ms": 780, + } + + +# ── 5 条记录蓝本:calls 决定成本;freeze=False 模拟旧记录(有 token 无 cost)── +RECORDS = [ + { + "label": "①单模型·真实样本", + "source": ("美团外卖", 4280), "best": ("京东秒送", 3680), + "store": "肯德基(建国路店)", "product": "疯狂星期四全家桶", + "info": "在京东秒送找到同款,到手价 ¥36.80,省 ¥6.00", + "freeze": True, + "calls": [ + _c("store_match", "qwen3.5-flash", 1512, 22), + _c("dish_match", "qwen3.5-flash", 2111, 160), + _c("dish_match", "qwen3.5-flash", 1940, 142), + _c("summary", "qwen3.5-flash", 1325, 13), + ], + }, + { + "label": "②多模型·flash+plus", + "source": ("淘宝闪购", 5900), "best": ("美团外卖", 5200), + "store": "瑞幸咖啡(国贸店)", "product": "生椰拿铁×2、丝绒拿铁", + "info": "在美团外卖找到同款,到手价 ¥52.00,省 ¥7.00", + "freeze": True, + "calls": [ + _c("store_match", "qwen3.5-flash", 2000, 50), + _c("dish_match", "qwen3.5-flash", 1800, 40), + _c("reasoning", "qwen3.5-plus", 3000, 500), + ], + }, + { + "label": "③未登记模型走 default", + "source": ("京东秒送", 3100), "best": ("美团外卖", 2650), + "store": "麦当劳(soho店)", "product": "麦辣鸡腿堡套餐", + "info": "在美团外卖找到同款,到手价 ¥26.50,省 ¥4.50", + "freeze": True, + "calls": [ + _c("store_match", "deepseek-v3", 5000, 800), + ], + }, + { + "label": "④旧记录·有token无成本(回退估算)", + "source": ("美团外卖", 3600), "best": ("淘宝闪购", 3200), + "store": "华莱士(双井店)", "product": "全鸡汉堡套餐", + "info": "在淘宝闪购找到同款,到手价 ¥32.00,省 ¥4.00", + "freeze": False, # 模拟本需求上线前的老记录:llm_cost_yuan=NULL → 前端回退估算 + "calls": [ + _c("store_match", "qwen3.5-flash", 2000, 100), + ], + }, + { + "label": "⑤含 error 调用(跳过计费)", + "source": ("淘宝闪购", 4100), "best": ("京东秒送", 3750), + "store": "海底捞(合生汇店)", "product": "番茄锅底、肥牛卷", + "info": "在京东秒送找到同款,到手价 ¥37.50,省 ¥3.50", + "freeze": True, + "calls": [ + _c("store_match", "qwen3.5-flash", 0, 0, error="timeout"), + _c("store_match", "qwen3.5-flash", 1500, 30), + ], + }, +] + +_PLATFORM_ID = { # 展示名 → 平台代号(comparison_results / source/best 列用) + "美团外卖": "meituan", "京东秒送": "jd", "淘宝闪购": "taobao", +} + + +def _naive_bj_now() -> datetime: + return datetime.now(_BJ).replace(tzinfo=None) + + +def clean(db) -> int: + n = db.execute( + delete(ComparisonRecord).where(ComparisonRecord.trace_id.like(f"{ID_PREFIX}%")) + ).rowcount or 0 + db.commit() + return n + + +def _build_record(spec: dict, owner_id: int | None, created_at: datetime) -> tuple[ComparisonRecord, float | None]: + """按蓝本造一条记录,LLM 派生完全对齐 _backfill_llm_calls;返回 (记录, 冻结成本或 None)。""" + calls = spec["calls"] + src_name, src_cents = spec["source"] + best_name, best_cents = spec["best"] + + # —— 与 _backfill_llm_calls 同款派生 —— + llm_call_count = len(calls) + retry_count = sum(1 for c in calls if c.get("error")) + input_tokens = sum((c.get("usage") or {}).get("prompt_tokens") or 0 for c in calls) + output_tokens = sum((c.get("usage") or {}).get("completion_tokens") or 0 for c in calls) + if spec["freeze"]: + cost, snapshot = compute_llm_cost(calls, PRICE_CFG) # 复用生产纯函数 + else: + cost, snapshot = None, None # 旧记录:回填这段代码上线前就有,只有 token 没成本 + + rec = ComparisonRecord( + user_id=owner_id, + device_id=f"{ID_PREFIX.lower()}dev", + business_type="food", + trace_id=f"{ID_PREFIX}{spec['label'][0]}", # ①..⑤ 各一,唯一 + status="success", + source_platform_id=_PLATFORM_ID.get(src_name), source_platform_name=src_name, + source_price_cents=src_cents, + best_platform_id=_PLATFORM_ID.get(best_name), best_platform_name=best_name, + best_price_cents=best_cents, + saved_amount_cents=src_cents - best_cents, + is_source_best=False, + store_name=spec["store"], + product_names=spec["product"], + information=spec["info"], + items=[{"name": spec["product"], "qty": 1}], + comparison_results=[ + {"platform_id": _PLATFORM_ID.get(src_name), "platform_name": src_name, + "price": src_cents / 100, "is_source": True, "rank": 2}, + {"platform_id": _PLATFORM_ID.get(best_name), "platform_name": best_name, + "price": best_cents / 100, "is_source": False, "rank": 1}, + ], + total_ms=90_000 + llm_call_count * 1000, + step_count=llm_call_count * 3, + llm_call_count=llm_call_count, + retry_count=retry_count, + input_tokens=input_tokens, + output_tokens=output_tokens, + llm_calls=calls, + llm_cost_yuan=cost, + llm_price_snapshot=snapshot, + created_at=created_at, + ) + return rec, cost + + +def seed(db) -> list[tuple[str, float | None]]: + app_config.set_value(db, "llm_token_price", PRICE_CFG, admin_id=None) # upsert 单价 + owner_id = db.execute(select(User.id).order_by(User.id).limit(1)).scalar() + base = _naive_bj_now() + out: list[tuple[str, float | None]] = [] + for i, spec in enumerate(RECORDS): + rec, cost = _build_record(spec, owner_id, base - timedelta(minutes=i * 3)) + db.add(rec) + out.append((spec["label"], cost)) + db.commit() + return out, owner_id + + +def main() -> None: + parser = argparse.ArgumentParser(description="造带 LLM 成本的比价记录 + app_config 模型单价(测管理后端)") + parser.add_argument("--clean-only", action="store_true", help="只清 MOCKLLM- 记录,不重建(保留单价)") + args = parser.parse_args() + + db = SessionLocal() + try: + removed = clean(db) + if removed: + print(f"🧹 已清理旧 mock 记录 {removed} 条") + if args.clean_only: + print("✅ 仅清理,已完成(app_config 单价保留)。") + return + + results, owner_id = seed(db) + print(f"\n✅ 已写入 app_config.llm_token_price(单价)+ {len(results)} 条比价记录" + f"(挂 user_id={owner_id or 'NULL(孤儿行)'})") + print("\n📋 每条冻结成本(admin 详情「LLM 成本」应显示):") + for label, cost in results: + shown = "NULL → 前端回退「估算」" if cost is None else f"¥{cost}" + print(f" {label:<20} {shown}") + print("\n👉 验收:admin「比价记录」→ trace 搜「MOCKLLM-」→ 点开详情核对 LLM 成本 + 价格快照。") + print(" 配置页「系统配置」→「福利页」Tab →「LLM 成本」卡片,单价应为「已改」态。") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/tests/test_llm_cost.py b/tests/test_llm_cost.py new file mode 100644 index 0000000..1e1c1c3 --- /dev/null +++ b/tests/test_llm_cost.py @@ -0,0 +1,184 @@ +"""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 + + 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(compare_record, "fetch_llm_calls", lambda trace_id: sample) + + 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