Compare commits

...

3 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza
4e0555a989 chore: sync release/v3.8.51 into fix/12968-anthropic-shim-tiny-probe-empty (base-red fix #13747) 2026-09-15 23:24:08 -03:00
diegosouzapw
512ffb6a49 Merge commit '8f55d85d221e8df0b788eab0e598935a1514536a' into fix/12968-anthropic-shim-tiny-probe-empty 2026-09-15 23:15:30 -03:00
diegosouzapw
1603f4f5c2 fix(sse): widen max_tokens/tool_use exemption to lone empty text blocks (#12968)
detectMalformedNonStream (open-sse/utils/diagnostics.ts) only exempted a
terminal stop_reason of max_tokens/tool_use when content was a completely
empty array ([]). An Anthropic-compatible shim's tiny max_tokens:1 probe
instead returns content:[{type:"text",text:""}] — one block with no visible
text — which fell straight through to empty_choices regardless of
stop_reason, producing a false upstream_empty_response/502.

Fix hoists the stop_reason exemption check out of the content.length===0
conditional so it applies whenever detectMalformedNonStream finds no visible
output, matching errorClassifier.ts's existing raw-body behavior for the same
shape.

Regression test: tests/unit/issue-12968-anthropic-shim-empty-text-block.test.ts
2026-09-15 14:29:21 -03:00
3 changed files with 55 additions and 8 deletions

View File

@@ -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

View File

@@ -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<string, unknown>) : null;
if (body?.object === "response" && body.status === "failed") {
const err = body.error && typeof body.error === "object" ? (body.error as Record<string, unknown>) : null;
const err =
body.error && typeof body.error === "object" ? (body.error as Record<string, unknown>) : null;
const rawMessage =
typeof err?.message === "string" && err.message.trim().length > 0 ? err.message.trim() : null;
return {

View File

@@ -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");
});