Skip to content

Commit 5137ae0

Browse files
committed
fix(qoder): 流内信封与 HTTP 错误同一判据,403 不再一律判死
为什么改:`_decode_frame` 直接调 `classify_error_code`,403 不看会话失效 标记就判 DEAD——「每日计费次数用尽」(403 code 110 `Billing daily count exceeded`,实测 2026-10-27 15:39)这类次日自愈的号被 硬禁用,只能人工 revive。HTTP 层早已是「有标记才 DEAD、否则 SOFT」,两条 路径判据分叉是根因。 - `classify_error_code` 增加 401/403 分支:有会话失效标记 → DEAD,否则 SOFT;排队信号仍优先(排队说明号是好的,只是忙)。 - 新增 `DAILY_QUOTA_MARKERS` / `is_daily_quota_exceeded`:命中即 CREDIT, 冷却到次日而不是硬禁用,判据放在函数内保证任何入口都不漏。 - `classify_status` 去掉重复的 401/403 分支,统一委托 `classify_error_code`; 信封错误改成调用 `classify_status`,两条路径共用一套判据。 回归:`tests/provider/qoder` 133 passed(新增 3 例:流内 403 无标记 → SOFT、每日计费耗尽 → CREDIT、流内信封端到端 CREDIT);全量 4065 passed、 行/分支 100%。
1 parent d91573c commit 5137ae0

3 files changed

Lines changed: 79 additions & 11 deletions

File tree

‎src/provider/qoder/client.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,10 @@ def _decode_frame(self, frame: Any) -> list[Event]:
349349
error_message=envelope.body[:2000],
350350
error_kind=ErrKind.QUEUE,
351351
retry_after_seconds=queue_wait)]
352-
kind = qoder_events.classify_error_code(envelope.status, envelope.body)
352+
# 信封错误与 HTTP 错误必须同一判据:早先这里直接调
353+
# `classify_error_code`,403 不看会话失效标记就判 DEAD——「每日计费
354+
# 次数用尽」这类次日自愈的号被硬禁用(实测 2026-10-27 15:39)。
355+
kind = qoder_events.classify_status(envelope.status, envelope.body.encode())
353356
# 200 字符只够看到外层 `"details":"{\"error\":{\"message\":...`——上游把
354357
# 真实原因嵌在转义的 details 里,截断后根因(如缺哪个 tool_call_id)
355358
# 永久丢失,排障时等于没打。放宽到 2000。

