zizi 9a513e79b8 feat(llm): 全局额度治理chat_governed——5h窗计钱计次/超$10自动切链/满4000次跳窗/共享账本,四调用方降级链上收
额度系统(item4,创始人2026-07-16拍板):llm.py 新增 chat_governed 统一入口——按5小时窗(00/05/10/15/20,末窗20-24)计钱计次;MiniMax(M3/M2.7)超$10/窗自动切 glm-5.2→deepseek-v4-flash;满4000次/窗自动睡到下一窗续跑;共享账本 example_llm_quota 原子累加,多分片共用一本;连接短开短关(LLM/睡眠期不持DB连接,治Tailscale空转)。parse_llm/parse_outline/parse_ingest/review_cards 各自的降级链上收复用统一路由,保各自异常契约(SensitiveHardStop/SensitiveError/判重保守keep)。新增离线单测 test_quota.py(9例:窗口归窗/计价缓存折扣/六条路由)+DDL db/ddl/95。

主代理亲验上线:9条离线单测亲跑全过;真调用对账端到端闭合(算11.04配额单位≈服务端实扣11);账本两笔原子累加正确;校准噪音已清空、额度从零起。

review_cards.py 一并携带批9c崩溃修复(连接读/算/写三段拆分+分片--shard+敏感降级),五书全量重审赖此收官。批9c收官锚点入 README§九+docs方案doc;深空升格样张入档。
2026-07-16 23:49:04 +08:00

