mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 02:42:24 +03:00
fix(sse): do not 502 Claude Code /model probes (content:[] + max_tokens) (#11568)
Merged via /merge-batch (lote 2026-08-26, v3.8.51). Boarded no worktree combinado junto com outras ~30 PRs; validação única: typecheck/complexity/cognitive-complexity/changelog-integrity verdes, file-size rebaseado onde necessário (crescimento legítimo), lint com os mesmos 228 achados pré-existentes confirmados via sonda contra o tip puro (não introduzidos por este lote), e ~370 testes focados (unit + vitest) passando. Obrigado pela contribuição.
This commit is contained in:
1
changelog.d/fixes/claude-code-model-probe-max-tokens.md
Normal file
1
changelog.d/fixes/claude-code-model-probe-max-tokens.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(sse): accept Claude `content:[]` + `stop_reason: max_tokens`/`tool_use` in `detectMalformedNonStream` (match `isEmptyContentResponse`) so Claude Code `/model` probes with `max_tokens: 1` no longer become a false 502
|
||||
@@ -256,18 +256,20 @@ export function detectMalformedNonStream(resp: unknown): MalformedReason | null
|
||||
// sentinel, or only null entries) — the model genuinely produced no
|
||||
// usable output. That is a MALFORMED-200 empty_choices regardless of
|
||||
// stop_reason (parity with the OpenAI content:"" path).
|
||||
// 2) `content: []` — no block at all. Only a genuinely *terminal* response
|
||||
// (a final stop_reason with no output) is empty_choices. #9971: a
|
||||
// truncated / non-terminal body — the Claude Code OAuth upstream cutting
|
||||
// a long generation mid-turn, or a content-less thinking-only stream
|
||||
// that never emitted a terminal event — carries content:[] with no
|
||||
// reachable end, so flagging it would turn an upstream truncation into a
|
||||
// false 502. Require a terminal stop_reason before calling a block-less
|
||||
// response genuinely empty.
|
||||
// 2) `content: []` — no block at all. #9971: a truncated / non-terminal
|
||||
// body (no stop_reason) must not become empty_choices. A terminal
|
||||
// stop_reason with no output usually is empty_choices — except the
|
||||
// same legitimate empty stops that `isEmptyContentResponse` already
|
||||
// accepts (`max_tokens`, `tool_use`). Claude Code's `/model` probe
|
||||
// sends `max_tokens: 1`; Opus can burn that budget on thinking and
|
||||
// return content:[] + stop_reason max_tokens. Treating that as
|
||||
// empty_choices turns a valid 200 into MALFORMED-200 → 502 even
|
||||
// though errorClassifier would have let it through.
|
||||
if (content.length === 0) {
|
||||
const stopReason = typeof body.stop_reason === "string" ? body.stop_reason : "";
|
||||
const isTerminal = stopReason.length > 0;
|
||||
return isTerminal ? "empty_choices" : null;
|
||||
if (stopReason.length === 0) return null;
|
||||
if (stopReason === "max_tokens" || stopReason === "tool_use") return null;
|
||||
return "empty_choices";
|
||||
}
|
||||
return "empty_choices";
|
||||
}
|
||||
|
||||
@@ -51,6 +51,14 @@ test("#5108 genuinely empty Claude content:[] is still flagged malformed", () =>
|
||||
assert.equal(detectMalformedNonStream(claudeMsg([])), "empty_choices");
|
||||
});
|
||||
|
||||
test("Claude Code /model probe: content:[] + max_tokens is not empty_choices", () => {
|
||||
const body = {
|
||||
...claudeMsg([]),
|
||||
stop_reason: "max_tokens",
|
||||
};
|
||||
assert.equal(detectMalformedNonStream(body), null);
|
||||
});
|
||||
|
||||
test("#5108/#9971 Claude thinking block with neither text nor signature is valid output (was empty_choices)", () => {
|
||||
const body = claudeMsg([{ type: "thinking", thinking: "", signature: "" }]);
|
||||
// #9971: an empty thinking block is valid structural output — the upstream can
|
||||
|
||||
@@ -293,6 +293,30 @@ test("detectMalformedNonStream returns 'empty_choices' for Claude message with e
|
||||
assert.equal(detectMalformedNonStream(body), "empty_choices");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test("detectMalformedNonStream returns null for Claude content:[] with no stop_reason (#9971 non-terminal)", () => {
|
||||
const body = { type: "message", role: "assistant", content: [] };
|
||||
assert.equal(detectMalformedNonStream(body), null);
|
||||
});
|
||||
|
||||
test("detectMalformedNonStream returns null for Claude content:[] with stop_reason tool_use (parity with errorClassifier)", () => {
|
||||
const body = { type: "message", role: "assistant", content: [], stop_reason: "tool_use" };
|
||||
assert.equal(detectMalformedNonStream(body), null);
|
||||
});
|
||||
|
||||
test("detectMalformedNonStream returns 'empty_choices' for Claude message with empty-text block", () => {
|
||||
const body = {
|
||||
type: "message",
|
||||
|
||||
Reference in New Issue
Block a user