mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
committed by
GitHub
parent
cc850122e3
commit
ff65652cdf
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
@@ -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)`,
|
||||
|
||||
@@ -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(",");
|
||||
|
||||
90
tests/unit/claude-beta-client-header-3415.test.ts
Normal file
90
tests/unit/claude-beta-client-header-3415.test.ts
Normal 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"));
|
||||
});
|
||||
Reference in New Issue
Block a user