diff --git a/CHANGELOG.md b/CHANGELOG.md index 64098b3fd6..51eb2fa7b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 676292a4c5..9875d5ccde 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -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, { provider, sourceFormat, targetFormat, nativeCodexPassthrough, + interceptSearchOverride, }); if (webSearchFallbackPlan.enabled) { body = bodyWithWebSearchFallback as typeof body; diff --git a/open-sse/services/webSearchFallback.ts b/open-sse/services/webSearchFallback.ts index f82b565630..9d80610d47 100644 --- a/open-sse/services/webSearchFallback.ts +++ b/open-sse/services/webSearchFallback.ts @@ -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( sourceFormat?: string | null; targetFormat?: string | null; nativeCodexPassthrough: boolean; + interceptSearchOverride?: boolean; } ): { body: T; fallback: WebSearchFallbackPlan } { const tools = Array.isArray(body.tools) ? body.tools : null; diff --git a/src/lib/db/interceptionRules.ts b/src/lib/db/interceptionRules.ts new file mode 100644 index 0000000000..faff5a1fc3 --- /dev/null +++ b/src/lib/db/interceptionRules.ts @@ -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; +} + +// ── Cache ─────────────────────────────────────────────────────────────────── + +let rulesCache: Map | null = null; + +function invalidateCache(): void { + rulesCache = null; +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function isRecord(value: unknown): value is Record { + 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 { + const models: Record = {}; + 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 { + 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 = {}; + for (const row of rows) { + values[row.key] = parseStoredValue(row.value); + } + return values; +} + +function loadAllRules(): Map { + const raw = readNamespace(NAMESPACE); + const map = new Map(); + for (const [key, value] of Object.entries(raw)) { + const parsed = toProviderInterceptionRules(value); + if (parsed) map.set(key, parsed); + } + return map; +} + +function loadRulesCached(): Map { + 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; +} diff --git a/src/lib/db/migrations/119_interception_rules.sql b/src/lib/db/migrations/119_interception_rules.sql new file mode 100644 index 0000000000..1a60288fa5 --- /dev/null +++ b/src/lib/db/migrations/119_interception_rules.sql @@ -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 diff --git a/tests/unit/interception-rules.test.ts b/tests/unit/interception-rules.test.ts new file mode 100644 index 0000000000..bad1e0a281 --- /dev/null +++ b/tests/unit/interception-rules.test.ts @@ -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); + }); + }); +}); diff --git a/tests/unit/web-search-fallback-format.test.ts b/tests/unit/web-search-fallback-format.test.ts index a7e038c855..39835e01dc 100644 --- a/tests/unit/web-search-fallback-format.test.ts +++ b/tests/unit/web-search-fallback-format.test.ts @@ -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); +});