diff --git a/changelog.d/fixes/9971-empty-choices-vps.md b/changelog.d/fixes/9971-empty-choices-vps.md new file mode 100644 index 0000000000..9e09aa3c9b --- /dev/null +++ b/changelog.d/fixes/9971-empty-choices-vps.md @@ -0,0 +1 @@ +- fix(chat): don't misclassify content-less thinking/redacted Claude bodies as empty_choices (#9971) \ No newline at end of file diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index 165e292dc0..3527cdfec5 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -523,6 +523,19 @@ export function translateNonStreamingResponse( } } + // #9971: a content-less-but-valid Claude body (thinking / redacted_thinking + // / tool_use-only, or a truncated extended-thinking-only stream) has blocks + // but no final text. Surfacing it here helps correlate a live VPS capture + // with detectMalformedNonStream's clause; the content itself is valid output + // (see detectMalformedNonStream), so this is observation, not a decision. + if (textContent.length === 0 && process.env.DEBUG_CLAUDE_NONSTREAM === "true") { + console.log( + `[ClaudeNonStream] ${contentBlocks.length} content block(s), empty textContent ` + + `(thinking=${thinkingContent.length}, toolCalls=${toolCalls.length}); ` + + `content-less-but-valid body preserved (not empty_choices)` + ); + } + const message: JsonRecord = { role: "assistant" }; if (textContent) { message.content = textContent; diff --git a/open-sse/utils/diagnostics.ts b/open-sse/utils/diagnostics.ts index 4402656281..d872599a98 100644 --- a/open-sse/utils/diagnostics.ts +++ b/open-sse/utils/diagnostics.ts @@ -211,7 +211,8 @@ export function detectMalformedNonStream(resp: unknown): MalformedReason | null // `choices`. Without this branch every non-streaming Claude response (incl. plain text) // falls through to `empty_choices` → a false 502 (#5108, regression from #4942). if (body.type === "message" && Array.isArray(body.content)) { - const hasOutput = (body.content as unknown[]).some((block) => { + const content = body.content as unknown[]; + const hasOutput = content.some((block) => { // A malformed/partial provider response could carry a null (or non-object) // entry in `content`; guard before type-asserting so the detector never // throws on `null.type` (that would crash the whole non-stream classifier). @@ -229,16 +230,18 @@ export function detectMalformedNonStream(resp: unknown): MalformedReason | null ) { return true; } - // Extended-thinking block: valid when it carries visible thinking text OR a - // non-empty `signature` (cryptographic proof the thinking step ran, so it is a - // valid completion even when the thinking text is ""). - if ( - b.type === "thinking" && - ((typeof b.thinking === "string" && (b.thinking as string).length > 0) || - (typeof b.signature === "string" && (b.signature as string).length > 0)) - ) { - return true; - } + // Extended-thinking block: valid structural output whenever the model + // entered the thinking phase, even with no visible thinking text and no + // `signature`. #9971: the Claude Code OAuth upstream can truncate long + // large-input+large-output generations around the ~3-min turn boundary, + // leaving a content-less thinking-only body whose final text (and, when + // cut mid-think, its signature) never arrived. The block's very presence + // is proof the turn produced output upstream, so it is a valid + // in-progress completion, NOT a genuinely empty terminal response. + // (Previously only a non-empty `thinking` text OR `signature` counted — + // #5108 — which misclassified these content-less bodies as empty_choices + // → 502.) + if (b.type === "thinking") return true; // Redacted thinking and tool_use are valid structural output. if (b.type === "redacted_thinking") return true; if (b.type === "tool_use" && typeof b.id === "string" && (b.id as string).length > 0) { @@ -246,7 +249,27 @@ export function detectMalformedNonStream(resp: unknown): MalformedReason | null } return false; }); - return hasOutput ? null : "empty_choices"; + if (hasOutput) return null; + + // No per-block output. Two distinct situations remain: + // 1) A block IS present but invalid (e.g. text:"", a lone "(empty response)" + // 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. + if (content.length === 0) { + const stopReason = typeof body.stop_reason === "string" ? body.stop_reason : ""; + const isTerminal = stopReason.length > 0; + return isTerminal ? "empty_choices" : null; + } + return "empty_choices"; } // ── Chat Completions shape ── diff --git a/tests/unit/diagnostics-claude-thinking-5108.test.ts b/tests/unit/diagnostics-claude-thinking-5108.test.ts index 0cfae4fced..4ef9caf3f6 100644 --- a/tests/unit/diagnostics-claude-thinking-5108.test.ts +++ b/tests/unit/diagnostics-claude-thinking-5108.test.ts @@ -12,8 +12,11 @@ * the thinking step actually ran, so this is a valid completion, not an empty one. * * The detector must understand the Claude shape: text blocks with text, thinking blocks - * with a signature, and tool_use blocks count as output; a genuinely empty `content:[]` - * (or thinking with neither text nor signature) is still flagged. + * (with or without a signature), redacted_thinking, and tool_use blocks count as output; + * a genuinely empty terminal `content:[]` (or a terminal `(empty response)` text sentinel) + * is still flagged. #9971 refined #5108's rule: an empty thinking block is also valid + * structural output (the upstream can truncate a thinking-only generation before any + * text/signature lands), so it is no longer flagged — only content-less *terminal* bodies are. */ import test from "node:test"; import assert from "node:assert/strict"; @@ -48,9 +51,12 @@ test("#5108 genuinely empty Claude content:[] is still flagged malformed", () => assert.equal(detectMalformedNonStream(claudeMsg([])), "empty_choices"); }); -test("#5108 Claude thinking block with neither text nor signature is still flagged", () => { +test("#5108/#9971 Claude thinking block with neither text nor signature is valid output (was empty_choices)", () => { const body = claudeMsg([{ type: "thinking", thinking: "", signature: "" }]); - assert.equal(detectMalformedNonStream(body), "empty_choices"); + // #9971: an empty thinking block is valid structural output — the upstream can + // truncate a thinking-only generation before any text/signature lands, and a + // content-less thinking-only body must not turn into an empty_choices 502. + assert.equal(detectMalformedNonStream(body), null); }); // Existing OpenAI / Responses behavior must be unchanged. diff --git a/tests/unit/issue-9971-empty-choices-contentless-claude.test.ts b/tests/unit/issue-9971-empty-choices-contentless-claude.test.ts new file mode 100644 index 0000000000..47f7bcd05e --- /dev/null +++ b/tests/unit/issue-9971-empty-choices-contentless-claude.test.ts @@ -0,0 +1,84 @@ +/** + * #9971 — Non-stream MALFORMED-200/empty_choices false positive on `cc/` routes. + * + * A content-less-but-valid Claude body — thinking-only (with no visible text AND + * no signature), redacted_thinking-only, or a truncated extended-thinking-only + * stream cut before any text/signature landed — must be treated as VALID output, + * not flagged as `empty_choices` (which became a false 502 / BAD_GATEWAY). + * + * Root cause (plan-file): the Claude Code OAuth subscription upstream can truncate + * long large-input+large-output generations around the ~3-min turn boundary; the + * non-stream path's `detectMalformedNonStream` then misclassified the resulting + * content-less/thinking-only Claude body as `empty_choices`. The guard may only + * fire for a genuinely malformed upstream response — a non-200 or a truly empty + * *terminal* completion (terminal stop_reason with no usable output). + * + * Live note: the exact large-recvBytes+empty signature (33–65KB) needs a live VPS + * capture to confirm the upstream truncation; this test encodes the + * offline-reproducible mechanism (content-less thinking/redacted bodies), which + * failed to `empty_choices` on the unfixed code and must pass after the fix. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { detectMalformedNonStream } from "../../open-sse/utils/diagnostics.ts"; + +const claudeMsg = (content: unknown[], stopReason = "end_turn") => ({ + type: "message", + role: "assistant", + id: "msg_x", + model: "claude-sonnet-4-5", + content, + stop_reason: stopReason, + usage: { input_tokens: 30000, output_tokens: 120 }, +}); + +// ── Content-less-but-valid Claude bodies must NOT be empty_choices ─────────── + +test("#9971 content-less thinking-only body (no text, no signature) is valid output", () => { + // Truncated extended-thinking stream: a thinking block arrived but the model + // never emitted its final text (and was cut before producing a signature). + const body = claudeMsg([{ type: "thinking", thinking: "", signature: "" }], ""); + assert.equal(detectMalformedNonStream(body), null); +}); + +test("#9971 redacted_thinking-only body is valid output", () => { + // OAuth-style redacted footprint: the control plane suppresses the raw thinking + // text, leaving only a redacted_thinking marker — still a valid completion. + const body = claudeMsg([{ type: "redacted_thinking", data: "" }]); + assert.equal(detectMalformedNonStream(body), null); +}); + +test("#9971 thinking-only body with visible thinking text is valid output", () => { + const body = claudeMsg([ + { type: "thinking", thinking: "working through the request", signature: "" }, + ]); + assert.equal(detectMalformedNonStream(body), null); +}); + +test("#9971 functional/structural tool_use body is valid output", () => { + const body = claudeMsg([ + { type: "tool_use", id: "toolu_1", name: "bash", input: { command: "ls" } }, + ]); + assert.equal(detectMalformedNonStream(body), null); +}); + +// ── Genuinely malformed / truly-empty terminal bodies must STILL be flagged ── + +test("#9971 genuinely empty terminal content:[] is still flagged", () => { + // Terminal stop_reason + no blocks at all = a truly empty completion. + assert.equal(detectMalformedNonStream(claudeMsg([], "end_turn")), "empty_choices"); +}); + +test("#9971 terminal '(empty response)' text sentinel is still flagged", () => { + // The OpenAI->Claude converter's sentinel for an upstream that produced no + // content: a terminal body carrying only that sentinel is genuinely empty. + const body = claudeMsg([{ type: "text", text: "(empty response)" }], "end_turn"); + assert.equal(detectMalformedNonStream(body), "empty_choices"); +}); + +test("#9971 truncated non-terminal empty body is valid (no false 502)", () => { + // Upstream cut mid-turn before a stop_reason landed: not a terminal completion, + // so the guard must not fire even though there is no output block yet. + const body = claudeMsg([], ""); + assert.equal(detectMalformedNonStream(body), null); +});