feat: web search via Tavily with short-circuit response

This commit is contained in:
810senpai114514
2026-07-12 00:01:29 +08:00
parent 990bc0d958
commit 48746183cf
2 changed files with 288 additions and 23 deletions
+286 -21
View File
@@ -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<string, unknown>[] = [];
content.push({
type: "server_tool_use",
id: toolUseId,
name: "web_search",
input: { query: queryHint }
});
const resultItems: Record<string, unknown>[] = [];
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<string, unknown> = {
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<string, string>
): 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<string, string>
): 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<string, string>;
name: string;
proxyPreloadFile?: string;
proxyEnv?: Record<string, string>;
}): 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<BrowserWebSearchProtocolRecord[]> {
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<BrowserWebSearchProtocolRecord[]> {
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<string, unknown> = await response.json() as Record<string, unknown>;
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<string, unknown>; 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<BrowserWebSearchProtocolRecord[]> {
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<string, unknown> = await response.json() as Record<string, unknown>;
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<string, unknown>; 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<BrowserWebSearchProtocolRecord[]> {
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<string, unknown> = await response.json() as Record<string, unknown>;
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<string, unknown>; 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<BrowserWebSearchProtocolRecord[]> {
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<string, unknown> = await response.json() as Record<string, unknown>;
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<string, unknown>; 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<BrowserWebSearchProtocolRecord[]> {
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<string, unknown> = await response.json() as Record<string, unknown>;
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<string, unknown>; 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<BrowserWebSearchProtocolRecord[]> {
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<string, unknown> = await response.json() as Record<string, unknown>;
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<string, unknown>; 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<BrowserWebSearchProtocolRecord[]> {
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<string, unknown> = await response.json() as Record<string, unknown>;
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<string, unknown>; 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
+2 -2
View File
@@ -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);
});