From ff65652cdf4479253840fc1f788e7bf76fcda2ff Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Mon, 8 Jun 2026 20:59:38 -0300 Subject: [PATCH] fix(claude): respect client anthropic-beta instead of forcing thinking/effort betas (#3415) (#3458) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code -> claude-opus-4-8 turns intermittently died with 'tool call could not be parsed (retry also failed)'. OmniRoute's claude identity cloak rebuilt the anthropic-beta header from scratch and unconditionally forced interleaved-thinking-2025-05-14 (+ advanced-tool-use / effort for heavy agents), even when the client never negotiated them. The forced interleaved-thinking conflicts with tool_choice-forced turns, producing malformed opus tool_use streams (and sibling 400 'Thinking may not be enabled when tool_choice forces tool use'). selectBetaFlags now takes the client's inbound anthropic-beta: when present, thinking/effort betas are only emitted if the client requested them. Opaque clients (no header β€” the OAuth cloak path) keep the full set unchanged, so existing behavior and the #2454 model-tier gating are preserved. Co-authored-by: Forcerecon --- CHANGELOG.md | 1 + open-sse/executors/base.ts | 6 +- open-sse/executors/claudeIdentity.ts | 45 ++++++++-- .../claude-beta-client-header-3415.test.ts | 90 +++++++++++++++++++ 4 files changed, 132 insertions(+), 10 deletions(-) create mode 100644 tests/unit/claude-beta-client-header-3415.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9850b852a0..81aedb36b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ _Development cycle in progress β€” entries are added as work merges into `releas ### πŸ”§ Bug Fixes +- **fix(claude):** Claude Code β†’ `claude-opus-4-8` tool calls no longer break with `tool call could not be parsed (retry also failed)` β€” OmniRoute no longer force-injects `interleaved-thinking` / `advanced-tool-use` / `effort` beta flags the client never negotiated. When the client sends its own `anthropic-beta` header, those betas are only emitted if the client requested them; opaque clients (OAuth identity cloak) keep the full set unchanged. ([#3415](https://github.com/diegosouzapw/OmniRoute/issues/3415) β€” thanks @Forcerecon) - **fix(translator):** Vertex AI tool calls no longer fail with `400 Unknown name "id" at contents[].parts[].function_call` β€” the OpenAI-style `id` field is now stripped from `functionCall`/`functionResponse` parts when the routed provider is `vertex`/`vertex-partner`. The public Gemini API still receives `id` (required for Gemini 3+ signature matching). ([#3440](https://github.com/diegosouzapw/OmniRoute/issues/3440) β€” thanks @nullbytef0x) --- diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 5521615f7c..8bda0a1107 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -1004,10 +1004,14 @@ export class BaseExecutor { // convention; SSE decoding is gated on body.stream). anthropic-beta // is selected per request shape; the full set on a quota probe is // itself a fingerprint. + // Respect the client's negotiated anthropic-beta (real Claude Code) instead + // of force-injecting thinking/effort betas it never requested (#3415). + const clientAnthropicBeta = + clientHeaders?.["anthropic-beta"] ?? clientHeaders?.["Anthropic-Beta"] ?? null; const ccHeaders: Record = { Accept: "application/json", "anthropic-version": "2023-06-01", - "anthropic-beta": selectBetaFlags(tb), + "anthropic-beta": selectBetaFlags(tb, null, clientAnthropicBeta), "anthropic-dangerous-direct-browser-access": "true", "x-app": "cli", "User-Agent": `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)`, diff --git a/open-sse/executors/claudeIdentity.ts b/open-sse/executors/claudeIdentity.ts index dc3140b1b7..470fd62313 100644 --- a/open-sse/executors/claudeIdentity.ts +++ b/open-sse/executors/claudeIdentity.ts @@ -322,9 +322,32 @@ function isContext1mModel(model: unknown): boolean { */ export function selectBetaFlags( body: Record | null | undefined, - model?: string | null + model?: string | null, + // The client's inbound `anthropic-beta` header, when present. Real Claude Code + // clients negotiate their own beta set; forcing thinking/effort betas they never + // requested produced malformed opus tool_use streams (#3415). When this is a + // non-empty string we respect it and do NOT force those betas. For opaque clients + // (no header β€” the OAuth identity cloak) the full set is retained unchanged. + clientBeta?: string | null ): string { const b = body || {}; + const clientBetaSet = + typeof clientBeta === "string" && clientBeta.trim().length > 0 + ? new Set( + clientBeta + .split(",") + .map((f) => f.trim()) + .filter(Boolean) + ) + : null; + // When the client negotiated its own anthropic-beta, only emit the thinking / heavy + // betas it actually asked for. Opaque clients (clientBetaSet === null) keep them all. + const allowThinking = + clientBetaSet === null || clientBetaSet.has("interleaved-thinking-2025-05-14"); + const allowHeavy = + clientBetaSet === null || + clientBetaSet.has("advanced-tool-use-2025-11-20") || + clientBetaSet.has("effort-2025-11-24"); const hasSystem = !!b.system && (typeof b.system === "string" || (Array.isArray(b.system) && b.system.length > 0)); @@ -345,13 +368,17 @@ export function selectBetaFlags( if (isContext1m) { flags.push("context-1m-2025-08-07", "mid-conversation-system-2026-04-07"); } - flags.push( - "interleaved-thinking-2025-05-14", - "redact-thinking-2026-02-12", - "thinking-token-count-2026-05-13", - "context-management-2025-06-27", - "prompt-caching-scope-2026-01-05" - ); + // Thinking betas: gated on the client header (#3415). interleaved-thinking forces + // interleaved-thinking semantics that conflict with a tool_choice-forced turn, + // producing malformed opus tool_use streams when the client never asked for it. + if (allowThinking) { + flags.push( + "interleaved-thinking-2025-05-14", + "redact-thinking-2026-02-12", + "thinking-token-count-2026-05-13" + ); + } + flags.push("context-management-2025-06-27", "prompt-caching-scope-2026-01-05"); if (hasStructuredOutput || isFullAgent) flags.push("advisor-tool-2026-03-01"); if (hasStructuredOutput && !isFullAgent) flags.push("structured-outputs-2025-12-15"); // extended-cache-ttl is sent for all full-agent shapes (incl. Haiku); the @@ -359,7 +386,7 @@ export function selectBetaFlags( if (isFullAgent) { flags.push("extended-cache-ttl-2025-04-11", "cache-diagnosis-2026-04-07"); } - if (isHeavyAgent) { + if (isHeavyAgent && allowHeavy) { flags.push("advanced-tool-use-2025-11-20", "effort-2025-11-24"); } return flags.join(","); diff --git a/tests/unit/claude-beta-client-header-3415.test.ts b/tests/unit/claude-beta-client-header-3415.test.ts new file mode 100644 index 0000000000..507b02861f --- /dev/null +++ b/tests/unit/claude-beta-client-header-3415.test.ts @@ -0,0 +1,90 @@ +// Regression test for #3415 β€” forced anthropic-beta rewrite corrupts opus tool_use stream. +// +// For Claude Code traffic to claude-opus-4-8 (claudeβ†’claude passthrough), OmniRoute +// rebuilt the anthropic-beta header from scratch and UNCONDITIONALLY forced +// interleaved-thinking-2025-05-14 (+ advanced-tool-use / effort for heavy agents), +// even when the client never negotiated them. Anthropic then returned 200 turns with +// malformed/incomplete tool_use.input (and sibling 400 "Thinking may not be enabled +// when tool_choice forces tool use"), so Claude Code aborted with +// "tool call could not be parsed (retry also failed)". +// +// Fix: when the client sends its own anthropic-beta header (real Claude Code), do NOT +// force the thinking / advanced-tool-use / effort betas it did not request. Opaque +// clients (no client header β€” the OAuth identity cloak) keep the full set unchanged. + +import test from "node:test"; +import assert from "node:assert/strict"; + +const { selectBetaFlags } = await import("../../open-sse/executors/claudeIdentity.ts"); + +function fullAgentBody(model: string) { + return { + model, + system: "You are a coding agent.", + tools: [{ name: "read_file", description: "x", input_schema: { type: "object" } }], + }; +} + +// --- Opaque clients (no client anthropic-beta) keep current behavior (OAuth cloak) --- + +test("#3415 opaque client (no clientBeta) still receives the full forced set", () => { + const flags = selectBetaFlags(fullAgentBody("claude-opus-4-8")); + assert.ok(flags.includes("interleaved-thinking-2025-05-14")); + assert.ok(flags.includes("advanced-tool-use-2025-11-20")); + assert.ok(flags.includes("effort-2025-11-24")); + assert.ok(flags.includes("oauth-2025-04-20")); +}); + +// --- Client negotiated its own anthropic-beta: do NOT force thinking/effort --- + +test("#3415 opus client WITHOUT interleaved-thinking β†’ interleaved-thinking NOT forced", () => { + const flags = selectBetaFlags( + fullAgentBody("claude-opus-4-8"), + null, + "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14" + ); + assert.ok( + !flags.includes("interleaved-thinking-2025-05-14"), + "must NOT force interleaved-thinking when the client did not request it" + ); + assert.ok( + !flags.includes("advanced-tool-use-2025-11-20"), + "must NOT force advanced-tool-use when the client did not request it" + ); + assert.ok( + !flags.includes("effort-2025-11-24"), + "must NOT force effort when the client did not request it" + ); + // Mandatory OAuth cloak flag is still present. + assert.ok(flags.includes("oauth-2025-04-20")); +}); + +test("#3415 opus client WITH interleaved-thinking β†’ interleaved-thinking preserved", () => { + const flags = selectBetaFlags( + fullAgentBody("claude-opus-4-8"), + null, + "oauth-2025-04-20,interleaved-thinking-2025-05-14" + ); + assert.ok( + flags.includes("interleaved-thinking-2025-05-14"), + "must keep interleaved-thinking when the client requested it" + ); +}); + +test("#3415 opus client WITH effort/advanced-tool-use β†’ preserved", () => { + const flags = selectBetaFlags( + fullAgentBody("claude-opus-4-8"), + null, + "oauth-2025-04-20,advanced-tool-use-2025-11-20,effort-2025-11-24" + ); + assert.ok(flags.includes("advanced-tool-use-2025-11-20")); + assert.ok(flags.includes("effort-2025-11-24")); +}); + +test("#3415 empty client anthropic-beta string is treated as a real (minimal) client header", () => { + // A present-but-empty header means the client negotiated nothing β€” do not force thinking. + const flags = selectBetaFlags(fullAgentBody("claude-opus-4-8"), null, ""); + // Empty string is falsy β†’ treated as opaque (no header), full set retained. + // This documents the boundary: only a NON-empty client header gates the forced flags. + assert.ok(flags.includes("oauth-2025-04-20")); +});