OmniRoute 网关修复解析:Claude Code `/model` 探测(`max_tokens: 1`)为何不再被误判为虚假 502
本文围绕 OmniRoute 仓库新增的一条修复记录 claude-code-model-probe-max-tokens.md 展开,剖析非流式"空 HTTP 200 响应"诊断器 detectMalformedNonStream 的误判根因、修复思路与回归保护。读完你可以理解:为什么 Claude Code 在 /model 模型切换探测中发送的 max_tokens: 1 请求会命中空响应保护,网关如何在 content: [] 与 stop_reason: max_tokens / tool_use 同时出现时正确放行一个真实的 200 响应,以及此类"智能 502"修复在网关可用性工程中的典型模式。
一、变更速览:它修了什么
该修复片段完整内容如下:
fix(sse): accept Claude
content:[]+stop_reason: max_tokens/tool_useindetectMalformedNonStream(matchisEmptyContentResponse) so Claude Code/modelprobes withmax_tokens: 1no longer become a false 502
翻译过来即:在 detectMalformedNonStream(位于 open-sse/utils/diagnostics.ts)中接受 Claude 形态的 content: [] + 终止原因(stop_reason)为 max_tokens 或 tool_use 的组合,使该函数与既有的 isEmptyContentResponse 判空口径保持一致,从而让 Claude Code 发起的 max_tokens: 1 模型探测不再变成一次虚假的 502 错误。
这条记录本身属于 sse(服务端流式/网关协议层) 的错误修复范畴,它所指向的问题横跨三个关键源码模块:
- 空响应诊断器 diagnostics.ts(
detectMalformedNonStream); - 空响应/错误分类器 errorClassifier.ts(
isEmptyContentResponse); - 非流式请求核心执行链 chatCore.ts(两处判空调用点)。
二、背景机制:什么是 malformed-200 与 empty_choices 502
在 OmniRoute 这类多供应商网关中,上游供应商"返回了 HTTP 200、但响应体实际上没有任何可用输出"是一种远比显式错误码更隐蔽的故障模式——例如配额耗尽、上游过载、或中间的代理/网关悄悄截断了响应。若把这种"假成功"直接透传给客户端,客户端会把它当成一次正常且完整的完成,造成静默失败。
diagnostics.ts 的模块注释给出了设计意图:
将"HTTP-200-但是空"的上游响应(空的 SSE 流、空的翻译后响应体)暴露为结构化、已净化的错误,而不是静默地把它当作
output: []/choices: []的成功。
detectMalformedNonStream(resp) 正是这个保护层的核心判定函数:它对已经过响应翻译的非流式响应体做检查,返回 MalformedReason(如 "empty_choices"、"no_terminal"),当返回值为空(null)时表示该响应携带可用输出,属合法响应;否则由调用方上报并改写为 502。
在非流式执行链 chatCore.ts 中可以看到该函数的调用位置与后续处理:
// Validate the *translated* response actually carries client-usable output.
// isEmptyContentResponse (above) runs on the raw responseBody before translation;
// this check runs after translation + sanitization + tool-call execution to catch
// cases where a provider returns a structurally valid raw body that translates into
// choices:[] or output:[] with no usable content (Responses API shape included).
const malformedTranslatedReason = detectMalformedNonStream(translatedResponse);
if (malformedTranslatedReason) {
// ... reportMalformed200(...) 输出 [MALFORMED-200] 结构化日志
const malformed = describeMalformedNonStream(translatedResponse, malformedTranslatedReason);
// ... 构造 HTTP 502 + error.code = "upstream_empty_response" 返回给客户端
}
即:detectMalformedNonStream 一旦判定失败,客户端将收到 502,其 error.type 为 upstream_response_error、error.code 为 upstream_empty_response(见 describeMalformedNonStream)。同时调用 reportMalformed200 在标准输出打点一行结构化日志([MALFORMED-200] mode=... reason=... recvBytes=...),用于日志关联与排障。
三、故障场景:Claude Code 的 /model 模型探测请求
Claude Code 在用户切换模型时,会先向目标模型发送一个极廉价的"探测"请求以确认该模型可用。这个探测请求以 max_tokens: 1 的方式把生成预算压到最低(测试注释里描述为 "Hi + max_tokens: 1")。
问题在于:当目标模型是带有思考(extended thinking)能力的模型时,情况会变得微妙。测试 diagnostics.test.ts 的注释给出了仓库侧的权威解释:
test("detectMalformedNonStream returns null for Claude content:[] with stop_reason max_tokens (Claude Code /model probe)", () => {
// Claude Code probes model switches with Hi + max_tokens:1. Opus can burn the
// token on thinking and return an empty content array with max_tokens — valid
// upstream 200, must not become empty_choices / 502.
const body = {
type: "message",
role: "assistant",
content: [],
stop_reason: "max_tokens",
usage: { input_tokens: 33, output_tokens: 1 },
};
assert.equal(detectMalformedNonStream(body), null);
});
这里的实际情况是:模型(如 Opus)在仅有 1 个输出 token 的预算下,把预算消耗在了思考过程上,于是上游返回的响应体是——
- HTTP 状态码:
200(合法成功); content: [](没有任何可见文本块);stop_reason: "max_tokens"(因触及 token 上限而终止)。
对上游而言,这是一次真实、合法、完整的终止,并非错误。但在修复前,detectMalformedNonStream 的 Claude 分支对 content: [] 一律判定为 empty_choices,导致网关把一个真实 200 改写成 MALFORMED-200 → 502。Claude Code 每次切换模型都会因此看到一次连接失败,从而影响 /model 探测体验。
四、根因分析:两个判空器口径不一致
深入源码可以发现,OmniRoute 的非流式链路里其实有两道独立的空响应检查,而它们对"空 content + 特定 stop_reason"的判定口径此前并不一致:
- 第一道:翻译前,作用于原始上游响应体 ——
isEmptyContentResponse(errorClassifier.ts),调用点见 chatCore.ts。它对 Claude 形态的判空逻辑是:
if (Array.isArray(body.content)) {
if (body.content.length > 0) return false;
// Empty content array: a response truncated at max_tokens (or one that stopped
// to emit a tool_use block) is a legitimate terminal state, not a silent
// failure. Only flag empty content when no such terminal stop_reason is present.
const stopReason = typeof body.stop_reason === "string" ? body.stop_reason : "";
return !LEGIT_EMPTY_CLAUDE_STOP.has(stopReason);
}
其中白名单定义在文件顶部:
// Terminal stop signals where an empty content payload is still a legitimate,
// successful completion (truncated at the token limit, or a tool-call turn) —
// NOT a silent "fake success" failure. Used to avoid rewriting a valid HTTP 200
// (e.g. a Claude Code `max_tokens: 1` connectivity ping) into a synthetic 502.
const LEGIT_EMPTY_CLAUDE_STOP = new Set(["max_tokens", "tool_use"]);
const LEGIT_EMPTY_OPENAI_FINISH = new Set(["length", "tool_calls", "content_filter"]);
也就是说,第一道检查早已把"空 content + max_tokens/tool_use"视为合法的终止态放行。
- 第二道:翻译后,作用于发给客户端的翻译响应体 ——
detectMalformedNonStream。由于 OmniRoute 会在"Claude 客户端 → Claude 上游"时保持响应为 Claude Messages 形态(不做翻译),两道检查看到的是同一形态的响应体,但detectMalformedNonStream的旧逻辑对content: []不区分stop_reason,一律判empty_choices。两道判空器对同一响应给出相反结论,正是虚假 502 的根因。
从 diagnostics.ts 的注释可见修复前的完整推理链:一个 content: [](完全没有块)在旧的 Claude Code OAuth 上游还可能因为长输入长输出在约 3 分钟轮次边界被截断而出现"非终止响应",那种情况不应判空;而真正"终止但空"的响应通常又确实是空错误——唯二合法的例外就是 isEmptyContentResponse 已经接受的 max_tokens 与 tool_use 两种终止。修复的核心,正是把这两个合法例外搬进 detectMalformedNonStream。
五、修复实现:让两道判空器对齐
修复后,detectMalformedNonStream 的 Claude Messages 分支在 open-sse/utils/diagnostics.ts 中按"逐块判空 + 兜底判定"两阶段工作:
阶段一:逐个 content block 判定是否构成有效输出。 只要出现以下任一情形即视为有效(直接返回 null):
text块携带非空文本,且不等于占位符"(empty response)"(后者是 OpenAI 形态翻译到 Claude 形态时,对空内容的占位符,必须继续视为空);thinking块(扩展思考块)——注释明确说明 #9971 场景下,上游在约 3 分钟轮次边界截断可能导致 thinking-only 响应体连signature都没有,块的存在本身就是上游产生过输出的证据;redacted_thinking块;- 携带非空
id的tool_use块; - 对
null/ 非对象块先做类型防护,避免null.type抛异常。
阶段二:所有块都被判空后,对 content: [] 依据 stop_reason 兜底判定:
if (content.length === 0) {
const stopReason = typeof body.stop_reason === "string" ? body.stop_reason : "";
if (stopReason.length === 0) return null; // #9971 非终止/截断响应,不是空错误
if (stopReason === "max_tokens" || stopReason === "tool_use") return null; // 与 errorClassifier 对齐
return "empty_choices";
}
return "empty_choices";
这个分支与 isEmptyContentResponse 的 LEGIT_EMPTY_CLAUDE_STOP = new Set(["max_tokens", "tool_use"]) 形成一一对应:凡第一道检查放行的合法空终止,第二道检查现在也放行。同时,end_turn 之类的"正常结束但真的什么都没输出"仍被严格判为 empty_choices,不会放松对真实空响应的拦截。
六、修复后的判定矩阵
把本次修复涉及的 Claude Messages 形态响应整理成矩阵,便于读者快速对照(对应源码 diagnostics.ts 与 errorClassifier.ts):
| content 内容 | stop_reason | 判定结果 | 说明 |
|---|---|---|---|
[{type:"text",text:"Hi!"}] |
end_turn |
合法(null) | 普通文本完成 |
[{type:"tool_use",id:"toolu_1",...}] |
tool_use |
合法(null) | 工具调用轮次 |
[{type:"thinking",thinking:"…"}] 或空 thinking 块 |
任意 | 合法(null) | 思考块本身即有效结构输出(含 #5108 带 signature 场景) |
[] |
max_tokens |
合法(null) | 本次修复:Claude Code /model 探测、max_tokens:1 被思考耗尽 |
[] |
tool_use |
合法(null) | 本次修复:与 isEmptyContentResponse 白名单对齐 |
[] |
(无 stop_reason) |
合法(null) | #9971:非终止/被截断响应,不应误伤 |
[] |
end_turn 等其余值 |
empty_choices → 502 |
真·正常结束但零输出,仍需拦截 |
[{type:"text",text:""}] 或 [{"(empty response)"}] |
end_turn |
empty_choices → 502 |
空文本块与翻译占位符仍判空(保持与 OpenAI content:"" 路径对等) |
可以看到,修复的边界非常克制:只放行了"确实没有输出、但属于合法终止信号"的两类情形,其余空响应依旧会被改写为带 upstream_empty_response 错误码的结构化 502。
七、回归保护与测试佐证
本次修复并非孤立改动,OmniRoute 用一组单元测试把边界情形固化了下来,集中在 tests/unit/diagnostics.test.ts 的 "Claude Messages shape" 分组里,除前述 /model 探测用例外,还包括:
content: []且无stop_reason(#9971 非终止截断)→null(L310-L313);content: []且stop_reason: "tool_use"→null,注释明确写有 "parity with errorClassifier"(L315-L318);content: []且stop_reason: "end_turn"→ 仍为empty_choices(L291-L294);- 空
text块与"(empty response)"占位符 → 仍为empty_choices(L320-L340)。
此外,仓库 CHANGELOG.md 与该修复同脉络的历史演进也佐证了这条判空逻辑一直在"收紧-打补丁"中迭代:从 v3.8.37 引入 malformed-200 检测器(仅识别 OpenAI choices 与 Responses API output 形态)→ 补上 Claude Messages 形态与带 signature 的 thinking 块(对应源码注释中的 #5108)→ 处理截断/非终止空体(#9971)→ 本次再对齐 max_tokens/tool_use 两类合法空终止。修复相关源码文件同目录下还有整套 synthOpenAIErrorChunk / reportMalformed200 / describeMalformedNonStream 配套测试,覆盖错误信息不泄漏堆栈路径(Hard Rule #12 净化规则)等约束。
八、如何验证与升级
对该仓库的使用者而言,验证方式有三层:
- 阅读层面:直接查看本次修复的差异所在,即 diagnostics.ts 中
content.length === 0的兜底分支,并与 errorClassifier.ts 的LEGIT_EMPTY_CLAUDE_STOP白名单互相对照,两者现在完全同源。 - 测试层面:仓库使用 Vitest,可针对诊断模块运行相关测试文件(如
tests/unit/diagnostics.test.ts),确认上述判定矩阵中的用例全部通过;若在自定义场景中发现新的空响应误判,建议优先以"补充detectMalformedNonStream判定用例"的方式回归。 - 运行层面:通过 Claude Code 接入 OmniRoute 网关、在启用扩展思考的模型间切换,观察
/model探测请求是否稳定返回 200;同时可在网关日志中 grep[MALFORMED-200]打点,确认reason=empty_choices不再因max_tokens:1探测而出现。
九、小结:如何优雅地"不过度拦截"
这条修复的工程价值在于一个很容易被忽视的细节:网关对"空响应"的保护越激进,就越容易把上游真实业务语义误杀。 content: [] 究竟代表"上游坏了"还是"一次合法的零可见输出终止",必须结合协议的终止信号(stop_reason / finish_reason)来综合判断,而不是看到空数组就一刀切。OmniRoute 的解法——让翻译后诊断器与翻译前错误分类器共享同一份"合法空终止"白名单(max_tokens / tool_use,OpenAI 侧对应 length / tool_calls / content_filter)——为所有做协议网关与自动回退路由的项目提供了一个可复用的判空范式:先理解协议里"真正的失败"与"合法的空"之间的边界,再用测试把边界固化,避免回退风暴与账户冷却误伤。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00