diff --git a/changelog.d/fixes/12968-anthropic-shim-tiny-probe-empty.md b/changelog.d/fixes/12968-anthropic-shim-tiny-probe-empty.md new file mode 100644 index 00000000000..2f576c8e3d7 --- /dev/null +++ b/changelog.d/fixes/12968-anthropic-shim-tiny-probe-empty.md @@ -0,0 +1 @@ +- **fix(sse):** stop misclassifying a truncated Anthropic-compatible `max_tokens` probe response (`content:[{type:"text",text:""}]`) as an empty upstream response (#12968) — thanks @pranay-gpt diff --git a/open-sse/utils/diagnostics.ts b/open-sse/utils/diagnostics.ts index ebee72e1e84..d7f56b54fab 100644 --- a/open-sse/utils/diagnostics.ts +++ b/open-sse/utils/diagnostics.ts @@ -255,7 +255,9 @@ export function detectMalformedNonStream(resp: unknown): MalformedReason | null // 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). + // stop_reason (parity with the OpenAI content:"" path) — UNLESS the + // terminal stop_reason is one of the legitimate truncated-completion + // exemptions below (#12968). // 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 @@ -265,12 +267,16 @@ export function detectMalformedNonStream(resp: unknown): MalformedReason | null // 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 : ""; - if (stopReason.length === 0) return null; - if (stopReason === "max_tokens" || stopReason === "tool_use") return null; - return "empty_choices"; - } + const stopReason = typeof body.stop_reason === "string" ? body.stop_reason : ""; + // #12968: the #9971 exemption above only fired when `content` was a + // completely empty array. A tiny `max_tokens` probe against an + // Anthropic-compatible shim can instead return content:[{type:"text", + // text:""}] — one block, just with no visible text — which is the exact + // same legitimate truncated-completion shape, so the exemption must apply + // whenever there is no visible output, not only when content is []. + if (stopReason === "max_tokens" || stopReason === "tool_use") return null; + // content:[] with no stop_reason at all is non-terminal, not empty (#9971). + if (content.length === 0 && stopReason.length === 0) return null; return "empty_choices"; } @@ -323,7 +329,8 @@ export function describeMalformedNonStream( ): { message: string; code: string; type: string } { const body = resp && typeof resp === "object" ? (resp as Record) : null; if (body?.object === "response" && body.status === "failed") { - const err = body.error && typeof body.error === "object" ? (body.error as Record) : null; + const err = + body.error && typeof body.error === "object" ? (body.error as Record) : null; const rawMessage = typeof err?.message === "string" && err.message.trim().length > 0 ? err.message.trim() : null; return { diff --git a/tests/unit/issue-12968-anthropic-shim-empty-text-block.test.ts b/tests/unit/issue-12968-anthropic-shim-empty-text-block.test.ts new file mode 100644 index 00000000000..627b41c5bce --- /dev/null +++ b/tests/unit/issue-12968-anthropic-shim-empty-text-block.test.ts @@ -0,0 +1,39 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { detectMalformedNonStream } from "../../open-sse/utils/diagnostics.ts"; + +// Exact upstream body captured in the issue (freeaiapikey.com probe response). +const upstreamBody = { + content: [{ text: "", type: "text" }], + id: "msg_4d5e123dda0d4eda8055cd21", + model: "anthropic/claude-sonnet-5", + role: "assistant", + stop_reason: "max_tokens", + stop_sequence: null, + type: "message", + usage: { + cache_creation_input_tokens: 2623, + cache_read_input_tokens: 0, + input_tokens: 2234, + output_tokens: 1, + }, +}; + +test("#12968 max_tokens probe with content:[{text:''}] must NOT be flagged empty_choices", () => { + const reason = detectMalformedNonStream(upstreamBody); + assert.equal( + reason, + null, + `expected legitimate truncated-probe response to pass through, got reason=${reason}` + ); +}); + +test("#12968 control — content:[] + max_tokens already exempted (#9971)", () => { + const reason = detectMalformedNonStream({ ...upstreamBody, content: [] }); + assert.equal(reason, null); +}); + +test("#12968 control — empty text block with end_turn stop_reason stays flagged", () => { + const reason = detectMalformedNonStream({ ...upstreamBody, stop_reason: "end_turn" }); + assert.equal(reason, "empty_choices"); +});