diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json
index f4b6733353..c676e87f12 100644
--- a/src/i18n/messages/en.json
+++ b/src/i18n/messages/en.json
@@ -5495,6 +5495,9 @@
"lkgpToggleDesc": "When enabled, the router remembers which provider last served a successful response and tries it first on subsequent requests.",
"echoRequestedModelTitle": "Echo requested model name in responses",
"echoRequestedModelDesc": "When enabled, the response `model` field echoes the alias or combo name the client requested instead of the upstream model name. Fixes strict clients (e.g. Claude Desktop) that reject a response whose model does not match the request.",
+ "webSearchRouteTitle": "Web search routing",
+ "webSearchRouteDesc": "When a request includes a native web_search tool, route the whole request to this model instead of the default — useful for providers that don't implement Anthropic's web_search server tool. Leave blank to disable.",
+ "webSearchRoutePlaceholder": "e.g. openrouter,anthropic/claude-3.5-sonnet",
"clearLkgpCache": "Clear LKGP Cache",
"lkgpCacheCleared": "LKGP cache cleared successfully",
"lkgpCacheClearFailed": "Failed to clear LKGP cache",
diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts
index 1acbe59272..9a8888bacd 100644
--- a/src/shared/validation/settingsSchemas.ts
+++ b/src/shared/validation/settingsSchemas.ts
@@ -292,6 +292,11 @@ export const updateSettingsSchema = z.object({
lkgpEnabled: z.boolean().optional(),
// #1311: echo the requested alias/combo name in the response model field (opt-in)
echoRequestedModelName: z.boolean().optional(),
+ // #4481 layer 2: CCR-style Router.webSearch — when a request carries a native
+ // web_search server tool, route the whole request to this model/provider instead of
+ // the default (for providers that don't implement Anthropic's web_search server tool).
+ // Empty/unset = disabled. Value is a model string ("provider,model" / alias / combo).
+ webSearchRouteModel: z.string().max(200).optional(),
backgroundDegradation: z.unknown().optional(),
bruteForceProtection: z.boolean().optional(),
// Auto-routing settings
diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts
index 1f621b954d..d20b5729d8 100644
--- a/src/sse/handlers/chat.ts
+++ b/src/sse/handlers/chat.ts
@@ -84,6 +84,10 @@ import {
applyTaskAwareRouting,
getTaskRoutingConfig,
} from "@omniroute/open-sse/services/taskAwareRouter.ts";
+import {
+ hasNativeWebSearchTool,
+ resolveWebSearchRouteOverride,
+} from "@omniroute/open-sse/services/webSearchRouting.ts";
import {
generateSessionId as generateStableSessionId,
touchSession,
@@ -401,6 +405,24 @@ export async function handleChat(
telemetry.endPhase();
}
+ // #4481 layer 2 — Web-Search Routing (CCR-style Router.webSearch): a native web_search
+ // server tool + a configured `webSearchRouteModel` routes the whole request to that
+ // model (some providers don't implement Anthropic's web_search_20250305 server tool).
+ // Settings are read only when a web-search tool is present; the override lands before
+ // auto/combo resolution and the layer-1 fallback so the target's own handling applies.
+ if (hasNativeWebSearchTool(body)) {
+ const wsSettings = await getCachedSettings().catch(() => ({}) as Record);
+ const wsRoute = resolveWebSearchRouteOverride(resolvedModelStr, body, wsSettings);
+ if (wsRoute.wasRouted) {
+ log.info(
+ "WEBSEARCH-ROUTE",
+ `web_search tool → model override: ${resolvedModelStr} → ${wsRoute.model}`
+ );
+ resolvedModelStr = wsRoute.model;
+ body = { ...body, model: wsRoute.model };
+ }
+ }
+
// ── Zero-Config Auto-Routing (auto and auto/ prefix) ────────────────────────
// If the model ID is "auto" or starts with "auto/", bypass DB combo lookup
// entirely and generate a virtual auto-combo on-the-fly from connected providers.
diff --git a/tests/unit/web-search-tool-routing-4481.test.ts b/tests/unit/web-search-tool-routing-4481.test.ts
new file mode 100644
index 0000000000..d5bfb18916
--- /dev/null
+++ b/tests/unit/web-search-tool-routing-4481.test.ts
@@ -0,0 +1,151 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+// #4481 layer 2 — CCR-style `Router.webSearch`. When a request carries a NATIVE
+// web-search server tool (`web_search`, `web_search_preview`, or Anthropic's versioned
+// `web_search_20250305`) and an operator configured `webSearchRouteModel`, route the
+// whole request to that model instead of the default — so a provider that doesn't
+// implement the server tool (e.g. MiniMax) isn't asked to run a tool it 400s on.
+// Pure helpers, no DB.
+
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+
+import {
+ hasNativeWebSearchTool,
+ resolveWebSearchRouteOverride,
+} from "../../open-sse/services/webSearchRouting.ts";
+
+const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
+
+// ── Wiring source-guard (RED on base: chat.ts doesn't call the router yet) ─
+
+test("chat.ts wires the web-search router at the request entrypoint", () => {
+ const chat = readFileSync(join(REPO_ROOT, "src/sse/handlers/chat.ts"), "utf8");
+ assert.match(chat, /from "@omniroute\/open-sse\/services\/webSearchRouting\.ts"/);
+ assert.match(chat, /hasNativeWebSearchTool\(body\)/);
+ assert.match(chat, /resolveWebSearchRouteOverride\(/);
+});
+
+test("webSearchRouteModel is registered in the settings Zod schema", () => {
+ const schema = readFileSync(join(REPO_ROOT, "src/shared/validation/settingsSchemas.ts"), "utf8");
+ assert.match(schema, /webSearchRouteModel:\s*z\.string\(\)/);
+});
+
+test("the Routing settings tab exposes a webSearchRouteModel field", () => {
+ const tab = readFileSync(
+ join(REPO_ROOT, "src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx"),
+ "utf8"
+ );
+ assert.match(tab, /settings\.webSearchRouteModel/);
+ assert.match(tab, /updateSetting\(\{ webSearchRouteModel:/);
+ assert.match(tab, /webSearchRouteTitle/);
+});
+
+test("en.json defines the web-search-routing UI strings", () => {
+ const en = JSON.parse(readFileSync(join(REPO_ROOT, "src/i18n/messages/en.json"), "utf8"));
+ const s = en.settings || {};
+ assert.equal(typeof s.webSearchRouteTitle, "string");
+ assert.equal(typeof s.webSearchRouteDesc, "string");
+ assert.equal(typeof s.webSearchRoutePlaceholder, "string");
+});
+
+// ── hasNativeWebSearchTool ───────────────────────────────────────────────
+
+test("detects the plain and preview native web-search tool types", () => {
+ assert.equal(hasNativeWebSearchTool({ tools: [{ type: "web_search" }] }), true);
+ assert.equal(hasNativeWebSearchTool({ tools: [{ type: "web_search_preview" }] }), true);
+});
+
+test("detects Anthropic's versioned web_search_20250305 (and future dated names)", () => {
+ assert.equal(
+ hasNativeWebSearchTool({ tools: [{ type: "web_search_20250305", name: "web_search" }] }),
+ true
+ );
+ assert.equal(hasNativeWebSearchTool({ tools: [{ type: "web_search_20251201" }] }), true);
+});
+
+test("detects a native web-search tool anywhere in a mixed tools array", () => {
+ assert.equal(
+ hasNativeWebSearchTool({
+ tools: [{ type: "function", function: { name: "x" } }, { type: "web_search_20250305" }],
+ }),
+ true
+ );
+});
+
+test("ignores a custom FUNCTION tool merely named web_search (has a function field)", () => {
+ assert.equal(
+ hasNativeWebSearchTool({
+ tools: [{ type: "function", function: { name: "web_search", parameters: {} } }],
+ }),
+ false
+ );
+ // A bare web_search type WITH a function field is also not the native server tool.
+ assert.equal(
+ hasNativeWebSearchTool({ tools: [{ type: "web_search", function: { name: "x" } }] }),
+ false
+ );
+});
+
+test("returns false for no tools / non-web-search tools / malformed input", () => {
+ assert.equal(hasNativeWebSearchTool({ tools: [] }), false);
+ assert.equal(hasNativeWebSearchTool({ tools: [{ type: "code_interpreter" }] }), false);
+ assert.equal(hasNativeWebSearchTool({}), false);
+ assert.equal(hasNativeWebSearchTool({ tools: "nope" }), false);
+ assert.equal(hasNativeWebSearchTool(null), false);
+ assert.equal(hasNativeWebSearchTool(undefined), false);
+});
+
+// ── resolveWebSearchRouteOverride ────────────────────────────────────────
+
+const bodyWithSearch = { tools: [{ type: "web_search_20250305", name: "web_search" }] };
+
+test("routes to the configured model when a native web-search tool is present", () => {
+ const r = resolveWebSearchRouteOverride("minimax,MiniMax-M3", bodyWithSearch, {
+ webSearchRouteModel: "openrouter,anthropic/claude-3.5-sonnet",
+ });
+ assert.deepEqual(r, { wasRouted: true, model: "openrouter,anthropic/claude-3.5-sonnet" });
+});
+
+test("does NOT route when the request has no native web-search tool", () => {
+ const r = resolveWebSearchRouteOverride("minimax,MiniMax-M3", { tools: [{ type: "function" }] }, {
+ webSearchRouteModel: "openrouter,anthropic/claude-3.5-sonnet",
+ });
+ assert.deepEqual(r, { wasRouted: false, model: "minimax,MiniMax-M3" });
+});
+
+test("does NOT route when no route model is configured (or it is blank)", () => {
+ assert.deepEqual(resolveWebSearchRouteOverride("minimax,MiniMax-M3", bodyWithSearch, {}), {
+ wasRouted: false,
+ model: "minimax,MiniMax-M3",
+ });
+ assert.deepEqual(
+ resolveWebSearchRouteOverride("minimax,MiniMax-M3", bodyWithSearch, {
+ webSearchRouteModel: " ",
+ }),
+ { wasRouted: false, model: "minimax,MiniMax-M3" }
+ );
+});
+
+test("does NOT route (no-op) when the configured model equals the current model", () => {
+ const r = resolveWebSearchRouteOverride("openrouter,claude", bodyWithSearch, {
+ webSearchRouteModel: " openrouter,claude ",
+ });
+ assert.deepEqual(r, { wasRouted: false, model: "openrouter,claude" });
+});
+
+test("trims the configured route model", () => {
+ const r = resolveWebSearchRouteOverride("minimax,M3", bodyWithSearch, {
+ webSearchRouteModel: " anthropic/claude-opus-4-8 ",
+ });
+ assert.deepEqual(r, { wasRouted: true, model: "anthropic/claude-opus-4-8" });
+});
+
+test("ignores a non-string route config", () => {
+ const r = resolveWebSearchRouteOverride("minimax,M3", bodyWithSearch, {
+ webSearchRouteModel: 123 as unknown as string,
+ });
+ assert.deepEqual(r, { wasRouted: false, model: "minimax,M3" });
+});