‎src/provider/qoder/events.py‎

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@
1515
1616
错误分类沿用本项目中立层(`provider.base.ErrKind`)语义;Qoder 没有观测到
1717
CB/TRAE 那套 `1005/6004/11102` 业务码,信封里的 `statusCodeValue` 本身就是
18-
HTTP 状态语义,故按状态码分类。唯一例外:上游会把**自身节点执行故障**也包成
19-
400(`[FAIL]node:… msg:Execution failed`),这类按 `NODE_FAILURE_MARKERS` 识别后
20-
归模型级瞬时故障(`ErrKind.MODEL`),见 `classify_error_code`。
18+
HTTP 状态语义,故按状态码分类——**HTTP 非 2xx 与流内信封共用 `classify_status`
19+
一套判据**(排队 / 每日计费耗尽 / 会话失效 / 其余 SOFT 的优先级见该函数)。
20+
两个例外:上游会把**自身节点执行故障**包成 400(`[FAIL]node:… msg:Execution failed`),
21+
这类按 `NODE_FAILURE_MARKERS` 识别后归模型级瞬时故障(`ErrKind.MODEL`);
22+
「每日计费次数用尽」(403 `{"code":"110","message":"Billing daily count exceeded"}`)
23+
按 `DAILY_QUOTA_MARKERS` 归 `ErrKind.CREDIT`,等次日恢复而不是硬禁用。
2124
"""
2225

2326
from __future__ import annotations
@@ -150,6 +153,12 @@ class RealmConfig:
150153
# 完全不同:换凭证不解决问题但会自愈,必须归为模型级瞬时冷却而非 INVALID。
151154
NODE_FAILURE_MARKERS = ("[FAIL]node:", "Execution failed")
152155

156+
# 上游「每日计费次数用尽」(实测 2026-10-27 15:39,流内信封 403
157+
# `{"code":"110","message":"Billing daily count exceeded"}`):号本身没坏,
158+
# 额度按上游日边界恢复——重登无效,硬禁用会把一个明天就能用的号踢出池子。
159+
# 归 CREDIT(冷却到次日 04:00),不进 DEAD。
160+
DAILY_QUOTA_MARKERS = ("Billing daily count exceeded",)
161+
153162
# 信封内层 delta 的空占位键(只在取值为空/假时才剔除,非空值一律保留)。
154163
NOISE_KEYS = ("extra_fields", "refusal", "reasoning_content")
155164

@@ -185,15 +194,23 @@ def is_session_dead(text: str) -> bool:
185194
return any(marker in text for marker in SESSION_DEAD_MARKERS)
186195

187196

197+
def is_daily_quota_exceeded(text: str) -> bool:
198+
"""响应体是否为「每日计费次数用尽」(次日自动恢复,与凭证状态无关)。"""
199+
return any(marker in text for marker in DAILY_QUOTA_MARKERS)
200+
201+
188202
def is_node_failure(text: str) -> bool:
189203
"""响应体是否为「上游节点故障」包装的 400(见 `NODE_FAILURE_MARKERS`)。"""
190204
return any(marker in text for marker in NODE_FAILURE_MARKERS)
191205

192206

193207
def classify_error_code(code: int | None, body: bytes | str = b"") -> ErrKind:
194-
"""状态/信封码 → ErrKind(401/403 的会话失效细节由 `classify_status` 补全)。
208+
"""状态/信封码 → ErrKind(HTTP 与流内信封共用,判据必须一视同仁)。
195209
196210
* 402 → 余额不足(等签到恢复)
211+
* 报文命中 `DAILY_QUOTA_MARKERS` → CREDIT:「每日计费次数用尽」
212+
(403 `{"code":"110","message":"Billing daily count exceeded"}`),
213+
号没坏、次日自动恢复,重登无效也不能硬禁用
197214
* 429 → 模型级限流(上游频控点名具体模型,换模型立即可用)
198215
* 418/500/502/503/504 → SOFT:上游把自身故障包装成 418/5xx,属瞬时类,
199216
短冷却换号即可,不该累计成「连续 3 次 → 10m」
@@ -206,39 +223,44 @@ def classify_error_code(code: int | None, body: bytes | str = b"") -> ErrKind:
206223
只是忙——判成 DEAD 会把可自愈的凭证打进冷宫(实测 2026-10-26:
207224
kmodel_latest 排队时回 403 + `isQueued`,凭证被误判死)。排队判据
208225
放在这里(而不是只在调用点)才能保证**任何**入口都不会漏判。
226+
* 401/403 且带会话失效标记 → DEAD(硬禁用,需重新登录)
227+
* 401/403 其余情况 → SOFT:实测 Qoder 的 401/403 多数是上游瞬时/风控
228+
抖动,直接硬禁用会把可自愈的凭证打进冷宫。**流内信封也走这里**
229+
(`client._decode_frame` 调 `classify_status`),两条路径必须同一判据。
209230
"""
231+
text = body.decode("utf-8", errors="replace") if isinstance(body, bytes) else body
210232
if code == 402:
211233
return ErrKind.CREDIT
234+
if is_daily_quota_exceeded(text):
235+
return ErrKind.CREDIT
212236
if code in (401, 403):
213-
text = body.decode("utf-8", errors="replace") if isinstance(body, bytes) else body
214237
if parse_queue_hint(text) is not None:
215238
return ErrKind.QUEUE
216-
return ErrKind.DEAD
239+
return ErrKind.DEAD if is_session_dead(text) else ErrKind.SOFT
217240
if code == 429:
218241
return ErrKind.MODEL
219242
if code in (418, 500, 502, 503, 504):
220243
return ErrKind.SOFT
221244
if code in (400, 404, 422):
222-
text = body.decode("utf-8", errors="replace") if isinstance(body, bytes) else body
223245
return ErrKind.MODEL if is_node_failure(text) else ErrKind.INVALID
224246
return ErrKind.OTHER
225247

226248

