Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---
Expand Down
6 changes: 5 additions & 1 deletion open-sse/executors/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)`,
Expand Down
45 changes: 36 additions & 9 deletions open-sse/executors/claudeIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +334 to +342

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is a contradiction between the code implementation and the comments/test intent.

The comment in the test file states:
// A present-but-empty header means the client negotiated nothing — do not force thinking.

However, because of the clientBeta.trim().length > 0 check, an empty string "" results in clientBetaSet being null. When clientBetaSet is null, allowThinking and allowHeavy default to true, which means thinking and heavy betas are forced.

To resolve this contradiction and correctly treat an empty client header as "negotiated nothing" (thus not forcing thinking/heavy betas), we should check only if clientBeta is a string.

Suggested change
const clientBetaSet =
typeof clientBeta === "string" && clientBeta.trim().length > 0
? new Set(
clientBeta
.split(",")
.map((f) => f.trim())
.filter(Boolean)
)
: null;
const clientBetaSet =
typeof clientBeta === "string"
? 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));
Expand All @@ -345,21 +368,25 @@ 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
// heavier afk-mode / advanced-tool-use / effort flags are Opus/Sonnet-only.
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(",");
Expand Down
90 changes: 90 additions & 0 deletions tests/unit/claude-beta-client-header-3415.test.ts
Original file line number Diff line number Diff line change
@@ -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"));
});
Comment on lines +84 to +90

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The comments and assertions in this test are contradictory:

  1. // A present-but-empty header means the client negotiated nothing — do not force thinking.
  2. // Empty string is falsy → treated as opaque (no header), full set retained.

If the empty string is treated as opaque and the full set is retained, then thinking is forced, which directly contradicts "do not force thinking".

If we update selectBetaFlags to treat any string (including "") as a non-opaque client, we should also update this test to assert that thinking/heavy betas are indeed not forced.

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