diff --git a/changelog.d/fixes/claude-code-model-probe-max-tokens.md b/changelog.d/fixes/claude-code-model-probe-max-tokens.md new file mode 100644 index 0000000000..5446e49454 --- /dev/null +++ b/changelog.d/fixes/claude-code-model-probe-max-tokens.md @@ -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 diff --git a/open-sse/utils/diagnostics.ts b/open-sse/utils/diagnostics.ts index 97c6dc2f10..06f7a0a0b6 100644 --- a/open-sse/utils/diagnostics.ts +++ b/open-sse/utils/diagnostics.ts @@ -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"; } diff --git a/tests/unit/diagnostics-claude-thinking-5108.test.ts b/tests/unit/diagnostics-claude-thinking-5108.test.ts index 4ef9caf3f6..efbb82da9e 100644 --- a/tests/unit/diagnostics-claude-thinking-5108.test.ts +++ b/tests/unit/diagnostics-claude-thinking-5108.test.ts @@ -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 diff --git a/tests/unit/diagnostics.test.ts b/tests/unit/diagnostics.test.ts index 93a85380b5..dbcb643480 100644 --- a/tests/unit/diagnostics.test.ts +++ b/tests/unit/diagnostics.test.ts @@ -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",