diff --git a/CHANGELOG.md b/CHANGELOG.md index 75326bf72a..b8441e915f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ _In development — bullets added per PR; finalized at release._ ### 🔧 Bug Fixes +- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln) - **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation) - **fix(dashboard): surface the detailed credential-validation error instead of a bare "invalid" badge** — the inline "Check" in the Add-Connection modal discarded the `error` message returned by `/api/providers/validate` and showed only an `invalid` badge. For web providers (claude-web / chatgpt-web) the real cause is often an environment error the backend already reports (e.g. `TLS impersonation client failed to start: EACCES … mkdir tls-client-node/bin`), so users were left guessing. The modal now renders the full reason next to the badge. ([#5088](https://github.com/diegosouzapw/OmniRoute/issues/5088), thanks @tkhs101) - **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta) diff --git a/open-sse/utils/diagnostics.ts b/open-sse/utils/diagnostics.ts index 8c58358c80..545203c3ad 100644 --- a/open-sse/utils/diagnostics.ts +++ b/open-sse/utils/diagnostics.ts @@ -200,6 +200,38 @@ export function detectMalformedNonStream(resp: unknown): MalformedReason | null return null; } + // ── Claude / Anthropic Messages shape ── + // A `/v1/messages` request to a Claude provider keeps the response in Claude shape + // (no translation when client and provider formats both = Claude), so it reaches here + // as `{ type:"message", content:[…] }` — which has neither `object:"response"` nor + // `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 b = block as Record; + // Text block with visible text. + if (b.type === "text" && typeof b.text === "string" && (b.text as string).length > 0) { + 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 "". + if ( + b.type === "thinking" && + typeof b.signature === "string" && + (b.signature as string).length > 0 + ) { + 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) { + return true; + } + return false; + }); + return hasOutput ? null : "empty_choices"; + } + // ── Chat Completions shape ── const choices = body.choices; if (!Array.isArray(choices) || choices.length === 0) return "empty_choices"; diff --git a/tests/unit/diagnostics-claude-thinking-5108.test.ts b/tests/unit/diagnostics-claude-thinking-5108.test.ts new file mode 100644 index 0000000000..0cfae4fced --- /dev/null +++ b/tests/unit/diagnostics-claude-thinking-5108.test.ts @@ -0,0 +1,63 @@ +/** + * #5108 — Regression from #4942. A non-streaming `/v1/messages` request to a Claude + * extended-thinking model returns HTTP 200 with a Claude-shaped body whose `content` + * array holds only an *empty* thinking block that still carries a valid `signature`: + * + * content: [{ type: "thinking", thinking: "", signature: "Eo…" }] + * + * `detectMalformedNonStream` only understood OpenAI Chat Completions (`choices`) and + * Responses API (`object:"response"`) shapes — a Claude body (`type:"message"`, + * `content:[…]`) has neither, so it fell through to `empty_choices` and OmniRoute + * returned 502 (cascading to "All models failed" inside a combo). The signature proves + * 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. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { detectMalformedNonStream } from "../../open-sse/utils/diagnostics.ts"; + +const claudeMsg = (content: unknown[]) => ({ + type: "message", + role: "assistant", + id: "msg_x", + model: "claude-opus-4-8", + content, + stop_reason: "end_turn", + usage: { input_tokens: 5, output_tokens: 1 }, +}); + +test("#5108 Claude thinking-only block with a signature is valid output (was 502 empty_choices)", () => { + const body = claudeMsg([{ type: "thinking", thinking: "", signature: "EoABCDEF" }]); + assert.equal(detectMalformedNonStream(body), null); +}); + +test("#5108 normal Claude text response is valid output", () => { + const body = claudeMsg([{ type: "text", text: "hello" }]); + assert.equal(detectMalformedNonStream(body), null); +}); + +test("#5108 Claude tool_use response is valid output", () => { + const body = claudeMsg([{ type: "tool_use", id: "toolu_1", name: "bash", input: {} }]); + assert.equal(detectMalformedNonStream(body), null); +}); + +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", () => { + const body = claudeMsg([{ type: "thinking", thinking: "", signature: "" }]); + assert.equal(detectMalformedNonStream(body), "empty_choices"); +}); + +// Existing OpenAI / Responses behavior must be unchanged. +test("#5108 OpenAI chat completion still validated normally", () => { + assert.equal( + detectMalformedNonStream({ choices: [{ message: { content: "hi" } }] }), + null + ); + assert.equal(detectMalformedNonStream({ choices: [] }), "empty_choices"); +});