From 48746183cfddfca991d447041d4d7da4f867afc2 Mon Sep 17 00:00:00 2001 From: 810senpai114514 <810senpai114514@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:01:29 +0800 Subject: [PATCH] feat: web search via Tavily with short-circuit response --- packages/core/src/gateway/service.ts | 307 +++++++++++++++++++-- tests/main/gateway-virtual-models.test.mjs | 4 +- 2 files changed, 288 insertions(+), 23 deletions(-) diff --git a/packages/core/src/gateway/service.ts b/packages/core/src/gateway/service.ts index 5936e7aa..d13e7f28 100644 --- a/packages/core/src/gateway/service.ts +++ b/packages/core/src/gateway/service.ts @@ -859,12 +859,72 @@ class GatewayService { }); if (hostedWebSearchProtocolContext && !this.browserWebSearchMcpIntegration) { - const message = browserWebSearchUnavailableMessage(hostedWebSearchProtocolContext.toolName); - const responseHeaders = new Headers({ "content-type": "application/json; charset=utf-8" }); - const responseBody = JSON.stringify({ error: { message } }); - writeRequestLog(503, responseHeaders, responseBody, false, message); - sendJson(response, 503, { error: { message } }); - return; + const body = parseJsonObjectSafe(bodyToForward); + if (body) { + const queryHint = extractHostedWebSearchQueryHint(body, hostedWebSearchProtocolContext.protocol); + if (queryHint) { + const provider = fusionWebSearchProviderForToolName(this.config, hostedWebSearchProtocolContext.toolName); + const records = provider ? await runWebSearch(queryHint, provider) : []; + if (records.length > 0) { + // Short-circuit: return synthetic response (LiteLLM-style). + // Use server_tool_use + web_search_tool_result (nested) format + // matching https://docs.anthropic.com/en/api/web-search-tool + const toolUseId = `srvtoolu_${randomUUID().replace(/-/g, "").slice(0, 24)}`; + const content: Record[] = []; + content.push({ + type: "server_tool_use", + id: toolUseId, + name: "web_search", + input: { query: queryHint } + }); + const resultItems: Record[] = []; + const textParts: string[] = []; + for (const record of records) { + for (const result of record.results) { + resultItems.push({ + type: "web_search_result", + url: result.url, + title: result.title, + page_age: null, + encrypted_content: "" + }); + const snippet = (result.snippet || result.content || "").slice(0, 500); + textParts.push(`Title: ${result.title}\nURL: ${result.url}\nSnippet: ${snippet}`); + } + } + content.push({ + type: "web_search_tool_result", + tool_use_id: toolUseId, + content: resultItems + }); + if (textParts.length > 0) { + content.push({ type: "text", text: textParts.join("\n\n") }); + } + const syntheticPayload: Record = { + id: `msg_${randomUUID().replace(/-/g, "").slice(0, 24)}`, + type: "message", + role: "assistant", + model: routedModel, + content, + stop_reason: "end_turn", + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0, server_tool_use: { web_search_requests: records.length } } + }; + const responseBody = JSON.stringify(syntheticPayload); + writeRequestLog(200, new Headers({ "content-type": "application/json" }), responseBody, false); + sendJson(response, 200, syntheticPayload); + return; + } + } + } + if (!hostedWebSearchProtocolContext.records) { + const message = browserWebSearchUnavailableMessage(hostedWebSearchProtocolContext.toolName); + const responseHeaders = new Headers({ "content-type": "application/json; charset=utf-8" }); + const responseBody = JSON.stringify({ error: { message } }); + writeRequestLog(503, responseHeaders, responseBody, false, message); + sendJson(response, 503, { error: { message } }); + return; + } } if (hostedWebSearchProtocolContext && this.browserWebSearchMcpIntegration) { @@ -1166,7 +1226,14 @@ async function writeCoreGatewayConfig( ...pluginService.getVirtualModelProfiles() ])), config); const coreEndpoint = endpoint(config.gateway.coreHost, config.gateway.corePort); - const builtinToolArtifacts = await fusionBuiltinToolArtifacts(virtualModelProfiles, coreEndpoint, coreAuthToken, browserWebSearchMcpIntegration); + const proxyUrl = process.env.CCR_UPSTREAM_PROXY_URL || process.env.HTTPS_PROXY || process.env.https_proxy; + const proxyPreloadFile = proxyUrl + ? pathJoin(dirname(config.gateway.generatedConfigFile), "gateway-proxy-preload.cjs") + : undefined; + const proxyEnv = proxyUrl ? { CCR_UPSTREAM_PROXY_URL: proxyUrl, CCR_UNDICI_MODULE: resolveUndiciProxyAgentModule() } : undefined; + const builtinToolArtifacts = await fusionBuiltinToolArtifacts( + virtualModelProfiles, coreEndpoint, coreAuthToken, browserWebSearchMcpIntegration, proxyPreloadFile, proxyEnv + ); const providers = [ ...config.Providers .flatMap((provider) => toCoreGatewayProviders(withCodexOauthProviderBaseUrl(provider, codexOauthProviderNames))) @@ -1461,7 +1528,9 @@ async function fusionBuiltinToolArtifacts( profiles: unknown[], coreEndpoint: string, coreAuthToken: string, - browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration + browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration, + proxyPreloadFile?: string, + proxyEnv?: Record ): Promise<{ mcpServers: GatewayMcpServerConfig[]; providers: CoreGatewayProvider[] }> { const providers: CoreGatewayProvider[] = []; const mcpServers: GatewayMcpServerConfig[] = []; @@ -1495,7 +1564,9 @@ async function fusionBuiltinToolArtifacts( ...(visionConfig.baseUrl && visionConfig.apiKey ? { VISION_API_KEY: visionConfig.apiKey } : {}), ...(visionConfig.timeoutMs ? { VISION_TIMEOUT_MS: String(visionConfig.timeoutMs) } : {}) }, - name: `fusion-vision-${sanitizedProfileId}` + name: `fusion-vision-${sanitizedProfileId}`, + proxyPreloadFile, + proxyEnv })); } } @@ -1528,7 +1599,9 @@ async function fusionBuiltinToolArtifacts( ...(webSearchConfig.timeoutMs ? { SEARCH_TIMEOUT_MS: String(webSearchConfig.timeoutMs) } : {}), ...(webSearchConfig.env ?? {}) }, - name: `fusion-web-search-${sanitizedProfileId}` + name: `fusion-web-search-${sanitizedProfileId}`, + proxyPreloadFile, + proxyEnv })); } } @@ -1542,25 +1615,32 @@ export async function fusionBuiltinToolArtifactsForTest( profiles: unknown[], coreEndpoint: string, coreAuthToken: string, - browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration + browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration, + proxyPreloadFile?: string, + proxyEnv?: Record ): Promise<{ mcpServers: GatewayMcpServerConfig[]; providers: unknown[] }> { - return fusionBuiltinToolArtifacts(profiles, coreEndpoint, coreAuthToken, browserWebSearchMcpIntegration); + return fusionBuiltinToolArtifacts(profiles, coreEndpoint, coreAuthToken, browserWebSearchMcpIntegration, proxyPreloadFile, proxyEnv); } function fusionBuiltinMcpServer({ entry, env, - name + name, + proxyPreloadFile, + proxyEnv }: { entry: string; env: Record; name: string; + proxyPreloadFile?: string; + proxyEnv?: Record; }): GatewayMcpServerConfig { return { - args: [entry], + args: proxyPreloadFile ? ["--require", proxyPreloadFile, entry] : [entry], command: process.execPath, env: { ELECTRON_RUN_AS_NODE: "1", + ...(proxyEnv ?? {}), ...env }, name, @@ -3257,15 +3337,16 @@ export function hostedWebSearchProtocolResponseStream( context: HostedWebSearchProtocolContext, integration: BrowserWebSearchMcpIntegration | undefined ): Readable { - if (!integration?.recentBrowserWebSearchResults && !integration?.runBrowserWebSearch) { + const hasIntegration = integration?.recentBrowserWebSearchResults !== undefined || integration?.runBrowserWebSearch !== undefined; + if (!hasIntegration && !context.records?.length) { return input; } const contentType = headers.get("content-type")?.toLowerCase() ?? ""; if (contentType.includes("text/event-stream")) { if (context.protocol === "anthropic_messages") { - return anthropicHostedWebSearchProtocolSseStream(input, context, integration); + return anthropicHostedWebSearchProtocolSseStream(input, context, integration!); } - return hostedWebSearchProtocolSseStream(input, context, integration); + return hostedWebSearchProtocolSseStream(input, context, integration!); } if (!contentType.includes("application/json")) { return input; @@ -3280,7 +3361,7 @@ export function hostedWebSearchProtocolResponseStream( flush(callback) { const body = Buffer.concat(chunks).toString("utf8"); void (async () => { - const records = await selectHostedWebSearchProtocolRecords(context, integration); + const records = context.records?.length ? context.records : await selectHostedWebSearchProtocolRecords(context, integration!); if (records.length === 0) { this.push(body); return; @@ -3302,7 +3383,9 @@ function hostedWebSearchProtocolSseStream( context: HostedWebSearchProtocolContext, integration: BrowserWebSearchMcpIntegration ): Readable { - const recordsPromise = selectHostedWebSearchProtocolRecords(context, integration); + const recordsPromise = context.records?.length + ? Promise.resolve(context.records) + : selectHostedWebSearchProtocolRecords(context, integration); let records: BrowserWebSearchProtocolRecord[] | undefined; let pending = ""; let passThrough = false; @@ -3498,7 +3581,9 @@ function anthropicHostedWebSearchProtocolSseStream( context: HostedWebSearchProtocolContext, integration: BrowserWebSearchMcpIntegration ): Readable { - const recordsPromise = selectHostedWebSearchProtocolRecords(context, integration); + const recordsPromise = context.records?.length + ? Promise.resolve(context.records) + : selectHostedWebSearchProtocolRecords(context, integration); let records: BrowserWebSearchProtocolRecord[] | undefined; let pending = ""; let passThrough = false; @@ -4723,8 +4808,10 @@ function textPartsFromGeminiContents(contents: unknown): string[] { } export function fusionWebSearchToolNameForRequest(config: AppConfig, model: string | undefined): string | undefined { + // Router already determines which Fusion profile handles web search. + // Match the requested model against all web search candidates (browser + non-browser). const normalizedModel = model ? fusionModelNameFromSelector(model) : ""; - for (const candidate of fusionBrowserWebSearchToolCandidates(config)) { + for (const candidate of allWebSearchToolCandidates(config)) { if (!normalizedModel || candidate.aliases.some((alias) => fusionModelNameFromSelector(alias).toLowerCase() === normalizedModel.toLowerCase())) { return candidate.toolName; } @@ -4732,6 +4819,41 @@ export function fusionWebSearchToolNameForRequest(config: AppConfig, model: stri return undefined; } +function allWebSearchToolCandidates(config: AppConfig): Array<{ aliases: string[]; toolName: string; provider: string | undefined }> { + const browser = fusionBrowserWebSearchToolCandidates(config).map((c) => ({ ...c, provider: "browser" as const })); + const nonBrowser = fusionWebSearchToolCandidates(config); + return [...browser, ...nonBrowser]; +} + +function fusionWebSearchToolCandidates(config: AppConfig): Array<{ aliases: string[]; toolName: string; provider: string | undefined }> { + const rawProfiles = Array.isArray(config.virtualModelProfiles) ? config.virtualModelProfiles : []; + const profiles = normalizeCoreGatewayVirtualModelProfiles( + withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases(rawProfiles)), + config + ); + const candidates: Array<{ aliases: string[]; toolName: string; provider: string | undefined }> = []; + for (const profile of profiles) { + if (!isRecord(profile) || profile.enabled === false) { + continue; + } + const metadata = isRecord(profile.metadata) ? profile.metadata : undefined; + const fusionWebSearch = isRecord(metadata?.fusionWebSearch) ? metadata.fusionWebSearch : undefined; + const webSearchConfig = readFusionWebSearchConfig(fusionWebSearch); + if (!webSearchConfig?.toolName) { + continue; + } + const match = isRecord(profile.match) ? profile.match : undefined; + const aliases = uniqueStrings([ + stringValue(profile.id), + stringValue(profile.key), + stringValue(profile.displayName), + ...stringListValue(match?.exactAliases) + ].filter((item): item is string => Boolean(item))); + candidates.push({ aliases, provider: webSearchConfig.provider, toolName: webSearchConfig.toolName }); + } + return candidates; +} + function fusionBrowserWebSearchToolCandidates(config: AppConfig): Array<{ aliases: string[]; toolName: string }> { const rawProfiles = Array.isArray(config.virtualModelProfiles) ? config.virtualModelProfiles : []; const profiles = normalizeCoreGatewayVirtualModelProfiles( @@ -4761,6 +4883,149 @@ function fusionBrowserWebSearchToolCandidates(config: AppConfig): Array<{ aliase return candidates; } +function fusionWebSearchProviderForToolName(config: AppConfig, toolName: string): string | undefined { + const candidate = fusionWebSearchToolCandidates(config).find((c) => c.toolName === toolName); + return candidate && candidate.provider !== "browser" ? candidate.provider : undefined; +} + +async function runWebSearch(query: string, provider: string): Promise { + switch (provider) { + case "tavily": return searchTavily(query); + case "brave": return searchBrave(query); + case "bing": return searchBing(query); + case "google_cse": return searchGoogleCse(query); + case "serper": return searchSerper(query); + case "serpapi": return searchSerpApi(query); + case "exa": return searchExa(query); + default: + console.log(`[gateway] Unknown search provider: ${provider}`); + return []; + } +} + +async function searchTavily(query: string): Promise { + const apiKey = process.env.TAVILY_API_KEY; + if (!apiKey) { console.log(`[gateway] Tavily: API key not set`); return []; } + try { + const response = await fetchWithSystemProxy("https://api.tavily.com/search", { + body: JSON.stringify({ api_key: apiKey, query, max_results: 5, search_depth: "basic" }), + headers: { "content-type": "application/json" }, + method: "POST", signal: AbortSignal.timeout(15000) + }); + if (!response.ok) { console.log(`[gateway] Tavily returned ${response.status}`); return []; } + const data: Record = await response.json() as Record; + const items = Array.isArray(data.results) ? data.results : []; + if (items.length === 0) return []; + return [{ completedAtMs: Date.now(), engine: "tavily", query, + results: items.map((item: unknown) => { const r = item as Record; return { snippet: stringValue(r.content), title: stringValue(r.title) || "", url: stringValue(r.url) || "" }; }).filter((r) => r.title || r.url), + searchUrl: "https://tavily.com", toolName: "web_search" }]; + } catch (error) { console.log(`[gateway] Tavily error: ${formatError(error)}`); return []; } +} + +async function searchBrave(query: string): Promise { + const apiKey = process.env.BRAVE_SEARCH_API_KEY; + if (!apiKey) { console.log(`[gateway] Brave: API key not set`); return []; } + try { + const url = new URL("https://api.search.brave.com/res/v1/web/search"); + url.searchParams.set("q", query); url.searchParams.set("count", "5"); + const response = await fetchWithSystemProxy(url.toString(), { headers: { "x-subscription-token": apiKey }, signal: AbortSignal.timeout(15000) }); + if (!response.ok) { console.log(`[gateway] Brave returned ${response.status}`); return []; } + const data: Record = await response.json() as Record; + const items = isRecord(data.web) && Array.isArray(data.web.results) ? data.web.results : []; + if (items.length === 0) return []; + return [{ completedAtMs: Date.now(), engine: "brave", query, + results: items.map((item: unknown) => { const r = item as Record; return { snippet: stringValue(r.description), title: stringValue(r.title) || "", url: stringValue(r.url) || "" }; }).filter((r) => r.title || r.url), + searchUrl: "https://search.brave.com", toolName: "web_search" }]; + } catch (error) { console.log(`[gateway] Brave error: ${formatError(error)}`); return []; } +} + +async function searchBing(query: string): Promise { + const apiKey = process.env.BING_SEARCH_API_KEY; + if (!apiKey) { console.log(`[gateway] Bing: API key not set`); return []; } + try { + const url = new URL("https://api.bing.microsoft.com/v7.0/search"); + url.searchParams.set("q", query); url.searchParams.set("count", "5"); url.searchParams.set("mkt", "en-US"); + const response = await fetchWithSystemProxy(url.toString(), { headers: { "ocp-apim-subscription-key": apiKey }, signal: AbortSignal.timeout(15000) }); + if (!response.ok) { console.log(`[gateway] Bing returned ${response.status}`); return []; } + const data: Record = await response.json() as Record; + const items = isRecord(data.webPages) && Array.isArray(data.webPages.value) ? data.webPages.value : []; + if (items.length === 0) return []; + return [{ completedAtMs: Date.now(), engine: "bing", query, + results: items.map((item: unknown) => { const r = item as Record; return { snippet: stringValue(r.snippet), title: stringValue(r.name) || "", url: stringValue(r.url) || "" }; }).filter((r) => r.title || r.url), + searchUrl: "https://www.bing.com", toolName: "web_search" }]; + } catch (error) { console.log(`[gateway] Bing error: ${formatError(error)}`); return []; } +} + +async function searchGoogleCse(query: string): Promise { + const apiKey = process.env.GOOGLE_SEARCH_API_KEY; const cx = process.env.GOOGLE_SEARCH_CX; + if (!apiKey || !cx) { console.log(`[gateway] Google CSE: API key or CX not set`); return []; } + try { + const url = new URL("https://www.googleapis.com/customsearch/v1"); + url.searchParams.set("key", apiKey); url.searchParams.set("cx", cx); url.searchParams.set("q", query); url.searchParams.set("num", "5"); + const response = await fetchWithSystemProxy(url.toString(), { signal: AbortSignal.timeout(15000) }); + if (!response.ok) { console.log(`[gateway] Google CSE returned ${response.status}`); return []; } + const data: Record = await response.json() as Record; + const items = Array.isArray(data.items) ? data.items : []; + if (items.length === 0) return []; + return [{ completedAtMs: Date.now(), engine: "google_cse", query, + results: items.map((item: unknown) => { const r = item as Record; return { snippet: stringValue(r.snippet), title: stringValue(r.title) || "", url: stringValue(r.link) || "" }; }).filter((r) => r.title || r.url), + searchUrl: "https://cse.google.com", toolName: "web_search" }]; + } catch (error) { console.log(`[gateway] Google CSE error: ${formatError(error)}`); return []; } +} + +async function searchSerper(query: string): Promise { + const apiKey = process.env.SERPER_API_KEY; + if (!apiKey) { console.log(`[gateway] Serper: API key not set`); return []; } + try { + const response = await fetchWithSystemProxy("https://google.serper.dev/search", { + body: JSON.stringify({ q: query, num: 5 }), headers: { "content-type": "application/json", "x-api-key": apiKey }, + method: "POST", signal: AbortSignal.timeout(15000) + }); + if (!response.ok) { console.log(`[gateway] Serper returned ${response.status}`); return []; } + const data: Record = await response.json() as Record; + const items = Array.isArray(data.organic) ? data.organic : []; + if (items.length === 0) return []; + return [{ completedAtMs: Date.now(), engine: "serper", query, + results: items.map((item: unknown) => { const r = item as Record; return { snippet: stringValue(r.snippet), title: stringValue(r.title) || "", url: stringValue(r.link) || "" }; }).filter((r) => r.title || r.url), + searchUrl: "https://serper.dev", toolName: "web_search" }]; + } catch (error) { console.log(`[gateway] Serper error: ${formatError(error)}`); return []; } +} + +async function searchSerpApi(query: string): Promise { + const apiKey = process.env.SERPAPI_API_KEY; + if (!apiKey) { console.log(`[gateway] SerpAPI: API key not set`); return []; } + try { + const url = new URL("https://serpapi.com/search.json"); + url.searchParams.set("api_key", apiKey); url.searchParams.set("engine", "google"); url.searchParams.set("q", query); url.searchParams.set("num", "5"); + const response = await fetchWithSystemProxy(url.toString(), { signal: AbortSignal.timeout(15000) }); + if (!response.ok) { console.log(`[gateway] SerpAPI returned ${response.status}`); return []; } + const data: Record = await response.json() as Record; + const items = Array.isArray(data.organic_results) ? data.organic_results : []; + if (items.length === 0) return []; + return [{ completedAtMs: Date.now(), engine: "serpapi", query, + results: items.map((item: unknown) => { const r = item as Record; return { snippet: stringValue(r.snippet), title: stringValue(r.title) || "", url: stringValue(r.link) || "" }; }).filter((r) => r.title || r.url), + searchUrl: "https://serpapi.com", toolName: "web_search" }]; + } catch (error) { console.log(`[gateway] SerpAPI error: ${formatError(error)}`); return []; } +} + +async function searchExa(query: string): Promise { + const apiKey = process.env.EXA_API_KEY; + if (!apiKey) { console.log(`[gateway] Exa: API key not set`); return []; } + try { + const response = await fetchWithSystemProxy("https://api.exa.ai/search", { + body: JSON.stringify({ query, numResults: 5 }), headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" }, + method: "POST", signal: AbortSignal.timeout(15000) + }); + if (!response.ok) { console.log(`[gateway] Exa returned ${response.status}`); return []; } + const data: Record = await response.json() as Record; + const items = Array.isArray(data.results) ? data.results : []; + if (items.length === 0) return []; + return [{ completedAtMs: Date.now(), engine: "exa", query, + results: items.map((item: unknown) => { const r = item as Record; return { snippet: stringValue(r.text), title: stringValue(r.title) || "", url: stringValue(r.url) || "" }; }).filter((r) => r.title || r.url), + searchUrl: "https://exa.ai", toolName: "web_search" }]; + } catch (error) { console.log(`[gateway] Exa error: ${formatError(error)}`); return []; } +} + async function selectHostedWebSearchProtocolRecords( context: HostedWebSearchProtocolContext, integration: BrowserWebSearchMcpIntegration diff --git a/tests/main/gateway-virtual-models.test.mjs b/tests/main/gateway-virtual-models.test.mjs index df4f1465..fa60136e 100644 --- a/tests/main/gateway-virtual-models.test.mjs +++ b/tests/main/gateway-virtual-models.test.mjs @@ -509,7 +509,7 @@ test("gateway does not route hosted web search through an unrelated Fusion searc assert.equal(fusionWebSearchToolNameForRequest(config, "Fusion/kimisearch"), "fusion_2_web_search"); }); -test("gateway resolves only browser-backed Fusion web search tools for hosted protocol bridging", () => { +test("gateway resolves non-browser Fusion web search tools for hosted protocol bridging", () => { const config = { Providers: [], Router: { fallback: { mode: "off", models: [], retryCount: 0 } }, @@ -538,7 +538,7 @@ test("gateway resolves only browser-backed Fusion web search tools for hosted pr ] }; - assert.equal(fusionWebSearchToolNameForRequest(config, "Fusion/research"), undefined); + assert.equal(fusionWebSearchToolNameForRequest(config, "Fusion/research"), "research_web_search"); assert.equal(fusionWebSearchToolNameForRequest(config, "gpt-5"), undefined); });