189 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""llm 额度治理离线单测:全程打桩 _now/_read_window/_bump_window/chat/time.sleep,
不碰真 DB、不发真请求、不做任何真实 LLM 调用。直接 `.venv/bin/python test_quota.py` 跑(无需 pytest)。
覆盖:window_key 归窗 / seconds_to_next_window 边界 / cost_usd 计价(含缓存折扣) /
chat_governed 六条路由(首选成功、预算耗尽切链、敏感换模型、不可用换模型、全链失败、达上限睡窗)。"""
import pathlib
import sys
import types
from datetime import datetime, timedelta
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import llm # noqa: E402
# 费率缓存预置为兜底表:cost_usd/chat_governed 记账时 get_pricing() 直接命中缓存,绝不触网
# (硬约束:单测不发真请求;兜底费率与 New-API 快照一致,计价结果确定可断言)
llm._PRICING_CACHE = dict(llm.PRICING_FALLBACK)
# 成功调用的假 usage(M3 计价:(100)+50×4=300 单位 → 成本>0,用于断言 MiniMax 计入预算)
_USAGE = {"prompt_tokens": 100, "completion_tokens": 50,
"prompt_tokens_details": {"cached_tokens": 0}}
def _install(now, reads, chat, sleep=None):
"""把一组桩装到 llm 模块全局,返回 bumps 记录列表(每项 = (window_key, add_usd))。
now:datetime(固定)或无参可调用(动态时钟);reads:{window_key:(usd,calls)} 或可调用。"""
bumps = []
llm._now = now if callable(now) else (lambda: now)
llm._read_window = (lambda wk: reads(wk)) if callable(reads) \
else (lambda wk: reads.get(wk, (0.0, 0)))
def fake_bump(wk, add):
bumps.append((wk, add))
return (add, 1) # 返回值 chat_governed 不消费,占位即可
llm._bump_window = fake_bump
llm.chat = chat
# 只替换 time.sleep(chat 被打桩后 chat_governed 路径不再用 time.time),不污染真 time 模块
llm.time = types.SimpleNamespace(sleep=(sleep if sleep else (lambda s: None)))
return bumps
def _ok_chat(prompt, model=None, **kw):
"""万能成功桩:任何模型都返回内容。"""
return "ok", dict(_USAGE)
def test_window_key():
d = lambda h: datetime(2026, 7, 16, h, 30)
assert llm.window_key(d(0)) == "2026-07-16T00", "0点应归 T00"
assert llm.window_key(d(4)) == "2026-07-16T00", "4点应归 T00"
assert llm.window_key(d(5)) == "2026-07-16T05", "5点应归 T05"
assert llm.window_key(d(9)) == "2026-07-16T05", "9点应归 T05"
assert llm.window_key(d(10)) == "2026-07-16T10", "10点应归 T10"
assert llm.window_key(d(14)) == "2026-07-16T10", "14点应归 T10"
assert llm.window_key(d(15)) == "2026-07-16T15", "15点应归 T15"
assert llm.window_key(d(19)) == "2026-07-16T15", "19点应归 T15"
assert llm.window_key(d(20)) == "2026-07-16T20", "20点应归 T20"
assert llm.window_key(d(23)) == "2026-07-16T20", "23点应归 T20"
def test_seconds_to_next_window():
def near(got, want):
assert abs(got - want) <= 1, f"距下一窗秒数应≈{want},实得{got}"
near(llm.seconds_to_next_window(datetime(2026, 7, 16, 3, 0)), 7200) # 03:00→05:00
near(llm.seconds_to_next_window(datetime(2026, 7, 16, 19, 30)), 1800) # 19:30→20:00
near(llm.seconds_to_next_window(datetime(2026, 7, 16, 21, 0)), 10800) # 21:00→次日00:00
near(llm.seconds_to_next_window(datetime(2026, 7, 16, 23, 59)), 60) # 23:59→次日00:00
def test_cost_usd():
# M3 无缓存:billable=(1000)+500×4=3000 → 0.15×3000/500000=0.0009
c1 = llm.cost_usd("MiniMax-M3", {"prompt_tokens": 1000, "completion_tokens": 500,
"prompt_tokens_details": {"cached_tokens": 0}})
assert abs(c1 - 0.0009) < 1e-9, f"M3 无缓存成本应=0.0009,实得{c1}"
# M3 有缓存 400:billable=(1000-400)+400×0.2+500×4=2680 → 0.15×2680/500000=0.000804
c2 = llm.cost_usd("MiniMax-M3", {"prompt_tokens": 1000, "completion_tokens": 500,
"prompt_tokens_details": {"cached_tokens": 400}})
assert abs(c2 - 0.000804) < 1e-9, f"M3 缓存折扣后成本应=0.000804,实得{c2}"
def test_route_first_success():
"""①usd=0/calls=0:链首 M3 成功 → 返回 MiniMax-M3、记账 usd>0、bump 一次到本窗。"""
calls = []
def chat(prompt, model=None, **kw):
calls.append(model)
return "ok", dict(_USAGE)
bumps = _install(datetime(2026, 7, 16, 12, 0), {"2026-07-16T10": (0.0, 0)}, chat)
content, usage, used = llm.chat_governed("p")
assert used == "MiniMax-M3", f"首选应为 M3,实得{used}"
assert content == "ok" and calls[0] == "MiniMax-M3", "第一个尝试的模型应是 M3"
assert len(bumps) == 1 and bumps[0][0] == "2026-07-16T10", "应向本窗 T10 记账一次"
assert bumps[0][1] > 0, f"M3 是 MiniMax,记账成本应>0,实得{bumps[0][1]}"
def test_route_budget_exhausted():
"""②usd=10.5≥预算:链首变 glm-5.2(摘掉 MiniMax)、记账增量=0。"""
calls = []
def chat(prompt, model=None, **kw):
calls.append(model)
return "ok", dict(_USAGE)
bumps = _install(datetime(2026, 7, 16, 12, 0), {"2026-07-16T10": (10.5, 0)}, chat)
content, usage, used = llm.chat_governed("p")
assert calls[0] == "glm-5.2", f"预算耗尽后链首应为 glm-5.2,实得{calls[0]}"
assert used == "glm-5.2", f"应由 glm-5.2 产出,实得{used}"
assert bumps[0][1] == 0.0, f"glm 非 MiniMax,记账增量应=0,实得{bumps[0][1]}"
def test_route_sensitive_then_next():
"""③M3 抛 SensitiveError、M2.7 成功 → 返回 MiniMax-M2.7。"""
calls = []
def chat(prompt, model=None, **kw):
calls.append(model)
if model == "MiniMax-M3":
raise llm.SensitiveError("敏感")
return "ok", dict(_USAGE)
bumps = _install(datetime(2026, 7, 16, 12, 0), {"2026-07-16T10": (0.0, 0)}, chat)
content, usage, used = llm.chat_governed("p")
assert calls == ["MiniMax-M3", "MiniMax-M2.7"], f"应 M3 敏感后换 M2.7,实得{calls}"
assert used == "MiniMax-M2.7", f"应由 M2.7 产出,实得{used}"
assert bumps[0][1] > 0, "M2.7 是 MiniMax,记账成本应>0"
def test_route_runtime_then_next():
"""④glm 抛 RuntimeError、deepseek 成功 → 返回 deepseek-v4-flash(前两 MiniMax 先敏感耗尽)。"""
calls = []
def chat(prompt, model=None, **kw):
calls.append(model)
if model in ("MiniMax-M3", "MiniMax-M2.7"):
raise llm.SensitiveError("敏感")
if model == "glm-5.2":
raise RuntimeError("不可用")
return "ok", dict(_USAGE)
bumps = _install(datetime(2026, 7, 16, 12, 0), {"2026-07-16T10": (0.0, 0)}, chat)
content, usage, used = llm.chat_governed("p")
assert calls == ["MiniMax-M3", "MiniMax-M2.7", "glm-5.2", "deepseek-v4-flash"], \
f"应走完全链到 deepseek,实得{calls}"
assert used == "deepseek-v4-flash", f"应由 deepseek 产出,实得{used}"
assert bumps[0][1] == 0.0, "deepseek 非 MiniMax,记账增量应=0"
def test_route_all_fail():
"""⑤全链抛 SensitiveError → 返回 (None,None,None)、绝不记账。"""
def chat(prompt, model=None, **kw):
raise llm.SensitiveError("全敏感")
bumps = _install(datetime(2026, 7, 16, 12, 0), {"2026-07-16T10": (0.0, 0)}, chat)
content, usage, used = llm.chat_governed("p")
assert (content, usage, used) == (None, None, None), "全链失败应返回三元 None"
assert bumps == [], "全链失败不得记账"
def test_route_call_cap_sleep():
"""⑥calls=4000 达上限 → sleep 桩被调一次、_now 推进到下一窗后 calls 归 0 续跑成功。"""
clock = {"t": datetime(2026, 7, 16, 12, 0)} # 起始窗 T10(12:00)
sleeps = []
def fake_sleep(secs):
sleeps.append(secs)
clock["t"] = clock["t"] + timedelta(seconds=secs) # 睡到下一窗边界(15:00)
reads = {"2026-07-16T10": (0.0, 4000), "2026-07-16T15": (0.0, 0)}
calls = []
def chat(prompt, model=None, **kw):
calls.append(model)
return "ok", dict(_USAGE)
bumps = _install(lambda: clock["t"], reads, chat, sleep=fake_sleep)
content, usage, used = llm.chat_governed("p")
assert len(sleeps) == 1, f"达上限应只睡一次,实得{len(sleeps)}次"
assert used == "MiniMax-M3", f"醒来在新窗应 M3 成功,实得{used}"
assert bumps[0][0] == "2026-07-16T15", f"应记账到下一窗 T15,实得{bumps[0][0]}"
def main():
tests = [test_window_key, test_seconds_to_next_window, test_cost_usd,
test_route_first_success, test_route_budget_exhausted,
test_route_sensitive_then_next, test_route_runtime_then_next,
test_route_all_fail, test_route_call_cap_sleep]
for t in tests:
t()
print(f" ✓ {t.__name__}")
print("全部通过")
if __name__ == "__main__":
main()