diff --git a/.claude/skills/llm/scripts/llm.py b/.claude/skills/llm/scripts/llm.py index adb747c..c2a2dce 100644 --- a/.claude/skills/llm/scripts/llm.py +++ b/.claude/skills/llm/scripts/llm.py @@ -29,6 +29,12 @@ MINIMAX_MODELS = {"MiniMax-M3", "MiniMax-M2.7"} # 计入每窗预算的 BUDGET_CHAIN = ["MiniMax-M3", "MiniMax-M2.7", "glm-5.2", "deepseek-v4-flash"] # 全局统一降级链 WINDOW_BUDGET_USD = 24.0 # 每窗 MiniMax 花费上限(创始人 2026-07-18 提额 $10→$24),超则切 glm-5.2→deepseek WINDOW_CALL_CAP = 6000 # 每窗全模型调用上限(创始人 2026-07-18 提额 4000→6000),达则自动睡到下一窗续跑 +# 上游实测上限:请求前主动裁剪,避免依赖不同渠道含混甚至错误的 HTTP 400 文案再猜测重发。 +# M3 / deepseek 未观察到该限制,故不在表内、不主动裁剪。 +MODEL_MAX_TOKENS = { + "MiniMax-M2.7": 196608, + "glm-5.2": 12000, +} # 费率兜底(model_ratio, completion_ratio, cache_ratio),与 New-API /api/pricing 一致(2026-07-16 快照) PRICING_FALLBACK = { "MiniMax-M3": (0.15, 4.0, 0.2), @@ -45,14 +51,21 @@ class SensitiveError(Exception): 立即抛给上层走模型降级链(创始人 2026-07-14:M3→MiniMax-M2.7→deepseek-v4-flash)。""" +class PlanQuotaExhausted(Exception): + """上游模型渠道的 Token Plan 已耗尽。 + + 该错误在同一额度窗内重试不会恢复,必须立即交给治理层熔断当前模型;它与普通限流 429 + 不同,普通 429 仍保留指数退避重试。""" + + def chat(prompt, model=DEFAULT_MODEL, max_tokens=512000, temperature=0.2, retries=2, timeout=900, system=None, top_p=None): - """单轮对话,返回 (content, usage)。网络错/5xx/429 指数退避重试。 + """单轮对话,返回 (content, usage)。网络错/5xx/普通 429 指数退避重试。 content 已剥离 …(推理模型可能把思考混进正文)。 system:身份段与任务材料分离(角色遵从更稳、身份段利于上游缓存)。 top_p:随 temperature 分化实验用(M 家族官方推荐 1.0/0.95,eval A/B 后定版)。 - max_tokens 默认 512000(创始人 2026-07-15 拍板:不设人为输出限制)。 + max_tokens 默认 512000;仅对有实测硬上限的 M2.7/GLM 请求前主动裁剪。 预扣费机制备忘:New-API 按 max_tokens 预扣(512k 预扣 $0.15375/次,网关已验证接受该值; 结算按实际用量,余额充足时预扣不产生额外成本)——**余额须 ≥ 并发路数 × $0.154**, 否则触发 403「预扣费额度失败」(2026-07-15 余额见底实测坐实此机制)。 @@ -61,10 +74,15 @@ def chat(prompt, model=DEFAULT_MODEL, max_tokens=512000, temperature=0.2, s.trust_env = False # 本机代理 env 会劫持内网直连 messages = ([{"role": "system", "content": system}] if system else []) \ + [{"role": "user", "content": prompt}] + model_cap = MODEL_MAX_TOKENS.get(model) + effective_max_tokens = min(max_tokens, model_cap) if model_cap is not None else max_tokens + if effective_max_tokens != max_tokens: + print(f"[llm] {model} max_tokens={max_tokens} 主动裁为模型上限 {effective_max_tokens}", + file=sys.stderr) payload = { "model": model, "messages": messages, - "max_tokens": max_tokens, + "max_tokens": effective_max_tokens, "temperature": temperature, } if top_p is not None: @@ -76,17 +94,9 @@ def chat(prompt, model=DEFAULT_MODEL, max_tokens=512000, temperature=0.2, r = s.post(f"{BASE}/v1/chat/completions", headers={"Authorization": f"Bearer {TOKEN}"}, json=payload, timeout=timeout) - # 各模型 max_tokens 上限不一(实测 M3 收 512k、M2.7 上限 196608 报 400): - # 撞上限自适应降档重发,保证降级链换模型时不被参数掀翻。 - # 必须在敏感检查之前处理——重发后的响应仍要完整走敏感/429/5xx 判定链 - if r.status_code == 400 and "max tokens" in r.text.lower(): - cur = payload.get("max_tokens", 0) - payload["max_tokens"] = 196608 if cur > 196608 else 12000 - print(f"[llm] {model} max_tokens={cur} 超模型上限,降为 {payload['max_tokens']} 重发", - file=sys.stderr) - r = s.post(f"{BASE}/v1/chat/completions", - headers={"Authorization": f"Bearer {TOKEN}"}, - json=payload, timeout=timeout) + # Token Plan 耗尽不是瞬时限流:同模型退避只会白等 8/16 秒,立即交治理层按窗熔断。 + if r.status_code == 429 and "Token Plan 用量上限" in r.text: + raise PlanQuotaExhausted(f"Token Plan 已耗尽 HTTP 429: {r.text[:200]}") # 内容安全拦截:同模型退避重试必再敏感,立即抛 SensitiveError 交上层 # 降级换模型,不在此浪费退避(否则一敏感章空烧 3 次,实测占放量请求 23%) if r.status_code >= 500 and "sensitive" in r.text.lower(): @@ -148,6 +158,16 @@ def extract_json(text): # 全窗调用达 6000 次自动睡到下一窗续跑——降级策略只此一处,调用方只管拿结果。 _PRICING_CACHE = None +# 仅保存当前额度窗内已确认 Token Plan 耗尽的模型。进程重启会自然重探;跨窗也会清空重探。 +_PLAN_QUOTA_OPEN = {} + + +def _plan_quota_open_models(wk): + """返回当前窗已熔断模型集合,并清除其他窗口的陈旧状态。""" + stale = [key for key in _PLAN_QUOTA_OPEN if key != wk] + for key in stale: + del _PLAN_QUOTA_OPEN[key] + return _PLAN_QUOTA_OPEN.setdefault(wk, set()) def get_pricing(): @@ -267,7 +287,7 @@ def chat_governed(prompt, model=DEFAULT_MODEL, system=None, max_tokens=512000, 策略(每次调用前): 1) 读本窗账本;本窗 total_calls ≥ WINDOW_CALL_CAP → 打日志、睡到下一窗边界(不持DB连接)、重读续跑; 2) 本窗 minimax_usd ≥ WINDOW_BUDGET_USD → 降级链去掉 MiniMax 前缀(只剩 glm-5.2→deepseek),否则用全链; - 3) 沿链逐个模型调 chat():撞 SensitiveError 或 RuntimeError(重试耗尽/不可用) → 换下一个;成功即止; + 3) 跳过本窗已确认 Token Plan 耗尽的模型;其余模型沿链调用,敏感/不可用时换下一个;成功即止; 4) 成功后 _bump_window(本窗, MiniMax模型才计成本否则0),返回三元组;全链失败返回 (None,None,None)。""" from datetime import timedelta while True: @@ -291,11 +311,21 @@ def chat_governed(prompt, model=DEFAULT_MODEL, system=None, max_tokens=512000, f"本窗改用非 MiniMax 链 {chain}", file=sys.stderr) else: chain = list(BUDGET_CHAIN) + plan_quota_open = _plan_quota_open_models(wk) + skipped = [m for m in chain if m in plan_quota_open] + if skipped: + print(f"[llm] 本窗 {wk} 跳过 Token Plan 已耗尽模型 {skipped}", file=sys.stderr) + chain = [m for m in chain if m not in plan_quota_open] # 3) 沿链逐个模型调用;撞敏感/不可用换下一个 for m in chain: try: content, usage = chat(prompt, model=m, system=system, max_tokens=max_tokens, temperature=temperature, top_p=top_p) + except PlanQuotaExhausted as e: + plan_quota_open.add(m) + print(f"[llm] 治理链 {m} Token Plan 本窗耗尽,立即熔断并降级下一个:{str(e)[:80]}", + file=sys.stderr) + continue except (SensitiveError, RuntimeError) as e: print(f"[llm] 治理链 {m} 失败({type(e).__name__}: {str(e)[:80]}),降级下一个", file=sys.stderr) diff --git a/.claude/skills/llm/scripts/test_quota.py b/.claude/skills/llm/scripts/test_quota.py index 0aa8025..4dd3171 100644 --- a/.claude/skills/llm/scripts/test_quota.py +++ b/.claude/skills/llm/scripts/test_quota.py @@ -3,7 +3,8 @@ 不碰真 DB、不发真请求、不做任何真实 LLM 调用。直接 `.venv/bin/python test_quota.py` 跑(无需 pytest)。 覆盖:window_key 归窗 / seconds_to_next_window 边界 / cost_usd 计价(含缓存折扣) / -chat_governed 六条路由(首选成功、$24 预算耗尽切链、敏感换模型、不可用换模型、全链失败、6000 次达上限睡窗)。""" +模型 max_tokens 主动上限 / Plan 429 不退避与按窗熔断 / 普通 429 重试 / +chat_governed 六条既有路由(首选成功、预算降级、敏感换模型、不可用换模型、全链失败、调用上限睡窗)。""" import pathlib import sys import types @@ -34,8 +35,9 @@ def _install(now, reads, chat, sleep=None): return (add, 1) # 返回值 chat_governed 不消费,占位即可 llm._bump_window = fake_bump llm.chat = chat + llm._PLAN_QUOTA_OPEN.clear() # 只替换 time.sleep(chat 被打桩后 chat_governed 路径不再用 time.time),不污染真 time 模块 - llm.time = types.SimpleNamespace(sleep=(sleep if sleep else (lambda s: None))) + llm.time = types.SimpleNamespace(sleep=(sleep if sleep else (lambda s: None)), time=lambda: 0) return bumps @@ -84,6 +86,129 @@ def test_current_quota_policy(): assert llm.WINDOW_CALL_CAP == 6000, "当前每窗总调用上限必须是 6000" +class _FakeResponse: + """只实现 chat() 所需的最小 requests.Response 接口。""" + + def __init__(self, status_code, text="", data=None): + self.status_code = status_code + self.text = text + self._data = data + + def raise_for_status(self): + if self.status_code >= 400: + raise llm.requests.HTTPError(f"HTTP {self.status_code}: {self.text}") + + def json(self): + return self._data + + +def _success_response(): + return _FakeResponse(200, data={ + "choices": [{"message": {"content": "ok"}, "finish_reason": "stop"}], + "usage": dict(_USAGE), + }) + + +def _run_chat_with_responses(model, responses, max_tokens=512000, retries=2): + """替换 HTTP 会话并执行一次 chat,返回(结果、请求 payload、sleep 记录)。""" + posts = [] + sleeps = [] + queue = list(responses) + + class FakeSession: + trust_env = True + + def post(self, url, headers=None, json=None, timeout=None): + posts.append(dict(json)) + return queue.pop(0) + + old_session, old_time = llm.requests.Session, llm.time + try: + llm.requests.Session = FakeSession + llm.time = types.SimpleNamespace(time=lambda: 0, sleep=lambda secs: sleeps.append(secs)) + result = llm.chat("p", model=model, max_tokens=max_tokens, retries=retries) + return result, posts, sleeps + finally: + llm.requests.Session, llm.time = old_session, old_time + + +def test_plan_429_does_not_retry(): + """Token Plan 429 必须首响应立即抛出,不进入 8/16 秒退避。""" + body = '{"error":{"message":"已达到 Token Plan 用量上限:请升级套餐"}}' + posts = [] + sleeps = [] + + class FakeSession: + trust_env = True + + def post(self, url, headers=None, json=None, timeout=None): + posts.append(dict(json)) + return _FakeResponse(429, text=body) + + old_session, old_time = llm.requests.Session, llm.time + try: + llm.requests.Session = FakeSession + llm.time = types.SimpleNamespace(time=lambda: 0, sleep=lambda secs: sleeps.append(secs)) + try: + llm.chat("p", model="MiniMax-M3", retries=2) + raise AssertionError("Plan 429 应抛 PlanQuotaExhausted") + except llm.PlanQuotaExhausted: + pass + finally: + llm.requests.Session, llm.time = old_session, old_time + assert len(posts) == 1, f"Plan 429 不应重试,实发 {len(posts)} 次" + assert sleeps == [], f"Plan 429 不应退避,实睡 {sleeps}" + + +def test_regular_429_still_retries(): + """普通速率限制 429 仍按原策略退避重试,不能误触发 Plan 熔断。""" + result, posts, sleeps = _run_chat_with_responses( + "MiniMax-M3", + [_FakeResponse(429, text='{"error":{"message":"rate limit"}}'), _success_response()], + retries=2, + ) + assert result[0] == "ok", "普通 429 后应重试成功" + assert len(posts) == 2, f"普通 429 应重试一次,实发 {len(posts)} 次" + assert sleeps == [8], f"第一次普通 429 应退避 8 秒,实得 {sleeps}" + + +def test_model_max_tokens_caps(): + """只主动裁 M2.7/GLM;M3/deepseek 保留调用方请求值。""" + cases = [ + ("MiniMax-M2.7", 196608), + ("glm-5.2", 12000), + ("MiniMax-M3", 512000), + ("deepseek-v4-flash", 512000), + ] + for model, expected in cases: + _, posts, _ = _run_chat_with_responses(model, [_success_response()]) + assert posts[0]["max_tokens"] == expected, \ + f"{model} max_tokens 应为 {expected},实得 {posts[0]['max_tokens']}" + + +def test_plan_quota_breaker_same_window_and_recovery(): + """M3/M2.7 同窗各探一次后跳过;跨窗清空熔断并从 M3 重探。""" + clock = {"t": datetime(2026, 7, 16, 12, 0)} + calls = [] + + def chat(prompt, model=None, **kw): + calls.append((llm.window_key(clock["t"]), model)) + if llm.window_key(clock["t"]) == "2026-07-16T10" and model in llm.MINIMAX_MODELS: + raise llm.PlanQuotaExhausted("plan exhausted") + return "ok", dict(_USAGE) + + _install(lambda: clock["t"], lambda wk: (0.0, 0), chat) + assert llm.chat_governed("first")[2] == "glm-5.2" + assert llm.chat_governed("second")[2] == "glm-5.2" + old_window_models = [m for wk, m in calls if wk == "2026-07-16T10"] + assert old_window_models == ["MiniMax-M3", "MiniMax-M2.7", "glm-5.2", "glm-5.2"], \ + f"同窗 MiniMax 应各探一次后跳过,实得 {old_window_models}" + + clock["t"] = datetime(2026, 7, 16, 15, 0) + assert llm.chat_governed("third")[2] == "MiniMax-M3" + assert calls[-1] == ("2026-07-16T15", "MiniMax-M3"), "跨窗后应清空熔断并重探 M3" + + def test_route_first_success(): """①usd=0/calls=0:链首 M3 成功 → 返回 MiniMax-M3、记账 usd>0、bump 一次到本窗。""" calls = [] @@ -186,6 +311,8 @@ def test_route_call_cap_sleep(): def main(): tests = [test_window_key, test_seconds_to_next_window, test_cost_usd, test_current_quota_policy, + test_plan_429_does_not_retry, test_regular_429_still_retries, + test_model_max_tokens_caps, test_plan_quota_breaker_same_window_and_recovery, 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]