Files
OmniRoute/src/lib/db/interceptionRules.ts
Diego Rodrigues de Sa e Souza a95da4a902 feat(routing): wire interceptFetch tool interception into the chat pipeline (#7339) (#7736)
Phases 3-4 of #3384 (Phases 1-2 shipped DB schema + resolveInterceptSearch in
release/v3.8.47). Adds resolveInterceptFetch(provider, model) as a structural
twin of resolveInterceptSearch, and open-sse/services/webFetchInterception.ts
(mirroring webSearchFallback.ts) to rewrite a provider-native web_fetch tool
declaration into a synthetic omniroute_web_fetch function tool. The synthetic
tool call is dispatched through the existing handleToolCallExecution path
(same as omniroute_web_search) to a new web_fetch builtin skill handler that
resolves credentials and calls handleWebFetch() against /v1/web/fetch.

Strictly opt-in: with no interceptFetch DB row configured (the default), the
outgoing request body is byte-identical to pre-change behavior — no heuristic
default bypass like the interceptSearch sibling, to guarantee zero overhead
when disabled (Hard Rule #20).

Also ships the dashboard toggle (owner decision, overriding the plan's
backend-only recommendation): ProviderInterceptionSection.tsx on the provider
detail page, backed by GET/PUT/DELETE /api/providers/[id]/interception-rules,
covering both interceptSearch and interceptFetch from one control.

chatCore.ts touch is minimal (frozen file): one resolver call + one
prepareWebFetchFallbackBody call mirroring the existing interceptSearch block,
plus threading provider/model into the existing handleToolCallExecution call.
2026-07-19 14:37:30 -03:00

229 lines
8.6 KiB
TypeScript

/**
* 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;
}
/**
* Resolve the effective `interceptFetch` 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). Structural twin of
* resolveInterceptSearch above (#7339 / Phase 3 of #3384).
*/
export function resolveInterceptFetch(
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]?.interceptFetch !== undefined) {
return rules.models[normalizedModel].interceptFetch;
}
return rules.interceptFetch;
}