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 @@ -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 '<name>' 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 `<baseUrl>/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)
Expand Down
3 changes: 2 additions & 1 deletion config/quality/file-size-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <baseUrl>/models fetch instead of serving the stale hardcoded registry catalog). Structural shrink of this route tracked in #3789.",
Expand All @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions open-sse/config/anthropicHeaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
9 changes: 8 additions & 1 deletion open-sse/executors/base.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -1039,7 +1040,13 @@ export class BaseExecutor {
const ccHeaders: Record<string, string> = {
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)`,
Expand Down
12 changes: 12 additions & 0 deletions open-sse/executors/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
Comment on lines +524 to +528

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.

high

If clientHeaders is null or undefined (which is possible as indicated by the clientHeaders || {} fallback on line 511), accessing clientHeaders["anthropic-beta"] directly will throw a TypeError and crash the server. Additionally, HTTP headers are case-insensitive, so we should perform a case-insensitive lookup on clientHeaders just like we do for headers to ensure we don't miss any client-negotiated betas.

      const clientBetaKey = clientHeaders
        ? Object.keys(clientHeaders).find((key) => key.toLowerCase() === "anthropic-beta")
        : undefined;
      const clientBeta = clientBetaKey ? clientHeaders[clientBetaKey] : null;
      const betaKey = Object.keys(headers).find((key) => key.toLowerCase() === "anthropic-beta");
      if (betaKey && clientBeta) {
        headers[betaKey] = mergeClientAnthropicBeta(headers[betaKey], clientBeta);
      }

}

return headers;
Expand Down
103 changes: 103 additions & 0 deletions tests/unit/claude-tool-search-beta-3974.test.ts
Original file line number Diff line number Diff line change
@@ -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 '<name>' 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, string>): 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<string, string>;
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");
});