fix(claude): respect client anthropic-beta instead of forcing thinking/effort betas (#3415) (#3458)

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 <Forcerecon@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-08 20:59:38 -03:00
committed by GitHub
parent cc850122e3
commit ff65652cdf
4 changed files with 132 additions and 10 deletions

View File

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

View File

@@ -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<string, string> = {
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)`,

View File

@@ -322,9 +322,32 @@ function isContext1mModel(model: unknown): boolean {
*/
export function selectBetaFlags(
body: Record<string, unknown> | 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(",");

View File

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