feat(routing): per-model web-search interception rule (#3384)

Adds a per-provider/per-model interceptSearch rule (src/lib/db/interceptionRules.ts,
key_value namespace interception_rules) that overrides the existing native
web-search bypass defaults (Codex/Gemini/Claude->Claude passthrough) in
webSearchFallback.ts. Wired at the existing prepareWebSearchFallbackBody() call
site in chatCore.ts. Resolution precedence: per-model rule > provider-level rule
> existing native-bypass defaults.

This lands Phase 1-2 of the plan (rule store + search interception). Web-fetch
interception and the dashboard UI toggle are tracked as follow-up phases.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-10 09:18:17 -03:00
parent 63fb1cbfa6
commit 2ef38703ee
7 changed files with 389 additions and 0 deletions

View File

@@ -13,6 +13,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral
### ✨ New Features
- **Provider/model param filters**: config-driven parameter denylist/allowlist per provider/model with auto-learn from upstream 400s (#6649 — thanks @ThongAccount, closes #6625)
- **Per-model web-search interception rule (Phase 1/2)**: a new `interceptSearch` rule, resolvable per provider or per model (`src/lib/db/interceptionRules.ts`, `key_value` namespace `interception_rules`), now overrides the existing native web-search bypass defaults (Codex/Gemini/Claude→Claude passthrough) — set `interceptSearch:true` to force a model's provider-native web-search tool call through OmniRoute's own `/v1/search` fallback even when it would otherwise be forwarded natively, or `interceptSearch:false` to force native passthrough for a model that would otherwise be converted. Per-model rule takes precedence over the provider-level rule, which takes precedence over the existing defaults when unset. Wired at the existing `prepareWebSearchFallbackBody()` call site in `chatCore.ts`. Web-fetch interception + the dashboard UI toggle are tracked as follow-up phases. Regression guard: `tests/unit/interception-rules.test.ts`, extended `tests/unit/web-search-fallback-format.test.ts` (#3384 — thanks @thomasmaerz)
- **Per-combo reasoning token buffer toggle**: the combo builder now exposes an explicit checkbox for the `#3587` reasoning-model `max_tokens` buffer, defaulting to the existing enabled behavior, so a combo can opt out without hand-editing raw JSON config (#6702 — thanks @xz-dev)
- **feat(dashboard):** 9router-parity **Routing Strategy** settings card on Settings → Routing, plus a per-provider account-routing override on the provider detail page ([#6678](https://github.com/diegosouzapw/OmniRoute/pull/6678)) — surfaces the existing account round-robin / sticky-limit knobs and adds a new combo-level sticky round-robin (`comboStickyRoundRobinLimit`, resolved via `resolveComboStickyRoundRobinLimit()` — per-combo → global combo sticky → account sticky cascade) so combo targets can batch calls per target the same way account fallback already does. A new `providerStrategies` setting (Zod-validated map, `src/shared/validation/settingsSchemas.ts`) lets a specific provider override the global `fallbackStrategy`/`stickyRoundRobinLimit` without touching the account-wide default, wired into `getProviderCredentials()` (`src/sse/services/auth.ts`) ahead of the global fallback. Regression guard: `tests/unit/combo-rr-sticky-9router.test.ts`, `tests/unit/settings-ui-layout-static.test.ts`. (thanks @SeaXen)
- **feat(icons):** provider logos now resolve local SVG assets first for faster rendering, with a 5-tier fallback chain — local SVG → `@lobehub/icons` React components → `thesvg.org` CDN (external SVG for unknown providers) → local PNG → generic AI icon — replacing the previous LobeHub-first order. Adds dozens of first-party provider SVGs and migrates several bitmap logos (continue/copilot/cursor/deepgram/heroku/openclaw/ovhcloud) from PNG to SVG. Regression guard: `tests/unit/ui/ProviderIcon-icon-url.test.tsx`. ([#6317](https://github.com/diegosouzapw/OmniRoute/pull/6317) — thanks @hamsa0x7)

View File

@@ -296,6 +296,7 @@ import {
import { resolveBackgroundTaskRedirect } from "./chatCore/backgroundRedirect.ts";
import type { CompressionConfig, CompressionPipelineStep } from "../services/compression/types.ts";
import { prepareWebSearchFallbackBody } from "../services/webSearchFallback.ts";
import { resolveInterceptSearch } from "@/lib/db/interceptionRules";
import {
resolveExplicitStreamAlias,
resolveStreamFlag,
@@ -731,12 +732,17 @@ export async function handleChatCore({
// Initialize rate limit settings from persisted DB (once, lazy)
await initializeRateLimits();
// #3384: per-model interception rule (src/lib/db/interceptionRules.ts) overrides the
// native-bypass defaults below when the operator explicitly configured it for this
// provider/model pair; undefined falls through to the existing bypass logic.
const interceptSearchOverride = resolveInterceptSearch(provider, effectiveModel);
const { body: bodyWithWebSearchFallback, fallback: webSearchFallbackPlan } =
prepareWebSearchFallbackBody(body as Record<string, unknown>, {
provider,
sourceFormat,
targetFormat,
nativeCodexPassthrough,
interceptSearchOverride,
});
if (webSearchFallbackPlan.enabled) {
body = bodyWithWebSearchFallback as typeof body;

View File

@@ -142,12 +142,20 @@ export function supportsNativeWebSearchFallbackBypass({
sourceFormat,
targetFormat,
nativeCodexPassthrough,
interceptSearchOverride,
}: {
provider?: string | null;
sourceFormat?: string | null;
targetFormat: string | null | undefined;
nativeCodexPassthrough: boolean;
// Per-model rule (#3384) — resolveInterceptSearch() in src/lib/db/interceptionRules.ts.
// true = force interception (never bypass); false = force native bypass; undefined =
// fall through to the native-bypass defaults below.
interceptSearchOverride?: boolean;
}): boolean {
if (typeof interceptSearchOverride === "boolean") {
return !interceptSearchOverride;
}
// Native Codex (OpenAI Responses) passthrough: the upstream runs web search itself.
if (nativeCodexPassthrough) return true;
// Gemini target: the Gemini translator maps built-in web search to googleSearch natively.
@@ -171,6 +179,7 @@ export function prepareWebSearchFallbackBody<T extends JsonRecord>(
sourceFormat?: string | null;
targetFormat?: string | null;
nativeCodexPassthrough: boolean;
interceptSearchOverride?: boolean;
}
): { body: T; fallback: WebSearchFallbackPlan } {
const tools = Array.isArray(body.tools) ? body.tools : null;

View File

@@ -0,0 +1,203 @@
/**
* db/interceptionRules.ts — Per-model web-search / web-fetch interception rules (#3384).
*
* CRUD against the key_value table under namespace "interception_rules". Follows the
* established key_value pattern from paramFilters.ts / databaseSettings.ts.
*
* Resolution precedence (see resolveInterceptSearch): per-model rule > provider-level
* rule > undefined (caller falls back to the existing native-bypass defaults).
*/
import { getDbInstance } from "./core";
const NAMESPACE = "interception_rules";
// ── Types ───────────────────────────────────────────────────────────────────
export type FetchInterceptionBackend = "firecrawl" | "jina" | "tavily";
export interface ModelInterceptionRule {
/** true = route through OmniRoute's /v1/search; false = force native passthrough. */
interceptSearch?: boolean;
/** true = route through OmniRoute's /v1/web/fetch; false = force native passthrough. */
interceptFetch?: boolean;
fetchBackend?: FetchInterceptionBackend;
fetchProxyUrl?: string;
}
export interface ProviderInterceptionRules {
/** Provider-level default, used when a model has no override. */
interceptSearch?: boolean;
interceptFetch?: boolean;
fetchBackend?: FetchInterceptionBackend;
fetchProxyUrl?: string;
/** Per-model overrides (stricter/looser than provider-level). */
models?: Record<string, ModelInterceptionRule>;
}
// ── Cache ───────────────────────────────────────────────────────────────────
let rulesCache: Map<string, ProviderInterceptionRules> | null = null;
function invalidateCache(): void {
rulesCache = null;
}
// ── Helpers ─────────────────────────────────────────────────────────────────
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function toNormalizedString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function toOptionalBool(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
function toFetchBackend(value: unknown): FetchInterceptionBackend | undefined {
return value === "firecrawl" || value === "jina" || value === "tavily" ? value : undefined;
}
function parseStoredValue(raw: unknown): unknown {
if (typeof raw !== "string") return raw;
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
function toModelInterceptionRule(raw: unknown): ModelInterceptionRule | null {
if (!isRecord(raw)) return null;
const rule: ModelInterceptionRule = {
interceptSearch: toOptionalBool(raw.interceptSearch),
interceptFetch: toOptionalBool(raw.interceptFetch),
fetchBackend: toFetchBackend(raw.fetchBackend),
fetchProxyUrl: toNormalizedString(raw.fetchProxyUrl) ?? undefined,
};
const hasAnyField = Object.values(rule).some((v) => v !== undefined);
return hasAnyField ? rule : null;
}
function toModelInterceptionRules(raw: unknown): Record<string, ModelInterceptionRule> {
const models: Record<string, ModelInterceptionRule> = {};
if (!isRecord(raw)) return models;
for (const [modelId, val] of Object.entries(raw)) {
const rule = toModelInterceptionRule(val);
if (rule) models[modelId] = rule;
}
return models;
}
function toProviderInterceptionRules(raw: unknown): ProviderInterceptionRules | null {
if (!isRecord(raw)) return null;
const models = toModelInterceptionRules(raw.models);
return {
interceptSearch: toOptionalBool(raw.interceptSearch),
interceptFetch: toOptionalBool(raw.interceptFetch),
fetchBackend: toFetchBackend(raw.fetchBackend),
fetchProxyUrl: toNormalizedString(raw.fetchProxyUrl) ?? undefined,
models: Object.keys(models).length > 0 ? models : undefined,
};
}
// ── Read ────────────────────────────────────────────────────────────────────
function readNamespace(namespace: string): Record<string, unknown> {
const db = getDbInstance();
const rows = db
.prepare("SELECT key, value FROM key_value WHERE namespace = ?")
.all(namespace) as Array<{ key: string; value: string }>;
const values: Record<string, unknown> = {};
for (const row of rows) {
values[row.key] = parseStoredValue(row.value);
}
return values;
}
function loadAllRules(): Map<string, ProviderInterceptionRules> {
const raw = readNamespace(NAMESPACE);
const map = new Map<string, ProviderInterceptionRules>();
for (const [key, value] of Object.entries(raw)) {
const parsed = toProviderInterceptionRules(value);
if (parsed) map.set(key, parsed);
}
return map;
}
function loadRulesCached(): Map<string, ProviderInterceptionRules> {
if (rulesCache === null) {
rulesCache = loadAllRules();
}
return rulesCache;
}
// ── Public API ──────────────────────────────────────────────────────────────
/** Get the interception rules for a single provider, or null if not configured. */
export function getInterceptionRules(provider: string): ProviderInterceptionRules | null {
return toNormalizedString(provider) ? (loadRulesCached().get(provider) ?? null) : null;
}
/** Upsert the entire interception rule set for a provider. Invalidates the cache. */
export function setInterceptionRules(provider: string, rules: ProviderInterceptionRules): void {
const normalizedProvider = toNormalizedString(provider);
if (!normalizedProvider) return;
const db = getDbInstance();
const stmt = db.prepare(
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)"
);
const normalized: ProviderInterceptionRules = {
interceptSearch: rules.interceptSearch,
interceptFetch: rules.interceptFetch,
fetchBackend: rules.fetchBackend,
fetchProxyUrl: rules.fetchProxyUrl,
models: rules.models && Object.keys(rules.models).length > 0 ? rules.models : undefined,
};
stmt.run(NAMESPACE, normalizedProvider, JSON.stringify(normalized));
invalidateCache();
}
/** Delete the interception rules for a provider. Resets that provider to default behavior. */
export function deleteInterceptionRules(provider: string): void {
const normalizedProvider = toNormalizedString(provider);
if (!normalizedProvider) return;
const db = getDbInstance();
db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run(
NAMESPACE,
normalizedProvider
);
invalidateCache();
}
/**
* Resolve the effective `interceptSearch` override for a provider/model pair.
*
* Precedence: per-model rule > provider-level rule > undefined (no override — the
* caller should fall back to the existing native-bypass defaults).
*/
export function resolveInterceptSearch(
provider: string | null | undefined,
model: string | null | undefined
): boolean | undefined {
const normalizedProvider = toNormalizedString(provider);
if (!normalizedProvider) return undefined;
const rules = getInterceptionRules(normalizedProvider);
if (!rules) return undefined;
const normalizedModel = toNormalizedString(model);
if (normalizedModel && rules.models?.[normalizedModel]?.interceptSearch !== undefined) {
return rules.models[normalizedModel].interceptSearch;
}
return rules.interceptSearch;
}

View File

@@ -0,0 +1,16 @@
-- 119_interception_rules.sql
-- Documents the interception_rules namespace in the key_value table (#3384).
-- No schema change — the key_value table already exists.
-- Key = provider ID, value = JSON with shape:
-- {
-- interceptSearch?: boolean,
-- interceptFetch?: boolean,
-- fetchBackend?: "firecrawl"|"jina"|"tavily",
-- fetchProxyUrl?: string,
-- models?: { [modelId]: { interceptSearch?, interceptFetch?, fetchBackend?, fetchProxyUrl? } }
-- }
--
-- Resolution precedence: per-model rule > provider-level rule > undefined (caller
-- falls back to the existing native web-search-bypass defaults in webSearchFallback.ts).
--
-- See: src/lib/db/interceptionRules.ts

View File

@@ -0,0 +1,96 @@
import { describe, it, beforeEach, after } from "node:test";
import assert from "node:assert/strict";
import os from "node:os";
import path from "node:path";
import fs from "node:fs";
// Set DATA_DIR to a temp dir before any imports that touch the DB.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-test-interception-rules-"));
process.env.DATA_DIR = tmpDir;
const core = await import("../../src/lib/db/core.ts");
const {
getInterceptionRules,
setInterceptionRules,
deleteInterceptionRules,
resolveInterceptSearch,
} = await import("../../src/lib/db/interceptionRules.ts");
// #3384 — per-model web-search/web-fetch interception rule store.
describe("db/interceptionRules — per-model interception rules (#3384)", () => {
function resetDb() {
core.resetDbInstance();
fs.rmSync(tmpDir, { recursive: true, force: true });
fs.mkdirSync(tmpDir, { recursive: true });
}
beforeEach(() => {
resetDb();
});
after(() => {
core.resetDbInstance();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("returns null for an unconfigured provider", () => {
assert.equal(getInterceptionRules("anthropic"), null);
});
it("round-trips a provider-level rule via set/get", () => {
setInterceptionRules("anthropic", { interceptSearch: true, interceptFetch: false });
const rules = getInterceptionRules("anthropic");
assert.equal(rules?.interceptSearch, true);
assert.equal(rules?.interceptFetch, false);
});
it("round-trips a per-model override", () => {
setInterceptionRules("anthropic", {
interceptSearch: false,
models: { "claude-opus-4": { interceptSearch: true } },
});
const rules = getInterceptionRules("anthropic");
assert.equal(rules?.interceptSearch, false);
assert.equal(rules?.models?.["claude-opus-4"]?.interceptSearch, true);
});
it("delete resets a provider back to unconfigured", () => {
setInterceptionRules("anthropic", { interceptSearch: true });
deleteInterceptionRules("anthropic");
assert.equal(getInterceptionRules("anthropic"), null);
});
it("invalidates the in-memory cache after a write", () => {
setInterceptionRules("openai", { interceptSearch: true });
assert.equal(getInterceptionRules("openai")?.interceptSearch, true);
setInterceptionRules("openai", { interceptSearch: false });
assert.equal(getInterceptionRules("openai")?.interceptSearch, false);
});
describe("resolveInterceptSearch — precedence", () => {
it("returns undefined when no rule is configured (caller falls back to bypass defaults)", () => {
assert.equal(resolveInterceptSearch("anthropic", "claude-opus-4"), undefined);
});
it("returns the provider-level rule when no model override exists", () => {
setInterceptionRules("anthropic", { interceptSearch: true });
assert.equal(resolveInterceptSearch("anthropic", "claude-opus-4"), true);
assert.equal(resolveInterceptSearch("anthropic", "claude-haiku-4"), true);
});
it("per-model rule overrides the provider-level rule", () => {
setInterceptionRules("anthropic", {
interceptSearch: false,
models: { "claude-opus-4": { interceptSearch: true } },
});
assert.equal(resolveInterceptSearch("anthropic", "claude-opus-4"), true);
assert.equal(resolveInterceptSearch("anthropic", "claude-haiku-4"), false);
});
it("returns undefined for an empty/missing provider", () => {
assert.equal(resolveInterceptSearch("", "claude-opus-4"), undefined);
assert.equal(resolveInterceptSearch(null, "claude-opus-4"), undefined);
assert.equal(resolveInterceptSearch(undefined, "claude-opus-4"), undefined);
});
});
});

View File

@@ -255,3 +255,61 @@ test("OpenAI -> Claude (non-passthrough): built-in web_search IS still converted
const toolNames = tools.map((t) => (t.function ? t.function.name : t.name));
assert.ok(toolNames.includes(OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME));
});
// ── #3384: per-model interceptSearch override wins over every native-bypass default ──
test("#3384 interceptSearchOverride=true forces interception even on the Claude->Claude bypass path", () => {
assert.equal(
supportsNativeWebSearchFallbackBypass({
provider: "claude",
sourceFormat: "claude",
targetFormat: "claude",
nativeCodexPassthrough: false,
interceptSearchOverride: true,
}),
false,
"explicit interceptSearch:true must NOT bypass, overriding the native Claude passthrough"
);
});
test("#3384 interceptSearchOverride=false forces native passthrough even for a standard provider", () => {
assert.equal(
supportsNativeWebSearchFallbackBypass({
provider: "openai",
sourceFormat: "openai",
targetFormat: "openai",
nativeCodexPassthrough: false,
interceptSearchOverride: false,
}),
true,
"explicit interceptSearch:false must bypass even though OpenAI->OpenAI has no native default bypass"
);
});
test("#3384 interceptSearchOverride=undefined falls through to the existing native-bypass defaults", () => {
assert.equal(
supportsNativeWebSearchFallbackBypass({
provider: "claude",
sourceFormat: "claude",
targetFormat: "claude",
nativeCodexPassthrough: false,
interceptSearchOverride: undefined,
}),
true,
"no override configured — default Claude->Claude bypass still applies"
);
});
test("#3384 end-to-end: interceptSearchOverride=true converts the tool on the Claude->Claude bypass path", () => {
const inputBody = { tools: [{ type: "web_search" }] };
const { fallback } = prepareWebSearchFallbackBody(inputBody, {
provider: "claude",
sourceFormat: "claude",
targetFormat: "claude",
nativeCodexPassthrough: false,
interceptSearchOverride: true,
});
assert.equal(fallback.enabled, true);
assert.equal(fallback.toolName, OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME);
});