227249
def classify_status(status: int, body: bytes = b"") -> ErrKind:
228-
"""HTTP 状态码分类。
250+
"""HTTP 状态码分类(流内信封错误同样走这里)。
229251
230252
401/403 只有**明确带会话失效标记**时才归 DEAD(硬禁用);否则按 SOFT
231253
短冷却轮换——实测 Qoder 的 401/403 多数是上游瞬时/风控抖动,直接硬禁用
232254
会把可自愈的凭证打进冷宫。
233255
234256
排队信号(`isQueued` / `RESOURCE_LIMIT`)优先于状态码:上游把「排队中」
235257
也包成 403,按状态码分类会硬禁用凭证——排队恰恰说明号是好的,只是忙。
258+
「每日计费次数用尽」同理,优先于 401/403 的通用分支(见
259+
`classify_error_code`)。
236260
"""
237261
text = body.decode("utf-8", errors="replace") if isinstance(body, bytes) else body
238262
if parse_queue_hint(text) is not None:
239263
return ErrKind.QUEUE
240-
if status in (401, 403) and not is_session_dead(text):
241-
return ErrKind.SOFT
242264
return classify_error_code(status, body)
243265

244266

‎tests/provider/qoder/test_client.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,49 @@ def test_classify_status_queue_hint_wins_over_403():
631631
assert qoder_events.classify_status(403, b"") is ErrKind.SOFT
632632

633633

634+
def test_inner_403_without_session_marker_is_soft_not_dead():
635+
"""流内 403 未带会话失效标记 → SOFT,和 HTTP 层同一判据。
636+
637+
早先 `_decode_frame` 直接调 `classify_error_code`,403 不看标记就判 DEAD,
638+
把一个还能用的号永久禁用(2026-10-27 实测)。
639+
"""
640+
body = '{"code":"403","message":"upstream risk control"}'
641+
assert qoder_events.classify_error_code(403, body) is ErrKind.SOFT
642+
assert qoder_events.classify_status(403, body.encode()) is ErrKind.SOFT
643+
# 带会话失效标记才是真的 DEAD
644+
dead = '{"code":"403","message":"Offline user session not found"}'
645+
assert qoder_events.classify_error_code(403, dead) is ErrKind.DEAD
646+
assert qoder_events.classify_status(403, dead.encode()) is ErrKind.DEAD
647+
648+
649+
def test_daily_quota_exceeded_is_credit_not_dead():
650+
"""「每日计费次数用尽」→ CREDIT(冷却到次日),不是 session 死亡。
651+
652+
实测 2026-10-27:403 `{"code":"110","message":"Billing daily count exceeded"}`
653+
被流内分类判成 DEAD,qoder 号 nick5080166916 被硬禁用后需人工 revive。
654+
"""
655+
body = '{"code":"110","message":"Billing daily count exceeded"}'
656+
assert qoder_events.is_daily_quota_exceeded(body) is True
657+
assert qoder_events.is_daily_quota_exceeded("ordinary error") is False
658+
assert qoder_events.classify_error_code(403, body) is ErrKind.CREDIT
659+
assert qoder_events.classify_error_code(403, body.encode()) is ErrKind.CREDIT
660+
assert qoder_events.classify_status(403, body.encode()) is ErrKind.CREDIT
661+
662+
663+
async def test_stream_chat_daily_quota_envelope_yields_credit():
664+
"""流内 403 每日计费耗尽 → CREDIT 事件(不硬禁用凭证)。"""
665+
body = json.dumps({"code": "110", "message": "Billing daily count exceeded"})
666+
667+
def handler(_request: httpx.Request) -> httpx.Response:
668+
return httpx.Response(200, text=envelope(body, status=403))
669+
670+
events = [e async for e in handler_for(handler).stream_chat(cred(), {}, "m")]
671+
assert len(events) == 1
672+
assert events[0].kind is EventKind.ERROR
673+
assert events[0].error_kind is ErrKind.CREDIT
674+
assert events[0].error_code == 403
675+
676+
634677
def test_to_status_variants():
635678
assert qoder_events._to_status(None) == 200
636679
assert qoder_events._to_status(True) == 502

0 commit comments

Comments
 (0)