From a95da4a9026953d3c31b06295ef070ece846df19 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:37:30 -0300 Subject: [PATCH] feat(routing): wire interceptFetch tool interception into the chat pipeline (#7339) (#7736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../features/7339-intercept-fetch-wiring.md | 1 + open-sse/handlers/chatCore.ts | 27 ++- open-sse/services/webFetchInterception.ts | 184 ++++++++++++++++++ .../[id]/components/ProviderExtraPanels.tsx | 4 + .../ProviderInterceptionSection.tsx | 156 +++++++++++++++ .../[id]/interception-rules/route.ts | 75 +++++++ src/i18n/messages/en.json | 8 + src/i18n/messages/pt-BR.json | 8 + src/lib/db/interceptionRules.ts | 25 +++ src/lib/skills/builtins.ts | 42 ++++ src/lib/skills/interception.ts | 9 + src/lib/skills/types.ts | 4 +- src/lib/skills/webFetchExecution.ts | 128 ++++++++++++ src/shared/validation/schemas/provider.ts | 19 ++ tests/unit/chat-core-intercept-fetch.test.ts | 115 +++++++++++ tests/unit/intercept-fetch-resolver.test.ts | 63 ++++++ tests/unit/web-fetch-dispatch.test.ts | 178 +++++++++++++++++ tests/unit/web-fetch-interception.test.ts | 139 +++++++++++++ 18 files changed, 1182 insertions(+), 3 deletions(-) create mode 100644 changelog.d/features/7339-intercept-fetch-wiring.md create mode 100644 open-sse/services/webFetchInterception.ts create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/ProviderInterceptionSection.tsx create mode 100644 src/app/api/providers/[id]/interception-rules/route.ts create mode 100644 src/lib/skills/webFetchExecution.ts create mode 100644 tests/unit/chat-core-intercept-fetch.test.ts create mode 100644 tests/unit/intercept-fetch-resolver.test.ts create mode 100644 tests/unit/web-fetch-dispatch.test.ts create mode 100644 tests/unit/web-fetch-interception.test.ts diff --git a/changelog.d/features/7339-intercept-fetch-wiring.md b/changelog.d/features/7339-intercept-fetch-wiring.md new file mode 100644 index 0000000000..19865e32c0 --- /dev/null +++ b/changelog.d/features/7339-intercept-fetch-wiring.md @@ -0,0 +1 @@ +- feat(routing): wire `interceptFetch` into the chat pipeline — `resolveInterceptFetch(provider, model)` (a structural twin of `resolveInterceptSearch`) and the new `open-sse/services/webFetchInterception.ts` module rewrite a provider-native `web_fetch` tool declaration into a synthetic `omniroute_web_fetch` function tool, dispatched through the existing tool-call execution path to OmniRoute's own `/v1/web/fetch` instead of the upstream provider running it natively; strictly opt-in per provider/model (undefined/off leaves the request byte-identical to today), with a new dashboard toggle on the provider detail page covering both `interceptSearch` and `interceptFetch` (#7339, Phases 3-4 of #3384) diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index d717e50559..6070d61efd 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -314,7 +314,8 @@ import type { } from "../services/compression/types.ts"; import { generateSessionId } from "../services/sessionManager.ts"; import { prepareWebSearchFallbackBody } from "../services/webSearchFallback.ts"; -import { resolveInterceptSearch } from "@/lib/db/interceptionRules"; +import { prepareWebFetchFallbackBody } from "../services/webFetchInterception.ts"; +import { resolveInterceptSearch, resolveInterceptFetch } from "@/lib/db/interceptionRules"; import { resolveExplicitStreamAlias, resolveStreamFlag, @@ -769,6 +770,24 @@ export async function handleChatCore({ `Converted ${webSearchFallbackPlan.convertedToolCount} web_search tool(s) to OmniRoute fallback for ${provider}` ); } + // #7339: interceptFetch (Phase 3-4 of #3384) — same per-model rule + native-bypass + // pattern as interceptSearch directly above. + const interceptFetchOverride = resolveInterceptFetch(provider, effectiveModel); + const { body: bodyWithWebFetchFallback, fallback: webFetchFallbackPlan } = + prepareWebFetchFallbackBody(body as Record, { + provider, + sourceFormat, + targetFormat, + nativeCodexPassthrough, + interceptFetchOverride, + }); + if (webFetchFallbackPlan.enabled) { + body = bodyWithWebFetchFallback as typeof body; + log?.info?.( + "TOOLS", + `Converted ${webFetchFallbackPlan.convertedToolCount} web_fetch tool(s) to OmniRoute fallback for ${provider}` + ); + } const noLogEnabled = apiKeyInfo?.noLog === true; // Consolidate settings reads — fetch once, reuse throughout the request const settings = cachedSettings ?? (await getCachedSettings()); @@ -4088,7 +4107,9 @@ export async function handleChatCore({ const customSkillExecutionEnabled = Boolean(memoryOwnerId) && memorySettings?.skillsEnabled === true; - const builtinToolNames = webSearchFallbackPlan.toolName ? [webSearchFallbackPlan.toolName] : []; + const builtinToolNames = [webSearchFallbackPlan.toolName, webFetchFallbackPlan.toolName].filter( + (name): name is string => Boolean(name) + ); if (customSkillExecutionEnabled || builtinToolNames.length > 0) { const skillSessionId = pipelineSessionId; @@ -4101,6 +4122,8 @@ export async function handleChatCore({ requestId: skillRequestId, builtinToolNames, customSkillExecutionEnabled, + provider, + model: effectiveModel, } ); } diff --git a/open-sse/services/webFetchInterception.ts b/open-sse/services/webFetchInterception.ts new file mode 100644 index 0000000000..eb464110fe --- /dev/null +++ b/open-sse/services/webFetchInterception.ts @@ -0,0 +1,184 @@ +/** + * webFetchInterception.ts — provider-native `web_fetch` tool interception (#7339, + * Phase 3-4 of #3384). Structural twin of webSearchFallback.ts: pure request-body / + * tool-array transformation only, no HTTP fetch, no streaming/SSE, no abort-signal + * handling here. Rewrites a provider-native `web_fetch` tool declaration into a + * synthetic `omniroute_web_fetch` function tool; the actual `/v1/web/fetch` call + * happens later, once the model emits that synthetic tool call, through the existing + * generic tool-call execution path (@/lib/skills/interception::handleToolCallExecution). + */ + +import { FORMATS } from "../translator/formats.ts"; + +export const OMNIROUTE_WEB_FETCH_FALLBACK_TOOL_NAME = "omniroute_web_fetch"; +// "web_fetch" mirrors the Responses-API-style built-in tool type convention already +// used for web_search; "web_fetch_20250910" is Anthropic's dated server-tool type. +const WEB_FETCH_TOOL_TYPES = new Set(["web_fetch", "web_fetch_20250910"]); + +type JsonRecord = Record; + +export interface WebFetchFallbackPlan { + enabled: boolean; + toolName: string | null; + convertedToolCount: number; +} + +function toRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function isBuiltInWebFetchTool(tool: unknown): tool is JsonRecord { + const toolRecord = toRecord(tool); + const toolType = typeof toolRecord.type === "string" ? toolRecord.type : ""; + return WEB_FETCH_TOOL_TYPES.has(toolType) && !toolRecord.function; +} + +function isBuiltInWebFetchToolChoice(toolChoice: unknown): boolean { + const choice = toRecord(toolChoice); + const toolType = typeof choice.type === "string" ? choice.type : ""; + return WEB_FETCH_TOOL_TYPES.has(toolType); +} + +function buildFallbackParameters(): JsonRecord { + return { + type: "object", + additionalProperties: false, + properties: { + url: { + type: "string", + description: "The URL to fetch and extract content from.", + }, + format: { + type: "string", + enum: ["markdown", "html", "links", "screenshot"], + description: "Desired output format. Defaults to markdown.", + }, + include_metadata: { + type: "boolean", + description: "Whether to include page metadata (title, description) in the result.", + }, + }, + required: ["url"], + }; +} + +function buildFallbackTool(targetFormat?: string | null): JsonRecord { + const name = OMNIROUTE_WEB_FETCH_FALLBACK_TOOL_NAME; + const description = [ + "Fetch and extract the content of a specific URL.", + "Use this when the user references a URL or asks you to read or summarize a specific page.", + ].join(" "); + const parameters = buildFallbackParameters(); + + // Responses API expects FLAT function tools ({ type, name, parameters }), whereas + // Chat Completions expects NESTED ({ type, function: { name, parameters } }) — see + // the identical note in webSearchFallback.ts (issue #2390). + if (targetFormat === FORMATS.OPENAI_RESPONSES) { + return { type: "function", name, description, parameters }; + } + + return { + type: "function", + function: { name, description, parameters }, + }; +} + +export function supportsNativeWebFetchFallbackBypass({ + interceptFetchOverride, +}: { + provider?: string | null; + sourceFormat?: string | null; + targetFormat: string | null | undefined; + nativeCodexPassthrough: boolean; + // Per-model rule (#3384/#7339) — resolveInterceptFetch() in src/lib/db/interceptionRules.ts. + // true = force interception; anything else (false/undefined, i.e. no operator + // opt-in) = native passthrough. Unlike its interceptSearch sibling — which + // predates #3384's opt-in rule and mirrors the older native-bypass heuristics + // by default — web_fetch interception is BRAND NEW behavior (#7339), so it + // stays strictly opt-in with no heuristic default: a request with no + // interceptFetch DB row configured is byte-identical to pre-#7339 behavior + // (the tool array is never touched), per Hard Rule #20's "opt-in, never + // default-on" precedent and the zero-overhead-when-disabled requirement. + interceptFetchOverride?: boolean; +}): boolean { + return interceptFetchOverride !== true; +} + +export function prepareWebFetchFallbackBody( + body: T, + options: { + provider?: string | null; + sourceFormat?: string | null; + targetFormat?: string | null; + nativeCodexPassthrough: boolean; + interceptFetchOverride?: boolean; + } +): { body: T; fallback: WebFetchFallbackPlan } { + const tools = Array.isArray(body.tools) ? body.tools : null; + if (!tools || tools.length === 0) { + return { + body, + fallback: { enabled: false, toolName: null, convertedToolCount: 0 }, + }; + } + + const builtInFetchTools = tools.filter(isBuiltInWebFetchTool); + if (builtInFetchTools.length === 0) { + return { + body, + fallback: { enabled: false, toolName: null, convertedToolCount: 0 }, + }; + } + + if (supportsNativeWebFetchFallbackBypass(options)) { + return { + body, + fallback: { enabled: false, toolName: null, convertedToolCount: 0 }, + }; + } + + const toolNames = new Set(); + const preservedTools = tools.filter((tool) => { + if (isBuiltInWebFetchTool(tool)) return false; + const toolRecord = toRecord(tool); + const functionRecord = toRecord(toolRecord.function); + const name = + typeof functionRecord.name === "string" + ? functionRecord.name + : typeof toolRecord.name === "string" + ? toolRecord.name + : ""; + if (name.trim().length > 0) { + toolNames.add(name.trim()); + } + return true; + }); + + const isResponsesTarget = options.targetFormat === FORMATS.OPENAI_RESPONSES; + + if (!toolNames.has(OMNIROUTE_WEB_FETCH_FALLBACK_TOOL_NAME)) { + preservedTools.unshift(buildFallbackTool(options.targetFormat)); + } + + const nextBody: T = { + ...body, + tools: preservedTools as T["tools"], + }; + + if (isBuiltInWebFetchToolChoice(body.tool_choice)) { + nextBody.tool_choice = ( + isResponsesTarget + ? { type: "function", name: OMNIROUTE_WEB_FETCH_FALLBACK_TOOL_NAME } + : { type: "function", function: { name: OMNIROUTE_WEB_FETCH_FALLBACK_TOOL_NAME } } + ) as T["tool_choice"]; + } + + return { + body: nextBody, + fallback: { + enabled: true, + toolName: OMNIROUTE_WEB_FETCH_FALLBACK_TOOL_NAME, + convertedToolCount: builtInFetchTools.length, + }, + }; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderExtraPanels.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderExtraPanels.tsx index 05b318dca3..eba56f455e 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderExtraPanels.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderExtraPanels.tsx @@ -7,6 +7,7 @@ import ProviderPlaygroundPanel from "./ProviderPlaygroundPanel"; import ProviderParamFilterSection from "./ProviderParamFilterSection"; +import ProviderInterceptionSection from "./ProviderInterceptionSection"; export default function ProviderExtraPanels({ providerId }: { providerId: string }) { return ( @@ -16,6 +17,9 @@ export default function ProviderExtraPanels({ providerId }: { providerId: string {/* Param filters — denylist/allowlist config per provider/model (#6625) */} + + {/* Web search/fetch tool interception toggles (#3384/#7339) */} + ); } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderInterceptionSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderInterceptionSection.tsx new file mode 100644 index 0000000000..8e465bafba --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderInterceptionSection.tsx @@ -0,0 +1,156 @@ +"use client"; + +/** + * ProviderInterceptionSection — provider-level toggles for OmniRoute web + * search/fetch tool interception (#3384 Phases 1-2 shipped the DB schema + + * resolvers only; #7339 wires interceptFetch into the chat pipeline and adds + * this dashboard toggle, covering both interceptSearch and interceptFetch + * since they share one interception-rules row per provider). + * + * Renders a card on the provider detail page where operators opt a provider + * into routing its provider-native web_search / web_fetch tool calls through + * OmniRoute's own /v1/search and /v1/web/fetch endpoints instead of letting + * the upstream provider run them natively. Off (undefined) preserves today's + * native-bypass behavior exactly — this is purely additive opt-in. + */ + +import { useCallback, useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { useNotificationStore } from "@/store/notificationStore"; +import Toggle from "@/shared/components/Toggle"; + +interface ProviderInterceptionSectionProps { + providerId: string; +} + +interface InterceptionToggles { + interceptSearch: boolean; + interceptFetch: boolean; +} + +type Translate = (key: string, values?: Record) => string; + +const DEFAULT_TOGGLES: InterceptionToggles = { interceptSearch: false, interceptFetch: false }; + +async function fetchInterceptionToggles(providerId: string): Promise { + const res = await fetch(`/api/providers/${providerId}/interception-rules`); + const data = await res.json(); + return { + interceptSearch: data?.interceptSearch === true, + interceptFetch: data?.interceptFetch === true, + }; +} + +async function throwOnErrorResponse(res: Response): Promise { + if (res.ok) return; + const errData = await res.json().catch(() => ({})); + throw new Error(errData.error || `HTTP ${res.status}`); +} + +async function putInterceptionToggles( + providerId: string, + toggles: InterceptionToggles +): Promise { + const res = await fetch(`/api/providers/${providerId}/interception-rules`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(toggles), + }); + await throwOnErrorResponse(res); +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function useProviderInterceptionToggles(providerId: string, t: Translate) { + const notify = useNotificationStore(); + const [toggles, setToggles] = useState(DEFAULT_TOGGLES); + const [loading, setLoading] = useState(true); + const [savingKey, setSavingKey] = useState(null); + + const loadToggles = useCallback(async () => { + setLoading(true); + try { + setToggles(await fetchInterceptionToggles(providerId)); + } catch (err) { + notify.error(t("interceptionLoadError", { error: errorMessage(err) })); + } finally { + setLoading(false); + } + }, [providerId, notify, t]); + + useEffect(() => { + loadToggles(); + }, [loadToggles]); + + const handleToggle = useCallback( + async (key: keyof InterceptionToggles, value: boolean) => { + const next = { ...toggles, [key]: value }; + setSavingKey(key); + try { + await putInterceptionToggles(providerId, next); + setToggles(next); + } catch (err) { + notify.error(t("interceptionSaveError", { error: errorMessage(err) })); + } finally { + setSavingKey(null); + } + }, + [providerId, toggles, notify, t] + ); + + return { toggles, loading, savingKey, handleToggle }; +} + +function InterceptionSectionSkeleton() { + return ( +
+
+
+
+ ); +} + +export default function ProviderInterceptionSection({ + providerId, +}: ProviderInterceptionSectionProps) { + const t = useTranslations("providers"); + const { toggles, loading, savingKey, handleToggle } = useProviderInterceptionToggles( + providerId, + t + ); + + if (loading) { + return ; + } + + return ( +
+

+ {t("interceptionSectionTitle")} +

+

+ {t("interceptionSectionHint")} +

+
+ handleToggle("interceptSearch", value)} + label={t("interceptSearchLabel")} + description={t("interceptSearchHint")} + /> + handleToggle("interceptFetch", value)} + label={t("interceptFetchLabel")} + description={t("interceptFetchHint")} + /> +
+
+ ); +} diff --git a/src/app/api/providers/[id]/interception-rules/route.ts b/src/app/api/providers/[id]/interception-rules/route.ts new file mode 100644 index 0000000000..0c12336e92 --- /dev/null +++ b/src/app/api/providers/[id]/interception-rules/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from "next/server"; +import { + getInterceptionRules, + setInterceptionRules, + deleteInterceptionRules, +} from "@/lib/db/interceptionRules"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { updateInterceptionRulesSchema } from "@/shared/validation/schemas"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +/** + * GET /api/providers/[id]/interception-rules + * Returns the web search/fetch interception rules for a provider, or the + * all-undefined default when not configured (#3384/#7339). + */ +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const { id } = await params; + const config = getInterceptionRules(id); + return NextResponse.json(config ?? { interceptSearch: undefined, interceptFetch: undefined }); + } catch (error) { + return NextResponse.json(buildErrorBody(500, sanitizeErrorMessage(error)), { status: 500 }); + } +} + +/** + * PUT /api/providers/[id]/interception-rules + * Upsert the interception rules for a provider. + * Body: { interceptSearch?, interceptFetch?, fetchBackend?, fetchProxyUrl?, models? } + */ +export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json(buildErrorBody(400, "Invalid JSON body"), { status: 400 }); + } + + try { + const { id } = await params; + const validation = validateBody(updateInterceptionRulesSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + setInterceptionRules(id, validation.data); + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json(buildErrorBody(500, sanitizeErrorMessage(error)), { status: 500 }); + } +} + +/** + * DELETE /api/providers/[id]/interception-rules + * Remove the interception rules for a provider (reset to native-bypass defaults). + */ +export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const { id } = await params; + deleteInterceptionRules(id); + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json(buildErrorBody(500, sanitizeErrorMessage(error)), { status: 500 }); + } +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index e404babdf2..416222b557 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4387,6 +4387,14 @@ "paramFiltersSaveError": "Failed to save param filter config: {error}", "paramFiltersResetSuccess": "Param filter config reset to defaults", "paramFiltersResetError": "Failed to reset param filter config: {error}", + "interceptionSectionTitle": "Web Tool Interception", + "interceptionSectionHint": "Route this provider's native web_search / web_fetch tool calls through OmniRoute's own search and fetch endpoints instead of letting the provider run them natively. Off by default — existing behavior is unchanged.", + "interceptSearchLabel": "Intercept web_search", + "interceptSearchHint": "Rewrite native web_search tool calls to OmniRoute's /v1/search.", + "interceptFetchLabel": "Intercept web_fetch", + "interceptFetchHint": "Rewrite native web_fetch tool calls to OmniRoute's /v1/web/fetch.", + "interceptionLoadError": "Failed to load interception settings: {error}", + "interceptionSaveError": "Failed to save interception settings: {error}", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 3fe6698df4..4c6c95b411 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -4381,6 +4381,14 @@ "paramFiltersSaveError": "Falha ao salvar configuração de filtro de parâmetros: {error}", "paramFiltersResetSuccess": "Configuração de filtro de parâmetros restaurada ao padrão", "paramFiltersResetError": "Falha ao restaurar configuração de filtro de parâmetros: {error}", + "interceptionSectionTitle": "Interceptação de Ferramentas Web", + "interceptionSectionHint": "Roteia as chamadas nativas de web_search / web_fetch deste provedor pelos próprios endpoints de busca e fetch da OmniRoute, em vez de deixar o provedor executá-las nativamente. Desativado por padrão — o comportamento atual não muda.", + "interceptSearchLabel": "Interceptar web_search", + "interceptSearchHint": "Reescreve chamadas nativas de web_search para /v1/search da OmniRoute.", + "interceptFetchLabel": "Interceptar web_fetch", + "interceptFetchHint": "Reescreve chamadas nativas de web_fetch para /v1/web/fetch da OmniRoute.", + "interceptionLoadError": "Falha ao carregar configuração de interceptação: {error}", + "interceptionSaveError": "Falha ao salvar configuração de interceptação: {error}", "contextWindowOverrideLabel": "Substituição de Janela de Contexto", "contextWindowOverridePlaceholder": "ex.: 131072", "contextWindowOverrideHint": "Define manualmente a janela de contexto real (tokens) deste modelo quando o provedor a reporta incorretamente. Tem prioridade sobre os valores detectados automaticamente/do catálogo e evita que o roteamento de combo descarte o modelo.", diff --git a/src/lib/db/interceptionRules.ts b/src/lib/db/interceptionRules.ts index faff5a1fc3..50a71891b5 100644 --- a/src/lib/db/interceptionRules.ts +++ b/src/lib/db/interceptionRules.ts @@ -201,3 +201,28 @@ export function resolveInterceptSearch( 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; +} diff --git a/src/lib/skills/builtins.ts b/src/lib/skills/builtins.ts index aec85ed255..ed367382cf 100644 --- a/src/lib/skills/builtins.ts +++ b/src/lib/skills/builtins.ts @@ -1,5 +1,6 @@ import { SkillHandler } from "./types"; import { executeWebSearch } from "@/lib/search/executeWebSearch"; +import { executeWebFetch } from "./webFetchExecution"; import { resolveDataDir } from "@/lib/dataPaths"; import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; import { sandboxRunner, type SandboxConfig } from "./sandbox"; @@ -386,6 +387,47 @@ export const builtinSkills: Record = { }; }, + web_fetch: async (input, context) => { + const { + url, + format, + depth, + wait_for_selector, + include_metadata, + provider, + } = input as { + url: string; + format?: "markdown" | "html" | "links" | "screenshot"; + depth?: 0 | 1 | 2; + wait_for_selector?: string; + include_metadata?: boolean; + provider?: string; + }; + if (!url || typeof url !== "string") { + throw new Error("Missing required field: url"); + } + const fetched = await executeWebFetch({ + url, + format, + depth, + wait_for_selector, + include_metadata, + provider, + ruleProvider: context.provider ?? null, + ruleModel: context.model ?? null, + }); + return { + success: true, + provider: fetched.provider, + url: fetched.url, + content: fetched.content, + links: fetched.links, + metadata: fetched.metadata, + screenshot_url: fetched.screenshot_url, + context: context.apiKeyId, + }; + }, + eval_code: async (input, context) => { const { code, diff --git a/src/lib/skills/interception.ts b/src/lib/skills/interception.ts index f026e3a6ab..79b33a8323 100644 --- a/src/lib/skills/interception.ts +++ b/src/lib/skills/interception.ts @@ -3,6 +3,7 @@ import { skillRegistry } from "./registry"; import { builtinSkills } from "./builtins"; import { detectProvider } from "./injection"; import { OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME } from "@omniroute/open-sse/services/webSearchFallback.ts"; +import { OMNIROUTE_WEB_FETCH_FALLBACK_TOOL_NAME } from "@omniroute/open-sse/services/webFetchInterception.ts"; import { logger } from "../../../open-sse/utils/logger.ts"; const log = logger("SKILLS_INTERCEPTION"); @@ -19,10 +20,16 @@ interface ExecutionContext { requestId: string; builtinToolNames?: string[]; customSkillExecutionEnabled?: boolean; + // #7339: threaded through to the web_fetch builtin so it can resolve a per-model + // pinned fetch backend (interceptionRules.fetchBackend). Optional — every other + // builtin/skill ignores these. + provider?: string; + model?: string; } const BUILTIN_TOOL_ALIASES: Record = { [OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME]: "web_search", + [OMNIROUTE_WEB_FETCH_FALLBACK_TOOL_NAME]: "web_fetch", }; function resolveBuiltinHandlerName( @@ -91,6 +98,8 @@ export async function interceptToolCalls( const result = await builtinSkills[builtinHandlerName](call.arguments, { apiKeyId: context.apiKeyId, sessionId: context.sessionId, + provider: context.provider, + model: context.model, }); log.info("skills.interception.execution_complete", { diff --git a/src/lib/skills/types.ts b/src/lib/skills/types.ts index 45977b05e3..905076b77f 100644 --- a/src/lib/skills/types.ts +++ b/src/lib/skills/types.ts @@ -57,5 +57,7 @@ export interface SkillConfig { export type SkillHandler = ( input: Record, - context: { apiKeyId: string; sessionId: string } + // provider/model (#7339): optional so existing handlers stay untouched — only the + // web_fetch builtin uses them to resolve a per-model pinned fetch backend. + context: { apiKeyId: string; sessionId: string; provider?: string; model?: string } ) => Promise>; diff --git a/src/lib/skills/webFetchExecution.ts b/src/lib/skills/webFetchExecution.ts new file mode 100644 index 0000000000..d0cce51bb6 --- /dev/null +++ b/src/lib/skills/webFetchExecution.ts @@ -0,0 +1,128 @@ +/** + * webFetchExecution.ts — resolves credentials for a web-fetch provider and dispatches + * to handleWebFetch(), mirroring src/lib/search/executeWebSearch.ts. Consumed by the + * `web_fetch` builtin skill handler (src/lib/skills/builtins.ts) when the synthetic + * `omniroute_web_fetch` tool call emitted by webFetchInterception.ts is executed + * (#7339, Phase 4 of #3384). + */ + +import { getProviderCredentialsWithQuotaPreflight } from "@/sse/services/auth"; +import { getInterceptionRules, type FetchInterceptionBackend } from "@/lib/db/interceptionRules"; +import { + handleWebFetch, + type WebFetchCredentials, + type WebFetchFormat, + type WebFetchResponse, +} from "@omniroute/open-sse/handlers/webFetch.ts"; + +const WEB_FETCH_PROVIDERS = ["firecrawl", "jina-reader", "tavily-search", "tinyfish"] as const; +type WebFetchProviderId = (typeof WEB_FETCH_PROVIDERS)[number]; + +const FETCH_BACKEND_TO_PROVIDER: Record = { + firecrawl: "firecrawl", + jina: "jina-reader", + tavily: "tavily-search", +}; + +export interface ExecuteWebFetchInput { + url: string; + provider?: string; + format?: WebFetchFormat; + depth?: 0 | 1 | 2; + wait_for_selector?: string; + include_metadata?: boolean; + /** Provider/model that owns the interception rule row, used to resolve a pinned backend. */ + ruleProvider?: string | null; + ruleModel?: string | null; +} + +export class WebFetchExecutionError extends Error { + statusCode: number; + + constructor(message: string, statusCode: number) { + super(message); + this.statusCode = statusCode; + } +} + +function isKnownWebFetchProvider(value: unknown): value is WebFetchProviderId { + return typeof value === "string" && (WEB_FETCH_PROVIDERS as readonly string[]).includes(value); +} + +function resolvePinnedBackend(input: ExecuteWebFetchInput): WebFetchProviderId | undefined { + if (isKnownWebFetchProvider(input.provider)) return input.provider; + if (!input.ruleProvider) return undefined; + + const rules = getInterceptionRules(input.ruleProvider); + if (!rules) return undefined; + + const modelRule = input.ruleModel ? rules.models?.[input.ruleModel] : undefined; + const backend = modelRule?.fetchBackend ?? rules.fetchBackend; + return backend ? FETCH_BACKEND_TO_PROVIDER[backend] : undefined; +} + +async function resolveCredentials( + providerId: WebFetchProviderId +): Promise { + try { + return (await getProviderCredentialsWithQuotaPreflight(providerId)) ?? null; + } catch { + return null; + } +} + +async function autoSelectProvider(): Promise<{ + provider: WebFetchProviderId; + credentials: WebFetchCredentials; +} | null> { + for (const providerId of WEB_FETCH_PROVIDERS) { + const credentials = await resolveCredentials(providerId); + if (credentials) return { provider: providerId, credentials }; + } + return null; +} + +async function resolveProviderAndCredentials( + input: ExecuteWebFetchInput +): Promise<{ provider: WebFetchProviderId; credentials: WebFetchCredentials }> { + const pinnedProvider = resolvePinnedBackend(input); + const pinnedCredentials = pinnedProvider ? await resolveCredentials(pinnedProvider) : null; + if (pinnedProvider && pinnedCredentials) { + return { provider: pinnedProvider, credentials: pinnedCredentials }; + } + + const auto = await autoSelectProvider(); + if (!auto) { + throw new WebFetchExecutionError( + `No credentials configured for any web-fetch provider. Add an API key for one of: ${WEB_FETCH_PROVIDERS.join(", ")}.`, + 400 + ); + } + return auto; +} + +export async function executeWebFetch(input: ExecuteWebFetchInput): Promise { + if (!input.url || typeof input.url !== "string") { + throw new WebFetchExecutionError("Missing required field: url", 400); + } + + const { provider, credentials } = await resolveProviderAndCredentials(input); + + const result = await handleWebFetch( + { + url: input.url, + format: input.format, + depth: input.depth, + wait_for_selector: input.wait_for_selector, + include_metadata: input.include_metadata, + }, + credentials, + provider + ); + + if (!result.success || !result.data) { + throw new WebFetchExecutionError(result.error || "Web fetch failed", result.status || 502); + } + + return result.data; +} diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts index a315489958..0fd917e4fa 100644 --- a/src/shared/validation/schemas/provider.ts +++ b/src/shared/validation/schemas/provider.ts @@ -517,6 +517,25 @@ export const updateParamFilterConfigSchema = z.object({ autoLearn: z.boolean().optional(), }); +// PUT /api/providers/[id]/interception-rules — upsert provider/model web +// search/fetch interception rules (#3384/#7339) +const fetchInterceptionBackendSchema = z.enum(["firecrawl", "jina", "tavily"]); + +const modelInterceptionRuleSchema = z.object({ + interceptSearch: z.boolean().optional(), + interceptFetch: z.boolean().optional(), + fetchBackend: fetchInterceptionBackendSchema.optional(), + fetchProxyUrl: z.string().trim().url().max(2000).optional(), +}); + +export const updateInterceptionRulesSchema = z.object({ + interceptSearch: z.boolean().optional(), + interceptFetch: z.boolean().optional(), + fetchBackend: fetchInterceptionBackendSchema.optional(), + fetchProxyUrl: z.string().trim().url().max(2000).optional(), + models: z.record(z.string().trim().min(1).max(200), modelInterceptionRuleSchema).optional(), +}); + export const validateProviderApiKeySchema = z .object({ provider: z.string().trim().min(1, "Provider and API key required"), diff --git a/tests/unit/chat-core-intercept-fetch.test.ts b/tests/unit/chat-core-intercept-fetch.test.ts new file mode 100644 index 0000000000..7c9dfa774d --- /dev/null +++ b/tests/unit/chat-core-intercept-fetch.test.ts @@ -0,0 +1,115 @@ +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"; + +// #7339 — regression guard for the chatCore.ts call site added right after the +// existing interceptSearch block. Proves that with no interceptFetch DB row +// configured (the default/common case), a request carrying a native web_fetch +// tool declaration produces a BYTE-IDENTICAL outgoing body to pre-#7339 +// behavior — chatCore.ts never called any web_fetch interception logic before +// this change, so "byte-identical" here means prepareWebFetchFallbackBody must +// be a true no-op end to end (resolver -> body-prep), not just individually. +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-test-chatcore-intercept-fetch-")); +process.env.DATA_DIR = tmpDir; + +const core = await import("../../src/lib/db/core.ts"); +const { setInterceptionRules, resolveInterceptFetch } = await import( + "../../src/lib/db/interceptionRules.ts" +); +const { prepareWebFetchFallbackBody } = await import( + "../../open-sse/services/webFetchInterception.ts" +); + +function buildRequestBody() { + return { + model: "gpt-5", + messages: [{ role: "user", content: "Summarize https://example.com" }], + tools: [{ type: "web_fetch" }], + }; +} + +// Mirrors the exact two-call sequence chatCore.ts now runs at its interceptFetch +// call site: resolveInterceptFetch(provider, effectiveModel) followed by +// prepareWebFetchFallbackBody(body, { ...options, interceptFetchOverride }). +function runChatCoreInterceptFetchStep( + provider: string, + effectiveModel: string, + body: Record +) { + const interceptFetchOverride = resolveInterceptFetch(provider, effectiveModel); + return prepareWebFetchFallbackBody(body, { + provider, + sourceFormat: "openai", + targetFormat: "openai", + nativeCodexPassthrough: false, + interceptFetchOverride, + }); +} + +describe("chatCore.ts interceptFetch call site — flag-off regression guard (#7339)", () => { + 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("leaves the outgoing body byte-identical when no interceptFetch rule is configured", () => { + const originalBody = buildRequestBody(); + const preChangeSerialized = JSON.stringify(originalBody); + + const { body: nextBody, fallback } = runChatCoreInterceptFetchStep( + "openai", + "gpt-5", + originalBody + ); + + assert.equal(fallback.enabled, false); + assert.equal(JSON.stringify(nextBody), preChangeSerialized); + assert.equal(nextBody, originalBody, "must be the same object reference — true no-op"); + }); + + it("leaves the body untouched for a request with no web_fetch tool at all, regardless of rule state", () => { + setInterceptionRules("openai", { interceptFetch: true }); + const originalBody = { + model: "gpt-5", + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "get_weather", parameters: {} } }], + }; + const preChangeSerialized = JSON.stringify(originalBody); + + const { body: nextBody, fallback } = runChatCoreInterceptFetchStep( + "openai", + "gpt-5", + originalBody + ); + + assert.equal(fallback.enabled, false); + assert.equal(JSON.stringify(nextBody), preChangeSerialized); + }); + + it("only converts the tool once the operator explicitly opts a provider/model into interceptFetch", () => { + setInterceptionRules("openai", { interceptFetch: true }); + const originalBody = buildRequestBody(); + + const { body: nextBody, fallback } = runChatCoreInterceptFetchStep( + "openai", + "gpt-5", + originalBody + ); + + assert.equal(fallback.enabled, true); + assert.notEqual(nextBody, originalBody); + assert.equal(originalBody.tools[0].type, "web_fetch", "the input object itself is untouched"); + }); +}); diff --git a/tests/unit/intercept-fetch-resolver.test.ts b/tests/unit/intercept-fetch-resolver.test.ts new file mode 100644 index 0000000000..ff109d89e2 --- /dev/null +++ b/tests/unit/intercept-fetch-resolver.test.ts @@ -0,0 +1,63 @@ +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-intercept-fetch-resolver-")); +process.env.DATA_DIR = tmpDir; + +const core = await import("../../src/lib/db/core.ts"); +const { setInterceptionRules, resolveInterceptFetch } = await import( + "../../src/lib/db/interceptionRules.ts" +); + +// #7339 — resolveInterceptFetch, a structural twin of resolveInterceptSearch +// (tests/unit/interception-rules.test.ts), covering Phase 3 of #3384. +describe("db/interceptionRules — resolveInterceptFetch precedence (#7339)", () => { + 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 undefined when no provider/model rule exists", () => { + assert.equal(resolveInterceptFetch("anthropic", "claude-opus-4"), undefined); + }); + + it("returns the provider-level interceptFetch value when only a provider rule is set", () => { + setInterceptionRules("anthropic", { interceptFetch: true }); + assert.equal(resolveInterceptFetch("anthropic", "claude-opus-4"), true); + assert.equal(resolveInterceptFetch("anthropic", "claude-haiku-4"), true); + }); + + it("model-level interceptFetch wins over the provider-level rule when both are set", () => { + setInterceptionRules("anthropic", { + interceptFetch: false, + models: { "claude-opus-4": { interceptFetch: true } }, + }); + assert.equal(resolveInterceptFetch("anthropic", "claude-opus-4"), true); + assert.equal(resolveInterceptFetch("anthropic", "claude-haiku-4"), false); + }); + + it("does not read interceptSearch when resolving interceptFetch (fields stay independent)", () => { + setInterceptionRules("anthropic", { interceptSearch: true, interceptFetch: false }); + assert.equal(resolveInterceptFetch("anthropic", "claude-opus-4"), false); + }); + + it("returns undefined for an empty/missing provider", () => { + assert.equal(resolveInterceptFetch("", "claude-opus-4"), undefined); + assert.equal(resolveInterceptFetch(null, "claude-opus-4"), undefined); + assert.equal(resolveInterceptFetch(undefined, "claude-opus-4"), undefined); + }); +}); diff --git a/tests/unit/web-fetch-dispatch.test.ts b/tests/unit/web-fetch-dispatch.test.ts new file mode 100644 index 0000000000..004435305e --- /dev/null +++ b/tests/unit/web-fetch-dispatch.test.ts @@ -0,0 +1,178 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// #7339 — proves the synthetic omniroute_web_fetch tool call (emitted by +// webFetchInterception.ts, Phase 3-4 of #3384) is routed through the same +// generic dispatch path already proven for omniroute_web_search +// (handleToolCallExecution -> builtinSkills.web_fetch), including the +// error/abort-not-swallowed case (Hard Rule #6). +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-web-fetch-dispatch-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const coreDb = await import("../../src/lib/db/core.ts"); +const { skillRegistry } = await import("../../src/lib/skills/registry.ts"); +const { skillExecutor } = await import("../../src/lib/skills/executor.ts"); +const { handleToolCallExecution } = await import("../../src/lib/skills/interception.ts"); +const { builtinSkills } = await import("../../src/lib/skills/builtins.ts"); +const { OMNIROUTE_WEB_FETCH_FALLBACK_TOOL_NAME } = await import( + "../../open-sse/services/webFetchInterception.ts" +); + +const originalWebFetchHandler = builtinSkills.web_fetch; + +function resetRuntime() { + skillRegistry["registeredSkills"].clear(); + skillRegistry["versionCache"].clear(); + skillExecutor["handlers"].clear(); + skillExecutor.setTimeout(50); +} + +test.beforeEach(() => { + resetRuntime(); + coreDb.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +}); + +test.after(() => { + builtinSkills.web_fetch = originalWebFetchHandler; + resetRuntime(); + coreDb.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +const contextWithFetchBuiltin = { + apiKeyId: "key-a", + sessionId: "session-1", + requestId: "request-1", + builtinToolNames: [OMNIROUTE_WEB_FETCH_FALLBACK_TOOL_NAME], + provider: "openai", + model: "gpt-5", +}; + +test("handleToolCallExecution routes omniroute_web_fetch to the web_fetch builtin handler and returns the tool-result envelope", async () => { + let receivedInput: unknown; + let receivedContext: unknown; + builtinSkills.web_fetch = async (input, context) => { + receivedInput = input; + receivedContext = context; + return { + success: true, + provider: "firecrawl", + url: input.url, + content: "fetched content", + links: [], + metadata: null, + screenshot_url: null, + }; + }; + + const result = await handleToolCallExecution( + { + choices: [ + { + message: { + tool_calls: [ + { + id: "call-fetch-1", + function: { + name: OMNIROUTE_WEB_FETCH_FALLBACK_TOOL_NAME, + arguments: '{"url":"https://example.com"}', + }, + }, + ], + }, + }, + ], + }, + "gpt-5", + contextWithFetchBuiltin + ); + + assert.deepEqual(result.tool_results, [ + { + tool_call_id: "call-fetch-1", + output: JSON.stringify({ + success: true, + provider: "firecrawl", + url: "https://example.com", + content: "fetched content", + links: [], + metadata: null, + screenshot_url: null, + }), + }, + ]); + assert.deepEqual(receivedInput, { url: "https://example.com" }); + assert.equal((receivedContext as { provider?: string }).provider, "openai"); + assert.equal((receivedContext as { model?: string }).model, "gpt-5"); +}); + +test("an unknown tool name is left untouched when builtinToolNames does not include it (#2815 no-alias-no-dispatch)", async () => { + const original = { + choices: [ + { + message: { + tool_calls: [ + { + id: "call-fetch-2", + function: { + name: OMNIROUTE_WEB_FETCH_FALLBACK_TOOL_NAME, + arguments: '{"url":"https://example.com"}', + }, + }, + ], + }, + }, + ], + }; + + const result = await handleToolCallExecution(original, "gpt-5", { + apiKeyId: "key-a", + sessionId: "session-1", + requestId: "request-1", + builtinToolNames: [], + }); + + assert.equal(result, original); +}); + +test("an error thrown mid-fetch (e.g. an aborted request) is surfaced, not silently dropped (Hard Rule #6)", async () => { + builtinSkills.web_fetch = async () => { + const abortError = new Error("The operation was aborted"); + abortError.name = "AbortError"; + throw abortError; + }; + + const result = await handleToolCallExecution( + { + choices: [ + { + message: { + tool_calls: [ + { + id: "call-fetch-abort", + function: { + name: OMNIROUTE_WEB_FETCH_FALLBACK_TOOL_NAME, + arguments: '{"url":"https://example.com"}', + }, + }, + ], + }, + }, + ], + }, + "gpt-5", + contextWithFetchBuiltin + ); + + assert.deepEqual(result.tool_results, [ + { + tool_call_id: "call-fetch-abort", + output: JSON.stringify({ error: "The operation was aborted" }), + }, + ]); +}); diff --git a/tests/unit/web-fetch-interception.test.ts b/tests/unit/web-fetch-interception.test.ts new file mode 100644 index 0000000000..5e093ad0ec --- /dev/null +++ b/tests/unit/web-fetch-interception.test.ts @@ -0,0 +1,139 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + OMNIROUTE_WEB_FETCH_FALLBACK_TOOL_NAME, + prepareWebFetchFallbackBody, + supportsNativeWebFetchFallbackBypass, +} from "../../open-sse/services/webFetchInterception.ts"; +import { FORMATS } from "../../open-sse/translator/formats.ts"; + +// #7339 — webFetchInterception.ts, a structural twin of webSearchFallback.ts +// (Phase 3-4 of #3384). Pure body/tool-array transformation only. +describe("services/webFetchInterception — prepareWebFetchFallbackBody (#7339)", () => { + const baseOptions = { + provider: "openai", + sourceFormat: FORMATS.OPENAI, + targetFormat: FORMATS.OPENAI, + nativeCodexPassthrough: false, + }; + + it("is a no-op (byte-identical body, enabled:false) when no tools array is present", () => { + const body = { model: "gpt-5" }; + const { body: nextBody, fallback } = prepareWebFetchFallbackBody(body, baseOptions); + assert.deepEqual(nextBody, body); + assert.equal(fallback.enabled, false); + assert.equal(fallback.toolName, null); + assert.equal(fallback.convertedToolCount, 0); + }); + + it("is a no-op when no web_fetch-shaped tool is present in the tools array", () => { + const body = { tools: [{ type: "function", function: { name: "get_weather" } }] }; + const { body: nextBody, fallback } = prepareWebFetchFallbackBody(body, baseOptions); + assert.deepEqual(nextBody, body); + assert.equal(fallback.enabled, false); + assert.equal(fallback.convertedToolCount, 0); + }); + + it("converts a native web_fetch tool into the synthetic omniroute_web_fetch tool when interceptFetchOverride is true", () => { + const body = { tools: [{ type: "web_fetch" }] }; + const { body: nextBody, fallback } = prepareWebFetchFallbackBody(body, { + ...baseOptions, + interceptFetchOverride: true, + }); + assert.equal(fallback.enabled, true); + assert.equal(fallback.toolName, OMNIROUTE_WEB_FETCH_FALLBACK_TOOL_NAME); + assert.equal(fallback.convertedToolCount, 1); + assert.equal(nextBody.tools.length, 1); + assert.equal( + (nextBody.tools[0] as { function: { name: string } }).function.name, + OMNIROUTE_WEB_FETCH_FALLBACK_TOOL_NAME + ); + }); + + it("uses a flat function-tool shape for the Responses API target", () => { + const body = { tools: [{ type: "web_fetch" }] }; + const { body: nextBody } = prepareWebFetchFallbackBody(body, { + ...baseOptions, + targetFormat: FORMATS.OPENAI_RESPONSES, + interceptFetchOverride: true, + }); + const tool = nextBody.tools[0] as { type: string; name: string; function?: unknown }; + assert.equal(tool.type, "function"); + assert.equal(tool.name, OMNIROUTE_WEB_FETCH_FALLBACK_TOOL_NAME); + assert.equal(tool.function, undefined); + }); + + it("leaves the native web_fetch tool untouched when interceptFetchOverride is false", () => { + const body = { tools: [{ type: "web_fetch" }] }; + const { body: nextBody, fallback } = prepareWebFetchFallbackBody(body, { + ...baseOptions, + interceptFetchOverride: false, + }); + assert.deepEqual(nextBody, body); + assert.equal(fallback.enabled, false); + }); + + it("preserves other tools alongside the converted synthetic tool", () => { + const body = { + tools: [{ type: "web_fetch" }, { type: "function", function: { name: "get_weather" } }], + }; + const { body: nextBody, fallback } = prepareWebFetchFallbackBody(body, { + ...baseOptions, + interceptFetchOverride: true, + }); + assert.equal(fallback.convertedToolCount, 1); + assert.equal(nextBody.tools.length, 2); + const names = (nextBody.tools as Array<{ function?: { name: string } }>).map( + (tool) => tool.function?.name + ); + assert.ok(names.includes(OMNIROUTE_WEB_FETCH_FALLBACK_TOOL_NAME)); + assert.ok(names.includes("get_weather")); + }); +}); + +describe("services/webFetchInterception — supportsNativeWebFetchFallbackBypass (#7339)", () => { + it("interceptFetchOverride true always forces interception (no bypass)", () => { + assert.equal( + supportsNativeWebFetchFallbackBypass({ + targetFormat: FORMATS.CLAUDE, + sourceFormat: FORMATS.CLAUDE, + nativeCodexPassthrough: false, + interceptFetchOverride: true, + }), + false + ); + }); + + it("interceptFetchOverride false always forces native bypass", () => { + assert.equal( + supportsNativeWebFetchFallbackBypass({ + targetFormat: FORMATS.OPENAI, + sourceFormat: FORMATS.OPENAI, + nativeCodexPassthrough: false, + interceptFetchOverride: false, + }), + true + ); + }); + + it("bypasses natively for Codex passthrough with no override set", () => { + assert.equal( + supportsNativeWebFetchFallbackBypass({ + targetFormat: FORMATS.OPENAI_RESPONSES, + nativeCodexPassthrough: true, + }), + true + ); + }); + + it("bypasses by default (strictly opt-in, no heuristic default) when no override is set at all", () => { + assert.equal( + supportsNativeWebFetchFallbackBypass({ + targetFormat: FORMATS.OPENAI, + sourceFormat: FORMATS.OPENAI, + nativeCodexPassthrough: false, + }), + true + ); + }); +});