From 4e705b9e002b35ed10efcdd33f48b68aca0f90da Mon Sep 17 00:00:00 2001 From: Anton <39598727+NomenAK@users.noreply.github.com> Date: Sun, 28 Jun 2026 08:09:27 +0200 Subject: [PATCH] fix(translator): accept Claude Messages shape in non-stream malformed-200 guard (#5156) Integrated into release/v3.8.39 --- open-sse/utils/diagnostics.ts | 24 ++++++++++---- tests/unit/diagnostics.test.ts | 59 ++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/open-sse/utils/diagnostics.ts b/open-sse/utils/diagnostics.ts index b323da4496..d84eabdf79 100644 --- a/open-sse/utils/diagnostics.ts +++ b/open-sse/utils/diagnostics.ts @@ -168,6 +168,9 @@ export function synthResponsesFailure(reason?: MalformedReason): string { * - Tool-call responses (content=null + tool_calls=[…]) are also valid. * - Responses API function_call / other structural items count as output even * when they carry no user-visible text. + * - Claude Messages shape (type:"message" + content[]) is checked directly, + * since a Claude client receives the body in that shape (no + * `choices`/`object:"response"`). */ export function detectMalformedNonStream(resp: unknown): MalformedReason | null { if (!resp || typeof resp !== "object") return "empty_choices"; @@ -213,16 +216,25 @@ export function detectMalformedNonStream(resp: unknown): MalformedReason | null // throws on `null.type` (that would crash the whole non-stream classifier). if (block === null || typeof block !== "object") return false; const b = block as Record; - // Text block with visible text. - if (b.type === "text" && typeof b.text === "string" && (b.text as string).length > 0) { + // Text block with visible text. `convertOpenAINonStreamingToClaude` emits + // "(empty response)" as a placeholder when the upstream produced no content, + // so treat that sentinel as empty — a genuinely empty completion still trips + // the guard (parity with the OpenAI `content:""` path). + if ( + b.type === "text" && + typeof b.text === "string" && + (b.text as string).length > 0 && + b.text !== "(empty response)" + ) { return true; } - // Extended-thinking block: a non-empty `signature` is cryptographic proof the - // thinking step ran, so it is a valid completion even when the thinking text is "". + // 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.signature === "string" && - (b.signature as string).length > 0 + ((typeof b.thinking === "string" && (b.thinking as string).length > 0) || + (typeof b.signature === "string" && (b.signature as string).length > 0)) ) { return true; } diff --git a/tests/unit/diagnostics.test.ts b/tests/unit/diagnostics.test.ts index 9c7df879f0..7ebefceb44 100644 --- a/tests/unit/diagnostics.test.ts +++ b/tests/unit/diagnostics.test.ts @@ -209,6 +209,65 @@ test("detectMalformedNonStream allows Responses API function_call items as valid assert.equal(detectMalformedNonStream(body), null); }); +// ── (c2) detectMalformedNonStream — Claude Messages shape ──────────────────── + +test("detectMalformedNonStream returns null for Claude message with text content", () => { + const body = { + type: "message", + role: "assistant", + content: [{ type: "text", text: "Hi!" }], + stop_reason: "end_turn", + }; + assert.equal(detectMalformedNonStream(body), null); +}); + +test("detectMalformedNonStream returns null for Claude message with tool_use block", () => { + const body = { + type: "message", + role: "assistant", + content: [{ type: "tool_use", id: "toolu_1", name: "search", input: {} }], + stop_reason: "tool_use", + }; + assert.equal(detectMalformedNonStream(body), null); +}); + +test("detectMalformedNonStream returns null for Claude message with thinking block", () => { + const body = { + type: "message", + role: "assistant", + content: [{ type: "thinking", thinking: "let me think" }], + stop_reason: "end_turn", + }; + assert.equal(detectMalformedNonStream(body), null); +}); + +test("detectMalformedNonStream returns 'empty_choices' for Claude message with empty content", () => { + const body = { type: "message", role: "assistant", content: [], stop_reason: "end_turn" }; + assert.equal(detectMalformedNonStream(body), "empty_choices"); +}); + +test("detectMalformedNonStream returns 'empty_choices' for Claude message with empty-text block", () => { + const body = { + type: "message", + role: "assistant", + content: [{ type: "text", text: "" }], + stop_reason: "end_turn", + }; + assert.equal(detectMalformedNonStream(body), "empty_choices"); +}); + +test("detectMalformedNonStream returns 'empty_choices' for Claude message with (empty response) placeholder", () => { + // convertOpenAINonStreamingToClaude substitutes this placeholder for empty + // upstream content; it must still be treated as malformed. + const body = { + type: "message", + role: "assistant", + content: [{ type: "text", text: "(empty response)" }], + stop_reason: "end_turn", + }; + assert.equal(detectMalformedNonStream(body), "empty_choices"); +}); + // ── (d) no stack trace leakage ─────────────────────────────────────────────── test("synthOpenAIErrorChunk message does NOT contain stack trace path", () => {