Compare commits
2 Commits
3146224944
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e9fd51d119 | |||
| 5c6840dd71 |
@@ -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")
|
||||
@@ -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
|
||||
|
||||
+24
-15
@@ -12,11 +12,16 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import test_account
|
||||
from app.core.ratelimit import enforce_rate_limit
|
||||
from app.core.ratelimit import (
|
||||
RateLimitRule,
|
||||
check_rate_limits,
|
||||
enforce_rate_limit,
|
||||
record_rate_limits,
|
||||
)
|
||||
from app.core.security import TokenError, decode_token, issue_token_pair
|
||||
from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone
|
||||
from app.integrations.sms import SmsError, send_code, verify_code
|
||||
@@ -40,9 +45,10 @@ router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
# 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。
|
||||
SMS_LOGIN_MAX_PER_HOUR = 5
|
||||
# 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。
|
||||
# 发码防刷(同一设备 device_id + 同一 IP,**只按成功发码计数**;被单号 60s 冷却挡下的重发不占额度):
|
||||
# 堵「换手机号绕开单号 60s 冷却」的洞 —— 冷却是单号维度,一机换号能绕开。
|
||||
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5
|
||||
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5 # 每小时上限
|
||||
SMS_SEND_MAX_PER_DAY_PER_DEVICE = 20 # 每天上限(再叠一层日封顶,挡低频长时间轰炸)
|
||||
|
||||
|
||||
def _login_response(
|
||||
@@ -99,23 +105,26 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
|
||||
logger.info("test_account sms_send short-circuit (不真发)")
|
||||
return SmsSendResponse(sent=True, mock=True, cooldown_sec=0)
|
||||
|
||||
# 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_SEND_MAX_PER_HOUR_PER_DEVICE 次发码。
|
||||
# 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,
|
||||
# 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。
|
||||
enforce_rate_limit(
|
||||
request,
|
||||
scope="sms-send-device",
|
||||
subject=req.device_id,
|
||||
limit=SMS_SEND_MAX_PER_HOUR_PER_DEVICE,
|
||||
window_sec=3600,
|
||||
detail="操作过于频繁,请稍后再试",
|
||||
)
|
||||
# 发码防刷:同一设备(device_id) + 同一 IP,每小时 / 每天两道闸,**均只按成功发码计数**。
|
||||
# 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,挡短信轰炸/烧钱。
|
||||
# 关键:被单号 60s 冷却挡下的重发是「没真发、没烧钱」→ 不该占额度。故 check(先判)放在真发之前
|
||||
# (超限直接 429、不真发),record(计数)只在 send_code 成功后调 —— 冷却/供应商失败抛 429 时直接返回、不计数。
|
||||
send_rules = [
|
||||
RateLimitRule("sms-send-device", SMS_SEND_MAX_PER_HOUR_PER_DEVICE, 3600,
|
||||
"操作过于频繁,请稍后再试"),
|
||||
RateLimitRule("sms-send-device-daily", SMS_SEND_MAX_PER_DAY_PER_DEVICE, 86400,
|
||||
"今日验证码发送次数过多,请明天再试"),
|
||||
]
|
||||
check_rate_limits(request, subject=req.device_id, rules=send_rules)
|
||||
|
||||
try:
|
||||
cooldown = send_code(req.phone)
|
||||
except SmsError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
|
||||
|
||||
# 发码成功 → 两道闸各 +1(被单号冷却挡下的重发走不到这里,故不占额度)
|
||||
record_rate_limits(request, subject=req.device_id, rules=send_rules)
|
||||
|
||||
from app.core.config import settings # 局部 import 避免循环
|
||||
|
||||
return SmsSendResponse(sent=True, mock=settings.SMS_MOCK, cooldown_sec=cooldown)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 兜底未登记的模型。改价只影响之后回填的新记录,历史记录用当时价格快照。"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
+93
-8
@@ -9,29 +9,41 @@ from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import NamedTuple
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# key -> (window_start_ts, count)
|
||||
_buckets: dict[str, tuple[float, int]] = {}
|
||||
# key -> (window_start_ts, count, window_sec)
|
||||
# 存每个 key 自己的 window_sec:_buckets 混着不同窗口(60s 广告 / 3600s 登录 / 86400s 日闸)的 key,
|
||||
# GC 必须按各 key 自己的窗口判过期(见 [_purge_expired]),否则短窗口调用触发的 GC 会误删长窗口 key。
|
||||
_buckets: dict[str, tuple[float, int, float]] = {}
|
||||
_lock = threading.Lock()
|
||||
_GC_THRESHOLD = 10000 # _buckets 超此阈值才顺手清过期 key(仿 sms.py;测试可 monkeypatch 调小强制每次扫)
|
||||
|
||||
|
||||
def _purge_expired(now: float) -> None:
|
||||
"""清过期 key(**仅在持有 _lock 时调用**)。按每个 key 自己存的 window_sec 判过期,而非调用方的窗口
|
||||
—— _buckets 是全局共享、混着 60s(广告)/3600s(登录)/86400s(日闸)不同窗口的 key;若用调用方窗口,
|
||||
高频的 60s 广告端点触发 GC 时会把本该活 3600s/86400s 的登录/日闸计数一并删掉,使其在规模上(超阈值才
|
||||
触发本清理)被反复清零而失效。仅在超阈值时扫,低频、开销可忽略。"""
|
||||
if len(_buckets) <= _GC_THRESHOLD:
|
||||
return
|
||||
for k in [k for k, (s, _, w) in _buckets.items() if now - s >= w]:
|
||||
_buckets.pop(k, None)
|
||||
|
||||
|
||||
def _hit(key: str, limit: int, window_sec: float) -> bool:
|
||||
"""记一次访问。返回 True=放行,False=超限。"""
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
start, count = _buckets.get(key, (now, 0))
|
||||
start, count, _ = _buckets.get(key, (now, 0, window_sec))
|
||||
if now - start >= window_sec: # 窗口过期,重置
|
||||
start, count = now, 0
|
||||
count += 1
|
||||
_buckets[key] = (start, count)
|
||||
# 顺手清理过期 key,防内存无限涨(低频访问足够)
|
||||
if len(_buckets) > 10000:
|
||||
for k in [k for k, (s, _) in _buckets.items() if now - s >= window_sec]:
|
||||
_buckets.pop(k, None)
|
||||
_buckets[key] = (start, count, window_sec)
|
||||
_purge_expired(now) # 顺手清过期 key(按各自窗口),防内存无限涨
|
||||
return count <= limit
|
||||
|
||||
|
||||
@@ -83,3 +95,76 @@ def enforce_rate_limit(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
# ===================== 先判 / 后记(只按「成功」计数)=====================
|
||||
# _hit 是原子「判+记」:一调用就 +1,适合登录爆破(失败尝试也要计)。但对「短信发码」这类
|
||||
# **只想给成功动作计数**的场景不合适 —— 被单号冷却挡下的重发没真发、没烧钱,不该占额度。
|
||||
# 故拆成 _peek(只判不记)+ _commit(只记):check_rate_limits 先判 → 动作 → 成功后 record。
|
||||
|
||||
|
||||
class RateLimitRule(NamedTuple):
|
||||
"""一条限流规则。scope 区分不同闸(不同 key 前缀);同一 (subject, IP) 在 window_sec
|
||||
内最多 limit 次,超限抛 429 用 detail 文案。
|
||||
|
||||
(scope, window_sec) 成对绑在一条规则里 —— check(先判)与 record(计数)复用同一条,
|
||||
避免两处把窗口/scope 写歪导致 key 对不上。
|
||||
"""
|
||||
|
||||
scope: str
|
||||
limit: int
|
||||
window_sec: float
|
||||
detail: str = "操作过于频繁,请稍后再试"
|
||||
|
||||
|
||||
def _peek(key: str, limit: int, window_sec: float) -> bool:
|
||||
"""只读:当前窗口内是否还没到上限(count < limit)。**不改计数**。
|
||||
与 [_commit] 配对实现「先判后记」——只在动作成功后才 _commit。"""
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
start, count, _ = _buckets.get(key, (now, 0, window_sec))
|
||||
if now - start >= window_sec: # 窗口已过期 → 视作已重置(count 归零)
|
||||
count = 0
|
||||
return count < limit
|
||||
|
||||
|
||||
def _commit(key: str, window_sec: float) -> None:
|
||||
"""记一次访问(+1)。窗口过期则以本次为起点重置。仅在动作成功后调用。"""
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
start, count, _ = _buckets.get(key, (now, 0, window_sec))
|
||||
if now - start >= window_sec: # 窗口过期,重置
|
||||
start, count = now, 0
|
||||
_buckets[key] = (start, count + 1, window_sec)
|
||||
_purge_expired(now) # 顺手清过期 key(按各自窗口,同 [_hit])
|
||||
|
||||
|
||||
def check_rate_limits(request: Request, subject: str, rules: list[RateLimitRule]) -> None:
|
||||
"""【先判】一组限流:任一规则已达上限即抛 429,且**不改计数**。
|
||||
|
||||
配合 [record_rate_limits] 实现「只按成功计数」:先 check 所有闸(全未超才继续)→ 执行动作
|
||||
→ 动作**成功后**再 record。动作被下游挡下(如短信单号冷却)、没真正发生时不 record → 不占额度。
|
||||
key = `scope:subject:client_ip`(与 [enforce_rate_limit] 同款)。
|
||||
"""
|
||||
if not settings.RATE_LIMIT_ENABLED:
|
||||
return
|
||||
ip = _client_ip(request)
|
||||
for rule in rules:
|
||||
if not _peek(f"{rule.scope}:{subject}:{ip}", rule.limit, rule.window_sec):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=rule.detail,
|
||||
)
|
||||
|
||||
|
||||
def record_rate_limits(request: Request, subject: str, rules: list[RateLimitRule]) -> None:
|
||||
"""【记一次】一组限流(每条规则 +1)。仅在动作成功后调用,与 [check_rate_limits] 配对。
|
||||
|
||||
⚠️ check→动作→record 非原子:并发突发下计数可能略超 limit(每个在途请求各 +1)。对
|
||||
「防脚本/防轰炸」的安全网定位可接受;要精确配额需迁 Redis(见模块 docstring)。
|
||||
"""
|
||||
if not settings.RATE_LIMIT_ENABLED:
|
||||
return
|
||||
ip = _client_ip(request)
|
||||
for rule in rules:
|
||||
_commit(f"{rule.scope}:{subject}:{ip}", rule.window_sec)
|
||||
|
||||
@@ -13,7 +13,8 @@ worker / 多机时内存不共享 → 冷却、校验都会失效,届时迁移
|
||||
|
||||
防刷两层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权):
|
||||
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件)
|
||||
2. 单设备(device_id)每小时频控(api 层 auth.sms_send 内 enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)。
|
||||
2. 单设备(device_id)+ IP 每小时 / 每天频控(api 层 auth.sms_send 的 check/record_rate_limits,
|
||||
**只按成功发码计数** —— 被本文件单号冷却挡下的重发不占额度)+ 极光控制台 IP 白名单/防轰炸(运维侧)。
|
||||
⚠️ 原「单 IP 频控(rate_limit 依赖)」2026-06-26 按产品要求删除、改设备维度;但 device_id 客户端可伪造/轮换,
|
||||
脚本轮换 id 能绕过本层 → 挡脚本狂发主要靠极光控制台侧(+ 可选 nginx 限流)。
|
||||
⚠️ 原「单号每日上限」2026-07-03 按精简要求删除(mentor 定:登录风控只留单号冷却 + 单设备频控);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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 集合` 现挂到实例上供出参用。
|
||||
|
||||
@@ -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()
|
||||
@@ -107,6 +107,67 @@ def test_sms_send_device_ip_rate_limit(client, monkeypatch) -> None:
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_sms_send_cooldown_reject_not_counted(client, monkeypatch) -> None:
|
||||
"""发码额度只算「成功发码」:被单号 60s 冷却挡下的重发(429)不占设备额度。
|
||||
做法:同号狂发只成功 1 次、其余被冷却挡下;把小时额度设 2,证明换号后仍能再成功发 1 次
|
||||
—— 若冷却重发也计数,额度早被那几次耗尽。"""
|
||||
from app.api.v1 import auth
|
||||
from app.core import ratelimit
|
||||
|
||||
monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True)
|
||||
monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_HOUR_PER_DEVICE", 2)
|
||||
ratelimit._buckets.clear()
|
||||
|
||||
device = "dev-cooldown"
|
||||
phone_a = "13710137000"
|
||||
# 首发成功(小时闸计 1/2)
|
||||
assert client.post(
|
||||
"/api/v1/auth/sms/send", json={"phone": phone_a, "device_id": device}
|
||||
).status_code == 200
|
||||
# 同号连发 3 次:都被单号 60s 冷却挡下 → 429,且**不占**设备额度
|
||||
for _ in range(3):
|
||||
r = client.post(
|
||||
"/api/v1/auth/sms/send", json={"phone": phone_a, "device_id": device}
|
||||
)
|
||||
assert r.status_code == 429, r.text
|
||||
# 换号再发:设备额度只用了 1/2(冷却那几次没算)→ 仍放行(计到 2/2)
|
||||
assert client.post(
|
||||
"/api/v1/auth/sms/send", json={"phone": "13710137001", "device_id": device}
|
||||
).status_code == 200
|
||||
# 又换号:此时小时闸已 2/2 → 429(反证成功发码确实各计了 1)
|
||||
r = client.post(
|
||||
"/api/v1/auth/sms/send", json={"phone": "13710137002", "device_id": device}
|
||||
)
|
||||
assert r.status_code == 429, r.text
|
||||
|
||||
|
||||
def test_sms_send_daily_cap(client, monkeypatch) -> None:
|
||||
"""每天发码上限(设备 + IP):成功发码累计到日上限即 429(用不同手机号绕开单号冷却)。
|
||||
抬高小时闸单独测日闸;超限文案含「今日」以便前端提示明天再来。"""
|
||||
from app.api.v1 import auth
|
||||
from app.core import ratelimit
|
||||
|
||||
monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True)
|
||||
monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_HOUR_PER_DEVICE", 100) # 抬高小时闸,不干扰
|
||||
monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_DAY_PER_DEVICE", 3)
|
||||
ratelimit._buckets.clear()
|
||||
|
||||
device = "dev-daily"
|
||||
for i in range(3):
|
||||
r = client.post(
|
||||
"/api/v1/auth/sms/send",
|
||||
json={"phone": f"13720137{i:03d}", "device_id": device},
|
||||
)
|
||||
assert r.status_code == 200, f"第 {i + 1} 次应放行: {r.text}"
|
||||
# 第 4 次:同设备同 IP 当日超限 → 429
|
||||
r = client.post(
|
||||
"/api/v1/auth/sms/send",
|
||||
json={"phone": "13720137999", "device_id": device},
|
||||
)
|
||||
assert r.status_code == 429, r.text
|
||||
assert "今日" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_sms_login_device_ip_rate_limit(client, monkeypatch) -> None:
|
||||
"""防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_LOGIN_MAX_PER_HOUR 次登录尝试,超出 429。
|
||||
conftest 默认 RATE_LIMIT_ENABLED=false(内存计数跨用例累加),本用例临时打开并清空计数隔离。"""
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,57 @@
|
||||
"""ratelimit 内存桶过期清理(GC)测试。
|
||||
|
||||
回归重点:_buckets 是**全局共享**、混着不同窗口(60s 广告 / 3600s 登录 / 86400s 日闸)的 key。
|
||||
GC 必须按【每个 key 自己存的 window_sec】判过期,而不是当前调用方的窗口 —— 否则高频的 60s 端点
|
||||
触发 GC 时会把本该存活更久的 3600s/86400s 计数(如短信日闸)一并删掉,使其被反复清零、限流失效。
|
||||
用 monkeypatch 把 _GC_THRESHOLD 调 0 强制每次都扫,免造上万条(仿 test_auth 里对 sms._GC_THRESHOLD 的做法)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core import ratelimit
|
||||
|
||||
|
||||
def test_purge_expired_respects_each_key_own_window(monkeypatch) -> None:
|
||||
"""短窗口(60s)触发的 GC 只删真正过期的 key,不得删掉仍在自身窗口内的长窗口 key。"""
|
||||
monkeypatch.setattr(ratelimit, "_GC_THRESHOLD", 0) # 强制每次都扫
|
||||
ratelimit._buckets.clear()
|
||||
|
||||
now = 1_000_000.0
|
||||
# 日闸:100s 前开窗、window=86400 → 远未过期,必须保留
|
||||
ratelimit._buckets["sms-send-device-daily:D:IP"] = (now - 100, 7, 86400.0)
|
||||
# 登录:1800s、window=3600 → 未过期,保留
|
||||
ratelimit._buckets["sms-login-device:D:IP"] = (now - 1800, 2, 3600.0)
|
||||
# 广告:120s、window=60 → 已过期,应删
|
||||
ratelimit._buckets["ad-watch-report:IP2"] = (now - 120, 3, 60.0)
|
||||
|
||||
ratelimit._purge_expired(now)
|
||||
|
||||
assert "sms-send-device-daily:D:IP" in ratelimit._buckets
|
||||
assert "sms-login-device:D:IP" in ratelimit._buckets
|
||||
assert "ad-watch-report:IP2" not in ratelimit._buckets
|
||||
|
||||
|
||||
def test_purge_expired_keeps_long_window_key_older_than_short_window(monkeypatch) -> None:
|
||||
"""反证旧 bug:日闸 key 已老于 3600s,旧代码在 60s/3600s 端点触发 GC 时会误删它;
|
||||
现在按自身 86400s 窗口判 → 未过期 → 必须保留。"""
|
||||
monkeypatch.setattr(ratelimit, "_GC_THRESHOLD", 0)
|
||||
ratelimit._buckets.clear()
|
||||
|
||||
now = 2_000_000.0
|
||||
# 3700s 前开窗(> 1 小时),但 window=86400 → 未过期
|
||||
ratelimit._buckets["sms-send-device-daily:D:IP"] = (now - 3700, 20, 86400.0)
|
||||
|
||||
ratelimit._purge_expired(now)
|
||||
|
||||
assert "sms-send-device-daily:D:IP" in ratelimit._buckets
|
||||
|
||||
|
||||
def test_purge_expired_noop_below_threshold(monkeypatch) -> None:
|
||||
"""未超阈值时不扫(即便有过期 key 也不动),避免每次请求都 O(n) 扫全表。"""
|
||||
monkeypatch.setattr(ratelimit, "_GC_THRESHOLD", 10)
|
||||
ratelimit._buckets.clear()
|
||||
|
||||
now = 3_000_000.0
|
||||
ratelimit._buckets["stale:IP"] = (now - 999, 1, 60.0) # 早过期,但没超阈值
|
||||
ratelimit._purge_expired(now)
|
||||
|
||||
assert "stale:IP" in ratelimit._buckets # 桶数没超阈值 → 不清理
|
||||
Reference in New Issue
Block a user