From f1f87ff72e6718ae01f008fb565e56ce0843d093 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 16 Jun 2026 09:43:09 -0300 Subject: [PATCH] fix(claude): forward client tool-search-tool anthropic-beta on the Claude OAuth path (#3974) The client-negotiated anthropic-beta: tool-search-tool-2025-10-19 was dropped on both Claude code paths (default executor rebuilt from static ANTHROPIC_BETA_CLAUDE_OAUTH; selectBetaFlags only read the client beta to gate thinking/effort), so claude.ai rejected deferred-tool requests with 400 'Tool reference not found'. Add an allowlist-merge (mergeClientAnthropicBeta) that unions the client's allowlisted betas into the outbound set on both paths, preserving #3415 (no forced thinking/effort). --- CHANGELOG.md | 1 + config/quality/file-size-baseline.json | 3 +- open-sse/config/anthropicHeaders.ts | 35 ++++++ open-sse/executors/base.ts | 9 +- open-sse/executors/default.ts | 12 ++ .../unit/claude-tool-search-beta-3974.test.ts | 103 ++++++++++++++++++ 6 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 tests/unit/claude-tool-search-beta-3974.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cf4e758ccfb..b6207089dc1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ ### 🐛 Fixed +- **fix(claude): forward the client-negotiated `anthropic-beta: tool-search-tool-2025-10-19` on the Claude OAuth path** — with `ENABLE_TOOL_SEARCH` active, Claude Code sends deferred tools + a `tool_search_tool_*` and negotiates the `tool-search-tool-2025-10-19` beta, but OmniRoute dropped that beta on **both** Claude code paths (the `default` executor rebuilt the header from the static `ANTHROPIC_BETA_CLAUDE_OAUTH` set, and `selectBetaFlags` only read the client beta to gate thinking/effort), so the claude.ai backend rejected every deferred-tool request with `400 Tool reference '' not found in available tools`. A new allowlist-merge (`mergeClientAnthropicBeta`) now unions the client's negotiated beta into the outbound set on both paths — appending only allowlisted client betas (currently just `tool-search-tool-2025-10-19`) so it never forces betas the client didn't request (preserving the #3415 fix) nor leaks betas the backend rejects. ([#3974](https://github.com/diegosouzapw/OmniRoute/issues/3974) — thanks @huohua-dev) - **fix(combo): strict-random spreads fallbacks across healthy peers instead of retrying a failing model** — with the `strict-random` strategy, a model that kept failing was retried on essentially every request and traffic concentrated on a few models. The strategy shuffled only the deck-selected slot 0 and left the fallback remainder in **fixed priority order**, so after any failing deck pick the dispatch chain always fell through to the same top-priority model next. The fallback remainder is now shuffled (like the `random` strategy), so the fallback load — and recovery from a persistently-failing target — spreads evenly across the healthy peers. (Note: when the client always sends `tools` (e.g. OpenCode), the combo still correctly routes only to the tool-capable models in the combo — that capability filtering is by design.) ([#3959](https://github.com/diegosouzapw/OmniRoute/issues/3959) — thanks @KeNJiKunG) - **fix(dashboard): Logs auto-refresh now works even when the tab loads in the background** — the Logs page never auto-refreshed (only the manual Refresh button worked). The auto-refresh interval gated each tick on a visibility ref seeded once at mount and updated only by a `visibilitychange` event; when the tab mounted while the document reported `hidden` (background load, bfcache restore, embedded/Docker-proxied webviews) and no `visibilitychange` ever fired, the ref stayed `false` forever, so the interval ticked but never fetched. The tick now reads the live `document.visibilityState`, so polling self-heals as soon as the tab is visible — while still pausing when genuinely hidden. ([#3972](https://github.com/diegosouzapw/OmniRoute/issues/3972) — thanks @tjengbudi) - **fix(providers): LLM7 (and BytePlus) now fetch the live `/models` catalog instead of a stale hardcoded list** — importing an LLM7 key surfaced only a small, outdated model list even though `GET https://api.llm7.io/v1/models` returns the full pro/standard catalog. Both providers carried a correct `modelsUrl` in the registry, but neither was classified by any live-fetch branch of the model-import route (not `openai-compatible-*`, not self-hosted, not in `NAMED_OPENAI_STYLE_PROVIDERS`), so the route skipped the upstream probe and served the registry's 4 hardcoded entries (`source: "local_catalog"`). Added `llm7` and `byteplus` to `NAMED_OPENAI_STYLE_PROVIDERS` so the route probes `/models` with the key and serves the live catalog, falling back to the local catalog only when the upstream fetch fails (so key import never breaks). ([#3976](https://github.com/diegosouzapw/OmniRoute/issues/3976) — thanks @FerLuisxd) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 2fc9b369c481..5f2b20cb4e7b 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -15,6 +15,7 @@ "_rebaseline_2026_06_15_3929_vertex_media": "PR #3929 own growth: audioSpeech.ts 952->965 (+13) and videoGeneration.ts 1026->1078 (+52) = vertex/* media branches (Gemini TTS, Veo predictLongRunning poll) wired into the speech/video handlers; new logic lives in open-sse/executors/vertexMedia.ts (341, under cap). Cohesive media-provider feature.", "_rebaseline_2026_06_15_3879_redact_thinking": "PR #3879 + #3921 reconcile: AddApiKeyModal.tsx 843->845 (+2 = merging #3879's CcCompatibleRequestDefaultsFields (context1m + opt-in redact-thinking toggle) into #3921's preset-input block in the cc-compatible settings group). Cohesive UI; not extractable.", "_rebaseline_2026_06_15_3890_cache_preserve": "Issue #3890 own growth: chatCore.ts 5815->5823 (+8 = wire resolveCacheAwareConfig() into the compression apply step so the system prompt is never compressed in a caching context — honors the cache-aware skipSystemPrompt flag that selectCompressionStrategy could not carry). Cohesive cache-preservation guard at the existing compression chokepoint; not extractable.", + "_rebaseline_2026_06_16_3974_toolsearch_beta": "Issue #3974 own growth: base.ts 1218->1222 (+4 = wrap selectBetaFlags() with mergeClientAnthropicBeta() at the ccHeaders anthropic-beta callsite, plus a 3-line comment, so the client's allowlisted tool-search beta survives). The shared helper + allowlist live in anthropicHeaders.ts (small file, well under cap); default.ts also gains the merge. Cohesive one-callsite fix; not extractable.", "_rebaseline_2026_06_16_3959_strict_random_shuffle": "Issue #3959 own growth: combo.ts 5277->5283 (+6 = shuffle the strict-random fallback remainder via fisherYatesShuffle, plus a 4-line comment, so a failing deck pick no longer always falls through to the same fixed top-priority model). Cohesive one-spot routing fix; structural shrink of combo.ts tracked in #3501.", "_rebaseline_2026_06_16_3972_logs_autorefresh": "Issue #3972 own growth: RequestLoggerV2.tsx 1282->1287 (+5 = the auto-refresh interval now reads the live document.visibilityState each tick instead of a stale mount-time ref, plus a 3-line comment explaining the hidden-tab trap). Cohesive one-spot fix; structural shrink of this component tracked in #3501.", "_rebaseline_2026_06_16_3976_llm7_byteplus_models": "Issue #3976 own growth: models/route.ts 2489->2494 (+5 = add llm7 + byteplus to NAMED_OPENAI_STYLE_PROVIDERS with an explanatory comment so the import route does a live /models fetch instead of serving the stale hardcoded registry catalog). Structural shrink of this route tracked in #3789.", @@ -24,7 +25,7 @@ "frozen": { "open-sse/config/providerRegistry.ts": 4731, "open-sse/executors/antigravity.ts": 1664, - "open-sse/executors/base.ts": 1218, + "open-sse/executors/base.ts": 1222, "open-sse/executors/chatgpt-web.ts": 2870, "open-sse/executors/claude-web.ts": 1057, "open-sse/executors/codex.ts": 1447, diff --git a/open-sse/config/anthropicHeaders.ts b/open-sse/config/anthropicHeaders.ts index 091b00a6882e..0cad3c043cec 100644 --- a/open-sse/config/anthropicHeaders.ts +++ b/open-sse/config/anthropicHeaders.ts @@ -29,6 +29,41 @@ export const ANTHROPIC_BETA_CLAUDE_OAUTH = [ ...ANTHROPIC_BETA_BASE.slice(3), ].join(","); +/** + * Client-negotiated `anthropic-beta` values that are safe to forward to the + * claude.ai backend on top of OmniRoute's own set. Kept to betas the backend + * actually accepts and that OmniRoute does not otherwise emit — so a blind + * passthrough cannot reintroduce the over-sending fingerprint/rejection bugs + * (#3415, #2454). Currently: deferred-tool negotiation (#3974). + */ +export const FORWARDABLE_CLIENT_BETAS = Object.freeze(["tool-search-tool-2025-10-19"]); + +/** + * Union an `anthropic-beta` comma-list with the allowlisted client-negotiated + * betas, preserving base order and appending only NEW tokens (deduped, + * case-insensitive). The client beta is added only if it is on `allow`, so this + * never forces betas the client did not request nor leaks betas the backend + * rejects. See #3974 (tool-search-tool dropped on the Claude OAuth path). + */ +export function mergeClientAnthropicBeta( + base: string, + clientBeta: string | null | undefined, + allow: readonly string[] = FORWARDABLE_CLIENT_BETAS +): string { + const baseList = base.split(",").map((s) => s.trim()).filter(Boolean); + if (typeof clientBeta !== "string" || !clientBeta.trim()) return baseList.join(","); + const seen = new Set(baseList.map((s) => s.toLowerCase())); + const allowSet = new Set(allow.map((s) => s.toLowerCase())); + for (const token of clientBeta.split(",").map((s) => s.trim()).filter(Boolean)) { + const lower = token.toLowerCase(); + if (allowSet.has(lower) && !seen.has(lower)) { + baseList.push(token); + seen.add(lower); + } + } + return baseList.join(","); +} + export const CLAUDE_CLI_VERSION = "2.1.158"; export const CLAUDE_CLI_USER_AGENT = `claude-cli/${CLAUDE_CLI_VERSION} (external, cli)`; export const CLAUDE_CLI_STAINLESS_PACKAGE_VERSION = "0.94.0"; diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 77690d30ed06..95fa963b19d9 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -1,4 +1,5 @@ import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts"; +import { mergeClientAnthropicBeta } from "../config/anthropicHeaders.ts"; import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts"; import { supportsClaudeMaxEffort, supportsXHighEffort } from "../config/providerModels.ts"; import type { PoolConfig } from "../services/sessionPool/types.ts"; @@ -1039,7 +1040,13 @@ export class BaseExecutor { const ccHeaders: Record = { Accept: "application/json", "anthropic-version": "2023-06-01", - "anthropic-beta": selectBetaFlags(tb, null, clientAnthropicBeta), + // #3974: merge the client's allowlisted betas (e.g. tool-search-tool) + // on top of the shape-derived set so deferred-tool requests are not + // rejected; selectBetaFlags still gates thinking/effort per #3415. + "anthropic-beta": mergeClientAnthropicBeta( + selectBetaFlags(tb, null, clientAnthropicBeta), + 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/default.ts b/open-sse/executors/default.ts index 2a7fd968b286..98c183342d25 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -14,6 +14,7 @@ import { } from "../services/claudeCodeCompatible.ts"; import { getGigachatAccessToken } from "../services/gigachatAuth.ts"; import { getRegistryEntry } from "../config/providerRegistry.ts"; +import { mergeClientAnthropicBeta } from "../config/anthropicHeaders.ts"; import { applyProviderRequestDefaults } from "../services/providerRequestDefaults.ts"; import { detectFormat, @@ -514,6 +515,17 @@ export class DefaultExecutor extends BaseExecutor { headers[headerName] = value; } } + + // #3974: merge the client's negotiated anthropic-beta (allowlisted) into the + // outbound set. The registry's static ANTHROPIC_BETA_CLAUDE_OAUTH lacks + // tool-search-tool-2025-10-19, so deferred-tool requests were rejected with + // 400 "Tool reference not found". Allowlist-merge preserves it without + // forwarding betas the backend rejects. + const clientBeta = clientHeaders["anthropic-beta"] ?? clientHeaders["Anthropic-Beta"] ?? null; + const betaKey = Object.keys(headers).find((key) => key.toLowerCase() === "anthropic-beta"); + if (betaKey && clientBeta) { + headers[betaKey] = mergeClientAnthropicBeta(headers[betaKey], clientBeta); + } } return headers; diff --git a/tests/unit/claude-tool-search-beta-3974.test.ts b/tests/unit/claude-tool-search-beta-3974.test.ts new file mode 100644 index 000000000000..fdc7f531b98d --- /dev/null +++ b/tests/unit/claude-tool-search-beta-3974.test.ts @@ -0,0 +1,103 @@ +/** + * TDD regression for #3974: the client-negotiated `anthropic-beta: + * tool-search-tool-2025-10-19` is dropped on BOTH Claude code paths, so the + * claude.ai backend rejects deferred-tool requests with + * `400 Tool reference '' not found in available tools`. + * + * - default executor (`claude` uses executor:"default"): buildHeaders set the + * beta from the static registry header (ANTHROPIC_BETA_CLAUDE_OAUTH, which + * lacks tool-search) and the client-header forward block is allowlist-only. + * - selectBetaFlags path (base.ts): rebuilds the beta from a fixed vocabulary + * and only reads the client beta to GATE thinking/effort, never to ADD. + * + * Fix: allowlist-merge the client beta on both paths (preserving #3415 — never + * force thinking/effort the client did not send). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { DefaultExecutor } from "../../open-sse/executors/default.ts"; +const { selectBetaFlags } = await import("../../open-sse/executors/claudeIdentity.ts"); +const { mergeClientAnthropicBeta, FORWARDABLE_CLIENT_BETAS } = await import( + "../../open-sse/config/anthropicHeaders.ts" +); + +const TOOL_SEARCH = "tool-search-tool-2025-10-19"; + +function fullAgentBody(model: string) { + return { + model, + system: "You are a coding agent.", + tools: [{ name: "read_file", description: "x", input_schema: { type: "object" } }], + }; +} + +function betaTokens(headers: Record): string[] { + const key = Object.keys(headers).find((k) => k.toLowerCase() === "anthropic-beta"); + return key ? headers[key].split(",").map((s) => s.trim()) : []; +} + +// ── helper unit (allowlist-merge) ──────────────────────────────────────────── + +test("#3974 mergeClientAnthropicBeta appends an allowlisted client beta once, preserving base order", () => { + const out = mergeClientAnthropicBeta( + "claude-code-20250219,oauth-2025-04-20", + `oauth-2025-04-20,${TOOL_SEARCH}` + ); + const tokens = out.split(","); + assert.deepEqual(tokens.slice(0, 2), ["claude-code-20250219", "oauth-2025-04-20"]); + assert.equal(tokens.filter((t) => t === TOOL_SEARCH).length, 1, "appended once, no dup"); +}); + +test("#3974 mergeClientAnthropicBeta ignores non-allowlisted client betas", () => { + const out = mergeClientAnthropicBeta("oauth-2025-04-20", "some-random-future-beta-2099-01-01"); + assert.equal(out, "oauth-2025-04-20", "only allowlisted betas are forwarded"); + assert.ok(FORWARDABLE_CLIENT_BETAS.includes(TOOL_SEARCH)); +}); + +// ── selectBetaFlags path (base.ts) ─────────────────────────────────────────── + +test("#3974 selectBetaFlags + merge preserves the client tool-search beta", () => { + const clientBeta = `claude-code-20250219,oauth-2025-04-20,${TOOL_SEARCH}`; + const out = mergeClientAnthropicBeta( + selectBetaFlags(fullAgentBody("claude-opus-4-8"), null, clientBeta), + clientBeta + ); + assert.ok(out.split(",").includes(TOOL_SEARCH)); +}); + +test("#3974 merge does not force interleaved-thinking the client omitted (guards #3415)", () => { + const clientBeta = `oauth-2025-04-20,${TOOL_SEARCH}`; + const out = mergeClientAnthropicBeta( + selectBetaFlags(fullAgentBody("claude-opus-4-8"), null, clientBeta), + clientBeta + ); + assert.ok(out.split(",").includes(TOOL_SEARCH)); + assert.ok( + !out.split(",").includes("interleaved-thinking-2025-05-14"), + "must NOT force interleaved-thinking when the client did not request it" + ); +}); + +// ── default executor path (default.ts buildHeaders) ────────────────────────── + +test("#3974 DefaultExecutor('claude') merges the client tool-search beta into outbound headers", () => { + const executor = new DefaultExecutor("claude"); + const headers = executor.buildHeaders({ accessToken: "sk-ant-oat-x" }, true, { + "anthropic-beta": `oauth-2025-04-20,${TOOL_SEARCH}`, + }) as Record; + const tokens = betaTokens(headers); + assert.ok(tokens.includes(TOOL_SEARCH), `outbound beta missing tool-search: ${tokens.join(",")}`); + assert.equal(tokens.filter((t) => t === TOOL_SEARCH).length, 1, "no duplicate"); + // The provider's own base betas are still present. + assert.ok(tokens.includes("claude-code-20250219")); +}); + +test("#3974 DefaultExecutor('claude') without a client beta leaves the static set unchanged", () => { + const executor = new DefaultExecutor("claude"); + const headers = executor.buildHeaders({ accessToken: "sk-ant-oat-x" }, true, {}) as Record< + string, + string + >; + assert.ok(!betaTokens(headers).includes(TOOL_SEARCH), "must not invent tool-search unprompted"); +});