From 185c474ee0467a42b4205e184bf9113fc1aa91e0 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 21 Jun 2026 14:02:29 -0300 Subject: [PATCH] feat(sse): route web_search requests to a configured model (#4509, #4481) Routes requests carrying a web_search tool to a configured webSearchRouteModel (#4481 layer-2). Integrated into release/v3.8.33. --- config/quality/file-size-baseline.json | 6 +- open-sse/services/webSearchRouting.ts | 68 ++++++++ .../settings/components/RoutingTab.tsx | 35 ++++ src/i18n/messages/en.json | 3 + src/shared/validation/settingsSchemas.ts | 5 + src/sse/handlers/chat.ts | 22 +++ .../unit/web-search-tool-routing-4481.test.ts | 151 ++++++++++++++++++ 7 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 open-sse/services/webSearchRouting.ts create mode 100644 tests/unit/web-search-tool-routing-4481.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 66db0357f0..57167393b8 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,6 +1,8 @@ { "_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.", "_rebaseline_2026_06_21_v3833_cycle_open_latent_filesize": "Abertura do ciclo v3.8.33: 4 arquivos cresceram no ciclo v3.8.32 sem bump de baseline e o drift escapou do fast-path do release (check:file-size não roda nas fast-gates p/ release/*, só no PR→main full CI, e o crescimento veio de commits entre fca66c644 e o head do merge 912239f46 — ex. #4475 targetFormat). Medido em origin/main (idêntico, cherry-picks deste ciclo NÃO tocam estes 4): open-sse/services/usage.ts 3408->3414, src/lib/db/core.ts 1820->1825, src/lib/usage/providerLimits.ts 949->950, src/shared/constants/providers.ts 3242->3243. Reconcílio ao valor real de main p/ abrir o .33 verde; shrink estrutural rastreado em #3501.", + "_rebaseline_2026_06_21_4481_websearch_routing_ui": "Feature #4481 layer 2 UI own growth: src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx 1594->1629 (+35 = one Settings → Routing with a text bound to the `webSearchRouteModel` setting, placed next to the adjacent echo/LKGP cards; value/onChange via updateSetting, i18n labels with inline English fallbacks). Presentational wiring for the setting shipped in the same PR (#4509); the routing logic itself lives in the already-tested pure leaf open-sse/services/webSearchRouting.ts. Cohesive next to the existing routing cards; not extractable. Covered by tests/unit/web-search-tool-routing-4481.test.ts (UI source-guard + en.json key presence).", + "_rebaseline_2026_06_21_4481_websearch_routing": "Feature #4481 layer 2 own growth: src/sse/handlers/chat.ts 1491->1513 (+22 = the CCR-style web-search routing hook at the request entrypoint, right after the T05 task-aware-routing block — a 5-line comment + one `if (hasNativeWebSearchTool(body)) { ... }` guard that reads getCachedSettings only when a web_search tool is present and overrides resolvedModelStr/body.model via the pure helper, plus a 4-line import). When a request carries a native web_search server tool and the operator set `webSearchRouteModel`, the whole request routes to that model instead of the default (some providers, e.g. MiniMax, don't implement Anthropic's web_search_20250305 server tool). The detection + override logic lives in the new pure leaf open-sse/services/webSearchRouting.ts (no DB, unit-testable); chat.ts is thin wiring mirroring the adjacent T05 override. Lands BEFORE auto/combo resolution + the layer-1 webSearchFallback so the target's own format/fallback handling applies. Not extractable further (it IS the entrypoint wiring). Covered by tests/unit/web-search-tool-routing-4481.test.ts. Structural shrink of this handler tracked separately.", "_rebaseline_2026_06_21_4483_auto_quota_cutoff": "PR #4483 (megamen32) own growth + review fix: open-sse/services/combo.ts 2611->2623 (+12). The PR adds an auto-routing hard quota cutoff in buildAutoCandidates (evaluateQuotaCutoff + buildAutoQuotaThresholds/clampPercent/asThresholdMap/quotaWindowLookupNames helpers) that drops low-quota candidates before scoring, plus a 429 guard when all candidates are below cutoff. On review (owner decision) the cutoff was made OPT-IN behind a new QuotaPreflightSettings.enabled flag (default OFF via QUOTA_PREFLIGHT_CUTOFF_ENABLED) so default behavior is unchanged, and the `...eligibleTargets` last-resort fallback the PR removed was restored (dedupe makes it identical to pre-cutoff when OFF; when ON a blocked target survives as final fallback rather than vanishing). Cohesive at the existing candidate-build/select chokepoints; not extractable. Covered by tests/unit/combo/auto-quota-cutoff.test.ts + tests/unit/resilience-settings-quota-preflight.test.ts (default-off + opt-in round-trip).", "_rebaseline_2026_06_21_4475_target_format_badge": "PR #4475 (adivekar-utexas) review: src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts 955->974 (+19). Extracted the pure targetFormatBadgeI18nKey (the 6 targetFormat value->i18n-key mapping) out of the CustomModelsSection.tsx badge so it is unit-testable outside the .tsx (Rule #18 gap — the PR had no UI test). The .tsx now calls the helper instead of an inline if-chain. This leaf is the strangler-fig home for pure provider-page helpers (#3501), so receiving the extraction is on-purpose. Covered by tests/unit/provider-target-format-badge-4475.test.ts.", "_rebaseline_2026_06_21_4427_low_noise_catalog": "PR #4427 (Rahulsharma0810) own growth: src/app/api/v1/models/catalog.ts 1478->1486 (+8). The opt-in MODELS_CATALOG_PREFIX_MODE (dual default | alias | canonical, with ?prefix= per-request override) gates the dual alias+canonical model emission at the three /v1/models push sites (static, synced, custom) behind includeAlias/includeCanonical, suppressing the duplicate cross-prefix entries (net +3 from the feature). On review, 4 incidental explanatory comments removed by the PR were restored (synced-models resolve, skip-static, try-block intent, strip-modelIdPrefix; +5) since their code is unchanged — useful docs on a non-trivial catalog function. Default `dual` keeps byte-identical output; request-side alias resolution unchanged. Structural shrink of this route tracked in #3789. Covered by tests/unit/models-catalog-low-noise-flag.test.ts.", @@ -163,7 +165,7 @@ "src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": 1012, "src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1089, "src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": 983, - "src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1594, + "src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1629, "src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1924, "src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": 1016, "src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148, @@ -199,7 +201,7 @@ "src/shared/constants/sidebarVisibility.ts": 1100, "src/shared/services/cliRuntime.ts": 1090, "src/shared/validation/schemas.ts": 2523, - "src/sse/handlers/chat.ts": 1491, + "src/sse/handlers/chat.ts": 1513, "src/sse/services/auth.ts": 2279 }, "testCap": 800, diff --git a/open-sse/services/webSearchRouting.ts b/open-sse/services/webSearchRouting.ts new file mode 100644 index 0000000000..f61fd85ad4 --- /dev/null +++ b/open-sse/services/webSearchRouting.ts @@ -0,0 +1,68 @@ +/** + * #4481 layer 2 — per-tool web-search model routing (CCR-style `Router.webSearch`). + * + * Some providers expose an Anthropic-compatible (Claude-format) endpoint but do NOT + * implement Anthropic's typed server tools, so forwarding `web_search_20250305` to them + * makes the upstream 400 (e.g. MiniMax: `invalid params ... (2013)`). Layer 1 + * (`webSearchFallback.ts`) already converts the tool to OmniRoute's own `/v1/search` + * fallback for such providers. Layer 2 is the alternative an operator can opt into: + * route the WHOLE request to a model that natively runs web search, the way + * claude-code-router's `Router.webSearch` does, while leaving every non-search request on + * the default model. + * + * Pure (no DB / no I/O) so it can be unit-tested and wired into the request entrypoint + * BEFORE combo/auto routing and before the layer-1 fallback is computed for the target. + */ + +interface WebSearchRouteResult { + wasRouted: boolean; + model: string; +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +/** + * True when the request body declares a NATIVE web-search server tool — `web_search`, + * `web_search_preview`, or any Anthropic dated variant (`web_search_20250305`, future + * `web_search_YYYYMMDD`). A custom *function* tool that merely happens to be named + * "web_search" (it carries a `function` field) is NOT a native server tool and is ignored. + * + * Uses a `web_search` prefix match on purpose: at the request entrypoint the client's tool + * is still in its raw form (Claude Code sends the versioned `web_search_20250305`), which + * the exact-set check in `webSearchFallback.ts` only sees after normalization. + */ +export function hasNativeWebSearchTool(body: unknown): boolean { + if (!body || typeof body !== "object") return false; + const tools = (body as { tools?: unknown }).tools; + if (!Array.isArray(tools)) return false; + return tools.some((tool) => { + if (!tool || typeof tool !== "object") return false; + const record = tool as { type?: unknown; function?: unknown }; + if (record.function) return false; // a custom function tool, not the server tool + return asString(record.type).startsWith("web_search"); + }); +} + +/** + * Resolve whether this request should be routed to the operator-configured web-search + * model. Routes only when (a) the request carries a native web-search tool, (b) + * `webSearchRouteModel` is a non-empty string, and (c) it differs from the current model + * (so re-entry on the already-chosen target is a no-op). The returned `model` is a model + * string (`provider,model` / `provider/model` / alias / combo name) resolved downstream + * by the normal routing pipeline. + */ +export function resolveWebSearchRouteOverride( + currentModel: string, + body: unknown, + settings: Record | null | undefined +): WebSearchRouteResult { + const fallthrough: WebSearchRouteResult = { wasRouted: false, model: currentModel }; + if (!hasNativeWebSearchTool(body)) return fallthrough; + + const configured = asString(settings?.webSearchRouteModel).trim(); + if (!configured || configured === currentModel) return fallthrough; + + return { wasRouted: true, model: configured }; +} diff --git a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx index daaff09cf3..a0860e29e6 100644 --- a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx @@ -1485,6 +1485,41 @@ export default function RoutingTab() { + {/* #4481 layer 2 — Web-Search Routing (CCR-style Router.webSearch) */} + +
+
+ +
+
+

+ {t("webSearchRouteTitle") || "Web search routing"} +

+

+ {t("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."} +

+
+ updateSetting({ webSearchRouteModel: e.target.value })} + placeholder={ + t("webSearchRoutePlaceholder") || "e.g. openrouter,anthropic/claude-3.5-sonnet" + } + disabled={loading} + aria-label={t("webSearchRouteTitle") || "Web search routing model"} + /> +
+
+
+
+
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" }); +});