diff --git a/build/esbuild.config.mjs b/build/esbuild.config.mjs index 9bdaba7..bcf3a1a 100644 --- a/build/esbuild.config.mjs +++ b/build/esbuild.config.mjs @@ -27,7 +27,7 @@ export const trayRendererHtmlOutput = path.join(rendererOutDir, "pages", "tray", export const cssInput = path.join(rendererRoot, "styles", "globals.css"); export const cssOutput = path.join(rendererAssetsDir, "main.css"); export const webClientBridgeOutput = path.join(rendererAssetsDir, "web-client-bridge.js"); -const lightweightMcpBundleNames = ["fusion-vision-mcp.js", "fusion-tool-fallback-mcp.js"]; +const lightweightMcpBundleNames = ["browser-web-search-proxy-mcp.js", "fusion-vision-mcp.js", "fusion-tool-fallback-mcp.js"]; const lightweightMcpBundleMaxBytes = 128 * 1024; const forbiddenLightweightMcpInputs = [ { prefix: "src/main/", reason: "main-process modules can pull in config, Electron, or native storage side effects" }, @@ -120,6 +120,7 @@ export function createMainBuildOptions({ mode = "production", plugins = [] } = { entryPoints: [ path.join(projectRoot, "src", "main", "main.ts"), path.join(projectRoot, "src", "main", "browser-preload.ts"), + path.join(projectRoot, "src", "server", "mcp", "browser-web-search-proxy-mcp.ts"), path.join(projectRoot, "src", "server", "mcp", "fusion-vision-mcp.ts"), path.join(projectRoot, "src", "server", "mcp", "fusion-tool-fallback-mcp.ts"), path.join(projectRoot, "src", "main", "preload.ts") diff --git a/src/main/codex-model-catalog.ts b/src/main/codex-model-catalog.ts index 1c87b84..841f553 100644 --- a/src/main/codex-model-catalog.ts +++ b/src/main/codex-model-catalog.ts @@ -415,11 +415,18 @@ function virtualModelProfileSupportsFusionWebSearch(profile: VirtualModelProfile return (profile.tools ?? []).some((tool) => { const name = tool.name.trim(); - return name === BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME || - name.startsWith(`${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}_`); + return fusionWebSearchToolNameMatches(name); }); } +function fusionWebSearchToolNameMatches(name: string): boolean { + const normalized = name.toLowerCase().replace(/[-.]/g, "_"); + return normalized === BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME || + normalized.startsWith(`${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}_`) || + normalized.endsWith(`_${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}`) || + normalized.includes("search_web"); +} + function recordValue(value: unknown): Record | undefined { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record diff --git a/src/main/electron-web-search-mcp.ts b/src/main/electron-web-search-mcp.ts new file mode 100644 index 0000000..8fa90cd --- /dev/null +++ b/src/main/electron-web-search-mcp.ts @@ -0,0 +1,1265 @@ +import { BrowserWindow, WebContentsView, app, session, type WebContents } from "electron"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { join as pathJoin } from "node:path"; +import { backendService } from "../server/backend-service"; +import type { GatewayMcpServerConfig } from "../shared/app"; +import type { + BrowserWebSearchMcpIntegration, + BrowserWebSearchMcpRegistration, + BrowserWebSearchProtocolRecord +} from "../server/gateway/service"; + +type JsonPrimitive = boolean | null | number | string; +type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +type JsonRpcRequest = { + id?: null | number | string; + jsonrpc?: string; + method?: string; + params?: unknown; +}; + +type JsonRpcResponse = + | { + id: null | number | string; + jsonrpc: "2.0"; + result: JsonValue; + } + | { + error: { + code: number; + data?: JsonValue; + message: string; + }; + id: null | number | string; + jsonrpc: "2.0"; + }; + +type ToolCallResult = { + content: Array<{ text: string; type: "text" }>; + isError?: boolean; +}; + +type BrowserSearchEngine = "bing" | "duckduckgo" | "google"; +type BrowserSearchFreshness = "day" | "month" | "week" | "year"; + +type BrowserSearchInput = { + after?: string; + anyTerms: string[]; + before?: string; + count: number; + country?: string; + engine: BrowserSearchEngine; + exactPhrase?: string; + excludeDomains: string[]; + excludeTerms: string[]; + freshness?: BrowserSearchFreshness; + includeDomains: string[]; + includeRaw: boolean; + keywords: string[]; + language?: string; + prompt: string; + safeSearch?: "moderate" | "off" | "strict"; + timeoutMs: number; +}; + +type BrowserSearchRequest = BrowserSearchInput & { + query: string; + searchUrl: string; +}; + +type BrowserSearchResult = { + content?: string; + snippet?: string; + title: string; + url: string; +}; + +type BrowserSearchPageResult = { + blocked?: string; + results?: BrowserSearchResult[]; + title?: string; +}; + +type BrowserSearchResponse = { + engine: BrowserSearchEngine; + query: string; + results: BrowserSearchResult[]; + searchUrl: string; +}; + +type BrowserSearchWorker = { + busy: boolean; + view: WebContentsView; +}; + +type BrowserSearchQueueEntry = { + reject: (error: Error) => void; + resolve: () => void; +}; + +const ownerId = "ccr-browser-web-search-mcp"; +const protocolVersion = "2024-11-05"; +const maxMcpRequestBytes = 2 * 1024 * 1024; +const defaultResultCount = 5; +const defaultTimeoutMs = 30_000; +const maxSearchResultCount = 20; +const searchPartition = "persist:ccr-browser-web-search-mcp"; +const defaultEngine: BrowserSearchEngine = "bing"; +const desktopUserAgent = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"; + +function bundledBrowserWebSearchProxyMcpEntryPath(): string { + return pathJoin(__dirname, "browser-web-search-proxy-mcp.js"); +} + +class BrowserWebSearchMcpService implements BrowserWebSearchMcpIntegration { + private readonly recentResults: BrowserWebSearchProtocolRecord[] = []; + private readonly servers = new Map(); + private readonly searchPool = new HiddenBrowserSearchPool(); + + async registerBrowserWebSearchMcpServer(options: BrowserWebSearchMcpRegistration): Promise { + await app.whenReady(); + const existing = this.servers.get(options.toolName); + if (existing) { + return existing.mcpConfig(); + } + + const server = new BrowserWebSearchToolServer(options, this.searchPool, (record) => this.recordSearchResult(record)); + const backend = await backendService.registerHttpBackend(ownerId, { + handler: (request, response) => server.handleRequest(request, response), + id: `${ownerId}:${options.name}` + }); + server.setUrl(`${backend.url}/mcp`); + this.servers.set(options.toolName, server); + return server.mcpConfig(); + } + + recentBrowserWebSearchResults(options: { sinceMs: number; toolName?: string }): BrowserWebSearchProtocolRecord[] { + this.pruneRecentResults(); + return this.recentResults.filter((record) => { + if (record.completedAtMs < options.sinceMs) { + return false; + } + return !options.toolName || record.toolName === options.toolName; + }); + } + + async runBrowserWebSearch(options: { count?: number; prompt: string; timeoutMs?: number; toolName?: string }): Promise { + const server = options.toolName ? this.servers.get(options.toolName) : this.servers.values().next().value; + return await server?.searchForProtocolResult(options); + } + + async stopBrowserWebSearchMcpServers(): Promise { + this.servers.clear(); + this.recentResults.splice(0); + await backendService.stopOwner(ownerId); + this.searchPool.destroy(); + } + + private recordSearchResult(record: BrowserWebSearchProtocolRecord): void { + this.recentResults.push(record); + this.pruneRecentResults(); + } + + private pruneRecentResults(): void { + const cutoff = Date.now() - 5 * 60_000; + while (this.recentResults.length > 0 && (this.recentResults[0].completedAtMs < cutoff || this.recentResults.length > 50)) { + this.recentResults.shift(); + } + } +} + +class BrowserWebSearchToolServer { + private readonly defaultCount: number; + private readonly defaultEngine: BrowserSearchEngine; + private readonly defaultLanguage?: string; + private readonly defaultCountry?: string; + private readonly defaultSafeSearch?: "moderate" | "off" | "strict"; + private readonly defaultTimeoutMs: number; + private readonly toolName: string; + private url = ""; + + constructor( + options: BrowserWebSearchMcpRegistration, + private readonly searchPool: HiddenBrowserSearchPool, + private readonly recordSearchResult: (record: BrowserWebSearchProtocolRecord) => void + ) { + const env = options.env ?? {}; + this.toolName = options.toolName; + this.defaultCount = clampInteger( + options.resultCount ?? readNumber(env.BROWSER_SEARCH_RESULT_COUNT) ?? readNumber(env.SEARCH_RESULT_COUNT) ?? defaultResultCount, + 1, + maxSearchResultCount + ); + this.defaultEngine = parseSearchEngine(env.BROWSER_SEARCH_ENGINE || env.SEARCH_ENGINE) ?? defaultEngine; + this.defaultLanguage = readString(env.BROWSER_SEARCH_LANGUAGE || env.SEARCH_LANGUAGE); + this.defaultCountry = readString(env.BROWSER_SEARCH_COUNTRY || env.SEARCH_COUNTRY); + this.defaultSafeSearch = parseSafeSearch(env.BROWSER_SEARCH_SAFE_SEARCH || env.SEARCH_SAFE_SEARCH); + this.defaultTimeoutMs = clampInteger( + options.timeoutMs ?? readNumber(env.BROWSER_SEARCH_TIMEOUT_MS) ?? readNumber(env.SEARCH_TIMEOUT_MS) ?? defaultTimeoutMs, + 100, + 600_000 + ); + } + + setUrl(url: string): void { + this.url = url; + } + + mcpConfig(): GatewayMcpServerConfig { + const requestTimeoutMs = clampInteger(this.defaultTimeoutMs + 5_000, 1_000, 600_000); + return { + args: [bundledBrowserWebSearchProxyMcpEntryPath()], + command: process.execPath, + env: { + BROWSER_WEB_SEARCH_MCP_URL: this.url, + BROWSER_WEB_SEARCH_PROXY_TIMEOUT_MS: String(requestTimeoutMs), + ELECTRON_RUN_AS_NODE: "1" + }, + name: this.toolName, + protocolVersion, + requestTimeoutMs, + startupTimeoutMs: 60_000, + stdioMessageMode: "content-length", + transport: "stdio" + }; + } + + async handleRequest(request: IncomingMessage, response: ServerResponse): Promise { + response.setHeader("MCP-Protocol-Version", protocolVersion); + const path = request.url ? new URL(request.url, this.url || "http://127.0.0.1").pathname : "/"; + + if (request.method === "GET" && (path === "/mcp" || path === "/mcp/")) { + sendJson(response, 200, { + endpoint: "/mcp", + name: this.toolName, + protocol: "mcp", + transport: "streamable-http" + }); + return; + } + + if (request.method !== "POST" || (path !== "/mcp" && path !== "/mcp/")) { + sendJson(response, 404, { error: { message: "In-app browser web search MCP endpoint not found." } }); + return; + } + + let payload: unknown; + try { + payload = JSON.parse((await readRequestBody(request, maxMcpRequestBytes)).toString("utf8")) as unknown; + } catch (error) { + sendJson(response, 400, jsonRpcError(null, -32700, `Invalid JSON-RPC request: ${formatError(error)}`)); + return; + } + + const requests = Array.isArray(payload) ? payload : [payload]; + const responses = await Promise.all(requests.map((item) => this.handleJsonRpcRequest(item))); + const filtered = responses.filter((item): item is JsonRpcResponse => Boolean(item)); + if (filtered.length === 0) { + response.writeHead(204); + response.end(); + return; + } + + sendJson(response, 200, Array.isArray(payload) ? filtered : filtered[0]); + } + + private async handleJsonRpcRequest(payload: unknown): Promise { + if (!isRecord(payload)) { + return jsonRpcError(null, -32600, "JSON-RPC request must be an object."); + } + + const request = payload as JsonRpcRequest; + const id = request.id ?? null; + if (request.id === undefined && request.method?.startsWith("notifications/")) { + return undefined; + } + if (request.jsonrpc !== "2.0" || !request.method) { + return jsonRpcError(id, -32600, "Invalid JSON-RPC 2.0 request."); + } + + try { + switch (request.method) { + case "initialize": + return jsonRpcResult(id, { + capabilities: { + tools: {} + }, + protocolVersion, + serverInfo: { + name: "ccr-browser-web-search", + title: "CCR In-app Browser Web Search", + version: "1.0.0" + } + }); + case "ping": + return jsonRpcResult(id, {}); + case "tools/list": + return jsonRpcResult(id, { tools: [this.tool()] as unknown as JsonValue }); + case "tools/call": + return jsonRpcResult(id, await this.callTool(request.params) as unknown as JsonValue); + default: + return jsonRpcError(id, -32601, `Unsupported MCP method: ${request.method}`); + } + } catch (error) { + return jsonRpcError(id, -32603, formatError(error)); + } + } + + private tool(): JsonValue { + return { + description: + "Search the web through a hidden in-app browser window. " + + "Supports Google, Bing, and DuckDuckGo plus advanced query controls such as domains, keywords, phrases, excluded terms, and date filters.", + inputSchema: objectSchema({ + after: { description: "Earliest result date as YYYY-MM-DD. Also added to the engine query as after:YYYY-MM-DD.", type: "string" }, + allowedDomains: { description: "Alias for includeDomains; compatible with Anthropic web search declarations.", items: { type: "string" }, type: "array" }, + allowed_domains: { description: "Alias for includeDomains; compatible with Anthropic web_search_20250305.", items: { type: "string" }, type: "array" }, + anyTerms: { description: "Terms where any one may match; encoded as OR terms.", items: { type: "string" }, type: "array" }, + before: { description: "Latest result date as YYYY-MM-DD. Also added to the engine query as before:YYYY-MM-DD.", type: "string" }, + blockedDomains: { description: "Alias for excludeDomains; compatible with Anthropic web search declarations.", items: { type: "string" }, type: "array" }, + blocked_domains: { description: "Alias for excludeDomains; compatible with Anthropic web_search_20250305.", items: { type: "string" }, type: "array" }, + count: { maximum: maxSearchResultCount, minimum: 1, type: "number" }, + country: { description: "Country/region hint such as US, CN, GB.", type: "string" }, + domains: { description: "Alias for includeDomains.", items: { type: "string" }, type: "array" }, + engine: { enum: ["auto", "bing", "google", "duckduckgo"], type: "string" }, + exactPhrase: { description: "Phrase to search exactly.", type: "string" }, + excludeDomain: { description: "Single domain alias for excludeDomains.", type: "string" }, + excludeDomains: { description: "Domains to exclude with -site:domain.", items: { type: "string" }, type: "array" }, + excludeTerms: { description: "Terms to exclude with a leading minus.", items: { type: "string" }, type: "array" }, + freshness: { description: "Relative time filter.", enum: ["day", "week", "month", "year"], type: "string" }, + includeDomains: { description: "Domains to restrict with site:domain.", items: { type: "string" }, type: "array" }, + includeRaw: { description: "Include the engine search URL in the text response.", type: "boolean" }, + keywords: { description: "Additional required keywords. A comma-separated string is also accepted.", items: { type: "string" }, type: "array" }, + language: { description: "Language hint such as en, zh-CN, ja.", type: "string" }, + prompt: { description: "Natural-language query. Search engine operators already present here are preserved.", type: "string" }, + query: { description: "Alias for prompt.", type: "string" }, + safeSearch: { enum: ["off", "moderate", "strict"], type: "string" }, + site: { description: "Single domain alias for includeDomains.", type: "string" }, + timeRange: { description: "Alias for freshness.", enum: ["day", "week", "month", "year"], type: "string" }, + timeoutMs: { minimum: 100, type: "number" } + }, ["prompt"]), + name: this.toolName, + title: "In-app Browser Web Search" + }; + } + + private async callTool(params: unknown): Promise { + if (!isRecord(params) || typeof params.name !== "string") { + throw new Error("tools/call params must include a tool name."); + } + if (params.name !== this.toolName) { + throw new Error(`Unknown in-app browser web search tool: ${params.name}`); + } + + const args = isRecord(params.arguments) ? params.arguments : {}; + try { + const input = this.readSearchInput(args); + const record = await this.search(input); + return textResult(formatSearchResponse(record, input.includeRaw)); + } catch (error) { + return { + ...textResult(formatError(error)), + isError: true + }; + } + } + + async searchForProtocolResult(options: { count?: number; prompt: string; timeoutMs?: number }): Promise { + return await this.search(this.readSearchInput({ + count: options.count, + prompt: options.prompt, + timeoutMs: options.timeoutMs + })); + } + + private async search(input: BrowserSearchInput): Promise { + const response = await this.searchPool.search(input); + const record = { + completedAtMs: Date.now(), + engine: response.engine, + query: response.query, + results: response.results, + searchUrl: response.searchUrl, + toolName: this.toolName + }; + this.recordSearchResult(record); + return record; + } + + private readSearchInput(args: Record): BrowserSearchInput { + const prompt = readString(args.prompt) || readString(args.query); + if (!prompt) { + throw new Error(`${this.toolName} requires prompt.`); + } + + const engine = parseSearchEngine(readString(args.engine)) ?? this.defaultEngine; + const includeDomains = uniqueStrings([ + ...readStringArray(args.includeDomains), + ...readStringArray(args.domains), + ...readStringArray(args.domain), + ...readStringArray(args.site), + ...readStringArray(args.allowedDomains), + ...readStringArray(args.allowed_domains) + ].map(normalizeDomain).filter((item): item is string => Boolean(item))); + const excludeDomains = uniqueStrings([ + ...readStringArray(args.excludeDomains), + ...readStringArray(args.excludeDomain), + ...readStringArray(args.blockedDomains), + ...readStringArray(args.blocked_domains) + ].map(normalizeDomain).filter((item): item is string => Boolean(item))); + + return { + after: normalizeSearchDate(readString(args.after) || readString(args.since) || readString(args.fromDate)), + anyTerms: readStringArray(args.anyTerms), + before: normalizeSearchDate(readString(args.before) || readString(args.until) || readString(args.toDate)), + count: clampInteger(readNumber(args.count) ?? this.defaultCount, 1, maxSearchResultCount), + country: readString(args.country) || this.defaultCountry, + engine, + exactPhrase: readString(args.exactPhrase), + excludeDomains, + excludeTerms: readStringArray(args.excludeTerms), + freshness: parseFreshness(readString(args.freshness) || readString(args.timeRange)), + includeDomains, + includeRaw: args.includeRaw === true, + keywords: readStringArray(args.keywords), + language: readString(args.language) || this.defaultLanguage, + prompt, + safeSearch: parseSafeSearch(readString(args.safeSearch)) ?? this.defaultSafeSearch, + timeoutMs: clampInteger(readNumber(args.timeoutMs) ?? this.defaultTimeoutMs, 100, 600_000) + }; + } +} + +class HiddenBrowserSearchPool { + private configuredSession = false; + private queue: BrowserSearchQueueEntry[] = []; + private window?: BrowserWindow; + private workers: BrowserSearchWorker[] = []; + + async search(input: BrowserSearchInput): Promise { + const request = buildBrowserSearchRequest(input); + const worker = await this.acquire(request.timeoutMs); + let page: BrowserSearchPageResult; + try { + page = await runSearchInWorker(worker, request); + } finally { + this.release(worker); + } + if (page.blocked) { + throw new Error(page.blocked); + } + const results = uniqueSearchResults(page.results ?? []).slice(0, request.count); + return { + engine: request.engine, + query: request.query, + results: await this.enrichResults(results, request), + searchUrl: request.searchUrl + }; + } + + destroy(): void { + this.queue.splice(0).forEach((entry) => entry.reject(new Error("Browser search service stopped."))); + this.queue = []; + for (const worker of this.workers) { + if (!worker.view.webContents.isDestroyed()) { + worker.view.webContents.close({ waitForBeforeUnload: false }); + } + } + this.workers = []; + if (this.window && !this.window.isDestroyed()) { + this.window.destroy(); + } + this.window = undefined; + this.configuredSession = false; + } + + private async acquire(timeoutMs: number): Promise { + await app.whenReady(); + this.ensureWindow(); + const maxWorkers = browserSearchConcurrency(); + for (;;) { + const idle = this.workers.find((worker) => !worker.busy); + if (idle) { + idle.busy = true; + return idle; + } + if (this.workers.length < maxWorkers) { + const worker = this.createWorker(); + worker.busy = true; + return worker; + } + await waitForQueueTurn(this.queue, timeoutMs); + } + } + + private release(worker: BrowserSearchWorker): void { + worker.busy = false; + const next = this.queue.shift(); + next?.resolve(); + } + + private ensureWindow(): BrowserWindow { + if (this.window && !this.window.isDestroyed()) { + return this.window; + } + this.configureSearchSession(); + this.window = new BrowserWindow({ + height: 900, + paintWhenInitiallyHidden: true, + show: false, + skipTaskbar: true, + title: "CCR In-app Browser Web Search", + webPreferences: { + backgroundThrottling: false, + contextIsolation: true, + images: false, + nodeIntegration: false, + sandbox: true, + webSecurity: true + }, + width: 1280 + }); + this.window.on("closed", () => { + this.workers = []; + this.window = undefined; + }); + return this.window; + } + + private createWorker(): BrowserSearchWorker { + const window = this.ensureWindow(); + const view = new WebContentsView({ + webPreferences: { + backgroundThrottling: false, + contextIsolation: true, + images: false, + nodeIntegration: false, + partition: searchPartition, + sandbox: true, + webSecurity: true + } + }); + view.webContents.setAudioMuted(true); + view.webContents.on("console-message", (event) => { + event.preventDefault(); + }); + view.setBounds({ height: 900, width: 1280, x: 0, y: 0 }); + window.contentView.addChildView(view); + const worker = { busy: false, view }; + this.workers.push(worker); + return worker; + } + + private configureSearchSession(): void { + if (this.configuredSession) { + return; + } + const browserSession = session.fromPartition(searchPartition); + browserSession.setUserAgent(desktopUserAgent); + browserSession.webRequest.onBeforeRequest((details, callback) => { + if (details.resourceType === "image" || details.resourceType === "media" || details.resourceType === "font") { + callback({ cancel: true }); + return; + } + callback({}); + }); + this.configuredSession = true; + } + + private async enrichResults(results: BrowserSearchResult[], request: BrowserSearchRequest): Promise { + const openCount = Math.min(results.length, browserSearchOpenResultCount()); + if (openCount <= 0) { + return results; + } + const enriched = await Promise.all(results.slice(0, openCount).map((result) => this.enrichResult(result, request))); + return results.map((result, index) => enriched[index] ?? result); + } + + private async enrichResult(result: BrowserSearchResult, request: BrowserSearchRequest): Promise { + if (!shouldOpenSearchResult(result.url)) { + return result; + } + const timeoutMs = browserSearchPageTimeoutMs(request.timeoutMs); + let worker: BrowserSearchWorker | undefined; + try { + worker = await this.acquire(timeoutMs); + await loadSearchUrl(worker.view.webContents, result.url, timeoutMs); + const page = await withTimeout( + worker.view.webContents.executeJavaScript(pageContentExtractionScript(), true) as Promise<{ description?: string; text?: string; title?: string }>, + timeoutMs, + "Browser result page extraction timed out." + ); + const content = compactExtractedContent(page.text); + const snippet = result.snippet || compactExtractedContent(page.description); + return { + ...result, + ...(content ? { content } : {}), + ...(snippet ? { snippet } : {}), + ...(page.title && page.title.length > result.title.length ? { title: page.title.slice(0, 240) } : {}) + }; + } catch { + return result; + } finally { + if (worker) { + this.release(worker); + } + } + } +} + +async function runSearchInWorker(worker: BrowserSearchWorker, request: BrowserSearchRequest): Promise { + const webContents = worker.view.webContents; + if (webContents.isDestroyed()) { + throw new Error("Browser search view is unavailable."); + } + + await loadSearchUrl(webContents, request.searchUrl, request.timeoutMs); + return await pollSearchResults(webContents, request); +} + +async function loadSearchUrl(webContents: WebContents, url: string, timeoutMs: number): Promise { + webContents.stop(); + await withTimeout( + webContents.loadURL(url, { + userAgent: desktopUserAgent + }), + timeoutMs, + "Browser search navigation timed out." + ); +} + +async function pollSearchResults(webContents: WebContents, request: BrowserSearchRequest): Promise { + const deadline = Date.now() + request.timeoutMs; + let last: BrowserSearchPageResult = { results: [] }; + while (Date.now() < deadline) { + last = await webContents.executeJavaScript(searchResultExtractionScript(request.engine, request.count), true) as BrowserSearchPageResult; + if (last.blocked || (last.results?.length ?? 0) >= Math.min(request.count, 3)) { + return last; + } + await sleep(150); + } + return last; +} + +function buildBrowserSearchRequest(input: BrowserSearchInput): BrowserSearchRequest { + const query = buildAdvancedSearchQuery(input); + const searchUrl = searchUrlForEngine(input, query); + return { + ...input, + query, + searchUrl + }; +} + +function searchUrlForEngine(input: BrowserSearchInput, query: string): string { + if (input.engine === "google") { + const url = new URL("https://www.google.com/search"); + url.searchParams.set("q", query); + url.searchParams.set("num", String(Math.min(input.count, 10))); + url.searchParams.set("filter", "0"); + url.searchParams.set("pws", "0"); + url.searchParams.set("udm", "14"); + if (input.language) url.searchParams.set("hl", input.language); + if (input.country) url.searchParams.set("gl", input.country); + if (input.safeSearch) url.searchParams.set("safe", input.safeSearch === "off" ? "off" : "active"); + const tbs = googleTimeFilter(input); + if (tbs) url.searchParams.set("tbs", tbs); + return url.toString(); + } + + if (input.engine === "duckduckgo") { + const url = new URL("https://duckduckgo.com/"); + url.searchParams.set("q", query); + url.searchParams.set("ia", "web"); + if (input.language) url.searchParams.set("kl", input.language); + if (input.safeSearch) url.searchParams.set("kp", input.safeSearch === "off" ? "-2" : "1"); + const df = duckDuckGoTimeFilter(input.freshness); + if (df) url.searchParams.set("df", df); + return url.toString(); + } + + const url = new URL("https://www.bing.com/search"); + url.searchParams.set("q", query); + url.searchParams.set("count", String(input.count)); + url.searchParams.set("qs", "n"); + url.searchParams.set("form", "QBRE"); + if (input.language) url.searchParams.set("setlang", input.language); + if (input.country) url.searchParams.set("cc", input.country); + if (input.safeSearch) url.searchParams.set("safeSearch", bingSafeSearch(input.safeSearch)); + const qft = bingTimeFilter(input.freshness); + if (qft) url.searchParams.set("qft", qft); + return url.toString(); +} + +function buildAdvancedSearchQuery(input: BrowserSearchInput): string { + const parts = [input.prompt.trim()]; + parts.push(...input.keywords.map(searchTerm)); + if (input.exactPhrase) { + parts.push(quoteSearchPhrase(input.exactPhrase)); + } + if (input.anyTerms.length > 0) { + parts.push(`(${input.anyTerms.map(searchTerm).join(" OR ")})`); + } + parts.push(...input.excludeTerms.map((term) => `-${searchTerm(term)}`)); + if (input.includeDomains.length === 1) { + parts.push(`site:${input.includeDomains[0]}`); + } else if (input.includeDomains.length > 1) { + parts.push(`(${input.includeDomains.map((domain) => `site:${domain}`).join(" OR ")})`); + } + parts.push(...input.excludeDomains.map((domain) => `-site:${domain}`)); + if (input.after) { + parts.push(`after:${input.after}`); + } + if (input.before) { + parts.push(`before:${input.before}`); + } + return parts.filter(Boolean).join(" ").replace(/\s+/g, " ").trim(); +} + +function searchTerm(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + return /\s/.test(trimmed) && !/^".*"$/.test(trimmed) ? quoteSearchPhrase(trimmed) : trimmed; +} + +function quoteSearchPhrase(value: string): string { + return `"${value.trim().replace(/"/g, " ")}"`; +} + +function googleTimeFilter(input: BrowserSearchInput): string | undefined { + if (input.after || input.before) { + const parts = ["cdr:1"]; + if (input.after) parts.push(`cd_min:${dateToGoogleDate(input.after)}`); + if (input.before) parts.push(`cd_max:${dateToGoogleDate(input.before)}`); + return parts.join(","); + } + if (input.freshness === "day") return "qdr:d"; + if (input.freshness === "week") return "qdr:w"; + if (input.freshness === "month") return "qdr:m"; + if (input.freshness === "year") return "qdr:y"; + return undefined; +} + +function bingTimeFilter(value: BrowserSearchFreshness | undefined): string | undefined { + if (value === "day") return "+filterui:age-lt1440"; + if (value === "week") return "+filterui:age-lt10080"; + if (value === "month") return "+filterui:age-lt43200"; + if (value === "year") return "+filterui:age-lt525600"; + return undefined; +} + +function duckDuckGoTimeFilter(value: BrowserSearchFreshness | undefined): string | undefined { + if (value === "day") return "d"; + if (value === "week") return "w"; + if (value === "month") return "m"; + if (value === "year") return "y"; + return undefined; +} + +function bingSafeSearch(value: "moderate" | "off" | "strict"): string { + if (value === "strict") return "Strict"; + if (value === "off") return "Off"; + return "Moderate"; +} + +function dateToGoogleDate(value: string): string { + const [year, month, day] = value.split("-"); + return `${month}/${day}/${year}`; +} + +function searchResultExtractionScript(engine: BrowserSearchEngine, count: number): string { + return `(() => { + const engine = ${JSON.stringify(engine)}; + const maxResults = ${JSON.stringify(count)}; + const results = []; + const seen = new Set(); + const blockedText = detectBlocked(); + if (blockedText) return { blocked: blockedText, results, title: document.title || "" }; + + function compact(value) { + return String(value || "").replace(/\\s+/g, " ").trim(); + } + function absoluteUrl(href) { + try { return new URL(href, location.href); } catch (_) { return undefined; } + } + function decodeMaybeBase64(value) { + if (!value) return ""; + let raw = value.replace(/-/g, "+").replace(/_/g, "/"); + if (raw.startsWith("a1")) raw = raw.slice(2); + try { + const decoded = atob(raw); + return /^https?:\\/\\//i.test(decoded) ? decoded : ""; + } catch (_) { + return ""; + } + } + function normalizeResultUrl(href) { + const url = absoluteUrl(href); + if (!url || !/^https?:$/i.test(url.protocol)) return ""; + const host = url.hostname.toLowerCase(); + if (host.includes("google.") && url.pathname === "/url") { + const q = url.searchParams.get("q") || url.searchParams.get("url"); + if (q) return normalizeResultUrl(q); + } + if (host.endsWith("bing.com") && url.pathname.startsWith("/ck/")) { + const raw = url.searchParams.get("u"); + const decoded = decodeMaybeBase64(raw || ""); + if (decoded) return normalizeResultUrl(decoded); + } + if (host.endsWith("duckduckgo.com") && url.pathname.startsWith("/l/")) { + const target = url.searchParams.get("uddg"); + if (target) return normalizeResultUrl(target); + } + if (isSearchChromeUrl(url)) return ""; + url.hash = ""; + return url.toString(); + } + function isSearchChromeUrl(url) { + const host = url.hostname.toLowerCase(); + const path = url.pathname.toLowerCase(); + if (host === location.hostname.toLowerCase() && (path === "/" || path === "/search" || path.startsWith("/search/"))) return true; + if (host.includes("google.") && /\\/(preferences|advanced_search|intl|policies|support|sorry)/.test(path)) return true; + if (host.endsWith("bing.com") && /\\/(search|account|profile|images|videos|maps|news)/.test(path)) return true; + if (host.endsWith("duckduckgo.com") && path === "/") return true; + return false; + } + function resultContainer(anchor, title) { + let node = anchor; + for (let depth = 0; node && depth < 8; depth += 1, node = node.parentElement) { + const text = compact(node.textContent || ""); + if (text.length > title.length + 40) return node; + } + return anchor.closest("li.b_algo, article, div.g, div[data-sokoban-container], div[data-testid='result'], .result, li, div"); + } + function cleanSnippetCandidate(value, title, url) { + const host = (() => { try { return new URL(url).hostname.replace(/^www\\./, ""); } catch (_) { return ""; } })(); + return compact(String(value || "") + .replace(title, " ") + .replace(url, " ") + .replace(host, " ") + .replace(/https?:\\/\\/\\S+/g, " ") + .replace(/\\b(?:Cached|Translate this page|Similar)\\b/gi, " ")); + } + function breadcrumbLike(value) { + const text = compact(value); + if (!text) return true; + if (/https?:\\/\\//i.test(text)) return true; + if (text.length < 35 && /[›/]|^https?:|^www\\./i.test(text)) return true; + if (/[›]/.test(text) && !/[.!?,。!?]/.test(text)) return true; + if (!/[.!?:;,。!?:;]/.test(text) && /^[\\w\\s.:-]+(?:[›/]\\s*[\\w\\s.:-]+)+$/i.test(text)) return true; + return false; + } + function snippetFor(anchor, container, title, url) { + const preferred = container && container.querySelector(".b_caption p, .b_snippet, [data-sncf], .VwiC3b, .IsZvec, [data-result='snippet'], .result__snippet, p"); + const preferredText = cleanSnippetCandidate(preferred && preferred.textContent, title, url); + if (preferredText && !breadcrumbLike(preferredText)) return preferredText.slice(0, 700); + const candidates = container + ? Array.from(container.querySelectorAll("p, span, div")) + .map((node) => cleanSnippetCandidate(node.textContent, title, url)) + .filter((text) => text.length >= 35 && !breadcrumbLike(text)) + : []; + const text = candidates.find((candidate) => candidate.length >= 50) || candidates[0] || ""; + if (!text) return ""; + return text.slice(0, 700); + } + function titleFor(anchor, titleOverride) { + const explicit = compact(titleOverride); + if (explicit && explicit.length >= 2) return explicit; + const heading = anchor && anchor.querySelector("h1, h2, h3, [role='heading']"); + return compact((heading && heading.textContent) || (anchor && (anchor.textContent || anchor.innerText))); + } + function add(anchor, titleOverride) { + if (!anchor || results.length >= maxResults) return; + const href = anchor.getAttribute("href") || ""; + const url = normalizeResultUrl(href); + if (!url || seen.has(url)) return; + const title = titleFor(anchor, titleOverride); + if (!title || title.length < 2) return; + const container = resultContainer(anchor, title); + seen.add(url); + results.push({ + snippet: snippetFor(anchor, container, title, url), + title: title.slice(0, 240), + url + }); + } + function collect(selector) { + document.querySelectorAll(selector).forEach((node) => { + const anchor = node.matches && node.matches("a[href]") ? node : node.closest && node.closest("a[href]"); + add(anchor, node.matches && node.matches("h1, h2, h3, [role='heading']") ? node.textContent : ""); + }); + } + function collectGoogle() { + collect("#search h3"); + collect("#rso h3"); + collect("a[href] h3"); + } + function collectBing() { + collect("li.b_algo h2 a[href]"); + collect("main ol li h2 a[href]"); + } + function collectDuckDuckGo() { + collect("a[data-testid='result-title-a']"); + collect(".result__a"); + collect("article h2 a[href]"); + } + if (engine === "google") collectGoogle(); + if (engine === "bing") collectBing(); + if (engine === "duckduckgo") collectDuckDuckGo(); + collect("main a[href], #search a[href], #b_results a[href], article a[href], a[href]"); + return { results, title: document.title || "" }; + + function detectBlocked() { + const text = compact(document.body && document.body.innerText).toLowerCase(); + if (!text) return ""; + if (text.includes("unusual traffic") || text.includes("detected unusual") || text.includes("not a robot") || text.includes("captcha")) { + return "Search engine returned an anti-bot or CAPTCHA page."; + } + if (text.includes("before you continue to google") || text.includes("consent.google")) { + return "Google returned a consent page instead of search results."; + } + return ""; + } + })();`; +} + +function pageContentExtractionScript(): string { + return `(() => { + function compact(value) { + return String(value || "").replace(/[\\t\\f\\v ]+/g, " ").replace(/\\n\\s*\\n+/g, "\\n").trim(); + } + function textFrom(node) { + if (!node) return ""; + const clone = node.cloneNode(true); + clone.querySelectorAll("script, style, noscript, svg, nav, header, footer, aside, form, button, input, select, textarea, iframe, [aria-hidden='true']").forEach((item) => item.remove()); + const lines = String(clone.innerText || clone.textContent || "") + .split(/\\n+/) + .map((line) => compact(line)) + .filter((line) => line.length >= 20) + .filter((line, index, array) => array.indexOf(line) === index); + return lines.join("\\n"); + } + function scoreText(text) { + const length = text.length; + const sentenceMarks = (text.match(/[.!?。!?]/g) || []).length; + return length + sentenceMarks * 80; + } + const title = compact( + document.querySelector("meta[property='og:title']")?.getAttribute("content") || + document.querySelector("h1")?.textContent || + document.title || + "" + ); + const description = compact( + document.querySelector("meta[name='description']")?.getAttribute("content") || + document.querySelector("meta[property='og:description']")?.getAttribute("content") || + "" + ); + const candidates = [ + ...document.querySelectorAll("article, main, [role='main'], .article, .article-content, .post, .post-content, .entry-content, .content, #content, .main-content") + ].map(textFrom).filter(Boolean); + const fallback = textFrom(document.body); + const text = [...candidates, fallback] + .filter(Boolean) + .sort((left, right) => scoreText(right) - scoreText(left))[0] || ""; + return { + description, + text: text.slice(0, 5000), + title + }; + })();`; +} + +function formatSearchResponse( + response: Pick & { engine: string }, + includeRaw: boolean +): string { + if (response.results.length === 0) { + return [ + `Search engine: ${response.engine}`, + `Query: ${response.query}`, + includeRaw ? `Search URL: ${response.searchUrl}` : "", + "No results." + ].filter(Boolean).join("\n"); + } + + return [ + `Search engine: ${response.engine}`, + `Query: ${response.query}`, + includeRaw ? `Search URL: ${response.searchUrl}` : "", + ...response.results.map((result, index) => [ + `${index + 1}. ${result.title}`, + `URL: ${result.url}`, + result.snippet ? `Snippet: ${result.snippet}` : "", + result.content ? `Extracted content: ${result.content}` : "" + ].filter(Boolean).join("\n")) + ].filter(Boolean).join("\n\n"); +} + +function uniqueSearchResults(results: BrowserSearchResult[]): BrowserSearchResult[] { + const seen = new Set(); + const unique: BrowserSearchResult[] = []; + for (const result of results) { + if (!result.url || seen.has(result.url)) { + continue; + } + seen.add(result.url); + unique.push(result); + } + return unique; +} + +function normalizeDomain(value: string): string | undefined { + const trimmed = value.trim().replace(/^site:/i, ""); + if (!trimmed) { + return undefined; + } + try { + const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`); + return url.hostname.replace(/^www\./i, "").toLowerCase(); + } catch { + return trimmed.replace(/^www\./i, "").replace(/\/.*$/, "").toLowerCase(); + } +} + +function normalizeSearchDate(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + const trimmed = value.trim(); + if (/^\\d{4}-\\d{2}-\\d{2}$/.test(trimmed)) { + return trimmed; + } + const time = Date.parse(trimmed); + return Number.isFinite(time) ? new Date(time).toISOString().slice(0, 10) : undefined; +} + +function parseSearchEngine(value: string | undefined): BrowserSearchEngine | undefined { + const normalized = value?.trim().toLowerCase().replace(/[_\s]+/g, "-"); + if (!normalized || normalized === "auto") { + return undefined; + } + if (normalized === "google") return "google"; + if (normalized === "bing") return "bing"; + if (normalized === "duckduckgo" || normalized === "duck-duck-go" || normalized === "ddg") return "duckduckgo"; + return undefined; +} + +function parseFreshness(value: string | undefined): BrowserSearchFreshness | undefined { + const normalized = value?.trim().toLowerCase(); + if (normalized === "day" || normalized === "24h" || normalized === "d") return "day"; + if (normalized === "week" || normalized === "7d" || normalized === "w") return "week"; + if (normalized === "month" || normalized === "30d" || normalized === "m") return "month"; + if (normalized === "year" || normalized === "365d" || normalized === "y") return "year"; + return undefined; +} + +function parseSafeSearch(value: string | undefined): "moderate" | "off" | "strict" | undefined { + const normalized = value?.trim().toLowerCase(); + if (normalized === "off" || normalized === "false" || normalized === "0") return "off"; + if (normalized === "strict") return "strict"; + if (normalized === "moderate" || normalized === "medium" || normalized === "on" || normalized === "true" || normalized === "1") return "moderate"; + return undefined; +} + +function browserSearchConcurrency(): number { + return clampInteger(readNumber(process.env.CCR_BROWSER_SEARCH_CONCURRENCY) ?? 2, 1, 4); +} + +function browserSearchOpenResultCount(): number { + return clampInteger(readNumber(process.env.CCR_BROWSER_SEARCH_OPEN_RESULT_COUNT) ?? 3, 0, 8); +} + +function browserSearchPageTimeoutMs(requestTimeoutMs: number): number { + return clampInteger( + readNumber(process.env.CCR_BROWSER_SEARCH_PAGE_TIMEOUT_MS) ?? Math.min(requestTimeoutMs, 8_000), + 500, + 30_000 + ); +} + +function shouldOpenSearchResult(url: string): boolean { + try { + const parsed = new URL(url); + if (!/^https?:$/i.test(parsed.protocol)) { + return false; + } + return !/\.(?:avi|dmg|docx?|exe|gif|jpe?g|mov|mp3|mp4|pdf|png|pptx?|rar|webp|xlsx?|zip)$/i.test(parsed.pathname); + } catch { + return false; + } +} + +function compactExtractedContent(value: string | undefined): string | undefined { + const compacted = value?.replace(/\s+/g, " ").trim(); + if (!compacted || compacted.length < 40) { + return undefined; + } + return compacted.slice(0, 2_400); +} + +function waitForQueueTurn(queue: BrowserSearchQueueEntry[], timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const entry = { + reject: (error: Error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + reject(error); + }, + resolve: () => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve(); + } + }; + const timer = setTimeout(() => { + if (settled) { + return; + } + settled = true; + const index = queue.indexOf(entry); + if (index >= 0) { + queue.splice(index, 1); + } + reject(new Error("Browser search worker queue timed out.")); + }, timeoutMs); + queue.push(entry); + }); +} + +function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), timeoutMs); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timer); + reject(error); + } + ); + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function textResult(text: string): ToolCallResult { + return { + content: [{ text, type: "text" }] + }; +} + +function objectSchema(properties: Record, required: string[] = []): JsonValue { + return { + additionalProperties: true, + properties, + ...(required.length ? { required } : {}), + type: "object" + }; +} + +function jsonRpcResult(id: null | number | string, result: JsonValue): JsonRpcResponse { + return { + id, + jsonrpc: "2.0", + result + }; +} + +function jsonRpcError(id: null | number | string, code: number, message: string): JsonRpcResponse { + return { + error: { + code, + message + }, + id, + jsonrpc: "2.0" + }; +} + +function readRequestBody(request: IncomingMessage, maxBytes: number): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + request.on("data", (chunk: Buffer) => { + total += chunk.byteLength; + if (total > maxBytes) { + reject(new Error(`Request body exceeds ${maxBytes} bytes.`)); + request.destroy(); + return; + } + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + request.on("end", () => resolve(Buffer.concat(chunks))); + request.on("error", reject); + }); +} + +function sendJson(response: ServerResponse, statusCode: number, payload: unknown): void { + const body = `${JSON.stringify(payload)}\n`; + response.writeHead(statusCode, { + "cache-control": "no-store", + "content-length": Buffer.byteLength(body), + "content-type": "application/json; charset=utf-8" + }); + response.end(body); +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function readStringArray(value: unknown): string[] { + if (typeof value === "string") { + return value.split(",").map((item) => item.trim()).filter(Boolean); + } + if (!Array.isArray(value)) { + return []; + } + return value.map(readString).filter((item): item is string => Boolean(item)); +} + +function uniqueStrings(values: string[]): string[] { + return [...new Set(values.filter(Boolean))]; +} + +function clampInteger(value: number, minimum: number, maximum: number): number { + return Math.max(minimum, Math.min(maximum, Math.round(value))); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export const browserWebSearchMcpService = new BrowserWebSearchMcpService(); diff --git a/src/main/main-app.ts b/src/main/main-app.ts index 9c1d9ca..a065668 100644 --- a/src/main/main-app.ts +++ b/src/main/main-app.ts @@ -10,6 +10,7 @@ import { ensureCcrCliLauncher } from "./profile-launch-service"; import { proxyService } from "../server/proxy/service"; import trayController from "./tray-controller"; import { appUpdateService } from "./update-service"; +import { browserWebSearchMcpService } from "./electron-web-search-mcp"; import windowsManager from "./windows"; const gotTheLock = app.requestSingleInstanceLock(); @@ -27,6 +28,7 @@ if (!gotTheLock) { } function startPrimaryInstance(): void { + gatewayService.setBrowserWebSearchMcpIntegration(browserWebSearchMcpService); deepLinkService.register(); deepLinkService.handleArgv(process.argv); diff --git a/src/renderer/pages/home/components/virtual-models.tsx b/src/renderer/pages/home/components/virtual-models.tsx index 453a957..6debc9d 100644 --- a/src/renderer/pages/home/components/virtual-models.tsx +++ b/src/renderer/pages/home/components/virtual-models.tsx @@ -3,18 +3,40 @@ import { Card, CardContent, CardHeader, CardTitle, Check, ChevronDown, ChevronRight, cn, createMcpServerDraftFromConfig, createRouteModelOptions, defaultFusionWebSearchProvider, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle, ExtensionInstallDraft, Field, FolderOpen, formatPluginDependencies, - createFusionWebSearchEnvRows, customFusionToolName, fusionToolExecutionFlagsFromTools, fusionToolOptions, + createFusionWebSearchEnvRows, createKeyValueDraftRow, customFusionToolName, fusionToolExecutionFlagsFromTools, fusionToolOptions, fusionWebSearchProviderOptions, GatewayMcpServerConfig, GatewayMcpToolInfo, GatewayProviderConfig, Input, isBuiltInFusionToolName, isFusionVisionToolName, isFusionWebSearchToolName, KeyValueRowsControl, LoaderCircle, mcpServerConfigFromDraft, mcpServerEndpointSummary, mcpServerTransportOptions, mcpStdioMessageModeOptions, motion, normalizeFusionToolName, Pencil, PluginMarketplaceEntry, Plus, PopoverContent, RouteTargetControl, Search, selectedFusionToolNames, SelectControl, Toggle, Trash2, translateOptions, useAppErrorText, useAppText, useEffect, useLayoutEffect, useMemo, useRef, useState, validateMcpServerDraft, virtualModelBaseModelSummary, VirtualModelDraft, virtualModelMatchesQuery, virtualModelMatchSummary, + type KeyValueDraftRow, VirtualModelProfileConfig, virtualModelToolSummary, X } from "../shared"; const virtualModelTableGridClass = "grid-cols-[minmax(180px,0.9fr)_minmax(220px,1.1fr)_minmax(220px,1.1fr)_minmax(170px,0.85fr)_112px_96px]"; const virtualModelTableMinWidthClass = "min-w-[1100px]"; +const browserWebSearchEngineEnvKey = "BROWSER_SEARCH_ENGINE"; +const browserWebSearchLanguageEnvKey = "BROWSER_SEARCH_LANGUAGE"; +const browserWebSearchCountryEnvKey = "BROWSER_SEARCH_COUNTRY"; +const browserWebSearchSafeSearchEnvKey = "BROWSER_SEARCH_SAFE_SEARCH"; +const browserWebSearchKnownEnvKeys = new Set([ + browserWebSearchEngineEnvKey, + browserWebSearchLanguageEnvKey, + browserWebSearchCountryEnvKey, + browserWebSearchSafeSearchEnvKey +]); +const browserWebSearchEngineOptions = [ + { label: "Bing", value: "bing" }, + { label: "Google", value: "google" }, + { label: "DuckDuckGo", value: "duckduckgo" } +]; +const browserWebSearchSafeSearchOptions = [ + { label: "Default", value: "default" }, + { label: "Moderate", value: "moderate" }, + { label: "Strict", value: "strict" }, + { label: "Off", value: "off" } +]; function uniqueFusionTools(tools: string[]): string[] { const seen = new Set(); @@ -452,17 +474,112 @@ function WebSearchToolConfigurationPanel({ - - onChange({ webSearchEnvRows })} rows={draft.webSearchEnvRows} /> + ) : ( + + onChange({ webSearchEnvRows })} + rows={draft.webSearchEnvRows} + /> + + )} + + ); +} + +function BrowserWebSearchConfigurationPanel({ + onChange, + rows +}: { + onChange: (rows: KeyValueDraftRow[]) => void; + rows: KeyValueDraftRow[]; +}) { + const t = useAppText(); + const extraRows = browserWebSearchExtraRows(rows); + const engine = browserWebSearchEnvValue(rows, browserWebSearchEngineEnvKey) || "bing"; + const language = browserWebSearchEnvValue(rows, browserWebSearchLanguageEnvKey); + const country = browserWebSearchEnvValue(rows, browserWebSearchCountryEnvKey); + const safeSearch = browserWebSearchEnvValue(rows, browserWebSearchSafeSearchEnvKey) || "default"; + + function updateKnownValue(key: string, value: string) { + onChange(upsertBrowserWebSearchEnvRows(rows, key, value === "default" ? "" : value)); + } + + function updateExtraRows(nextExtraRows: KeyValueDraftRow[]) { + onChange(mergeBrowserWebSearchEnvRows(rows, nextExtraRows)); + } + + return ( +
+
+ + updateKnownValue(browserWebSearchEngineEnvKey, value)} + options={browserWebSearchEngineOptions} + value={browserWebSearchEngineOptions.some((option) => option.value === engine) ? engine : "bing"} + /> + + + updateKnownValue(browserWebSearchSafeSearchEnvKey, value)} + options={translateOptions(browserWebSearchSafeSearchOptions, t)} + value={browserWebSearchSafeSearchOptions.some((option) => option.value === safeSearch) ? safeSearch : "default"} + /> + + + updateKnownValue(browserWebSearchLanguageEnvKey, event.target.value)} + placeholder="en, zh-CN" + value={language} + /> + + + updateKnownValue(browserWebSearchCountryEnvKey, event.target.value)} + placeholder="US, CN" + value={country} + /> + +
+ +
); } +function browserWebSearchEnvValue(rows: KeyValueDraftRow[], key: string): string { + return rows.find((row) => row.key.trim() === key)?.value.trim() ?? ""; +} + +function browserWebSearchExtraRows(rows: KeyValueDraftRow[]): KeyValueDraftRow[] { + return rows.filter((row) => { + const key = row.key.trim(); + return key && !browserWebSearchKnownEnvKeys.has(key); + }); +} + +function upsertBrowserWebSearchEnvRows(rows: KeyValueDraftRow[], key: string, value: string): KeyValueDraftRow[] { + const normalizedValue = value.trim(); + const nextRows = rows.filter((row) => row.key.trim() !== key); + return normalizedValue ? [createKeyValueDraftRow(key, normalizedValue), ...nextRows] : nextRows; +} + +function mergeBrowserWebSearchEnvRows(currentRows: KeyValueDraftRow[], extraRows: KeyValueDraftRow[]): KeyValueDraftRow[] { + const knownRows = currentRows.filter((row) => browserWebSearchKnownEnvKeys.has(row.key.trim()) && row.value.trim()); + const cleanedExtraRows = extraRows.filter((row) => row.key.trim() || row.value.trim()); + return [...knownRows, ...cleanedExtraRows]; +} + function CustomMcpToolDialog({ draft, error, diff --git a/src/renderer/pages/home/shared/i18n.tsx b/src/renderer/pages/home/shared/i18n.tsx index cf70595..cb33420 100644 --- a/src/renderer/pages/home/shared/i18n.tsx +++ b/src/renderer/pages/home/shared/i18n.tsx @@ -380,7 +380,17 @@ export const appCopy: Record = { "Vision model is required.": "Vision model is required.", "Web search configuration": "Web search configuration", "Search provider": "Search provider", - "Provider configuration": "Provider configuration", + "In-app Browser": "In-app Browser", + "Provider configuration": "Provider configuration", + "Search engine": "Search engine", + "Safe search": "Safe search", + "Language": "Language", + "Country": "Country", + "Advanced variables": "Advanced variables", + "Default": "Default", + "Moderate": "Moderate", + "Strict": "Strict", + "Off": "Off", "Add tool": "Add tool", "Add variable": "Add variable", "Add custom MCP": "Add custom MCP", @@ -407,6 +417,7 @@ export const appCopy: Record = { "Image recognition and Web Search": "Image recognition and Web Search", "Generic image understanding tool for OCR, screenshot analysis, chart reading, UI comparison, error diagnosis, and other multi-image tasks.": "Generic image understanding tool for OCR, screenshot analysis, chart reading, UI comparison, error diagnosis, and other multi-image tasks.", "Generic web search tool supporting Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.": "Generic web search tool supporting Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.", + "Generic web search tool supporting hidden in-app browser search plus Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.": "Generic web search tool supporting hidden in-app browser search plus Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.", "Web Search": "Web Search", "New model": "New model", "New model is required.": "New model is required.", @@ -1232,7 +1243,16 @@ export const appCopy: Record = { "Vision model is required.": "必须选择视觉模型。", "Web search configuration": "Web Search 配置", "Search provider": "搜索类型", - "Provider configuration": "类型配置", + "In-app Browser": "内置浏览器", + "Provider configuration": "类型配置", + "Search engine": "搜索引擎", + "Safe search": "安全搜索", + "Language": "语言", + "Country": "国家/地区", + "Advanced variables": "高级变量", + "Default": "默认", + "Moderate": "中等", + "Strict": "严格", "Add tool": "添加工具", "Add variable": "添加变量", "Add custom MCP": "添加自定义 MCP", @@ -1250,6 +1270,7 @@ export const appCopy: Record = { "Image recognition and Web Search": "图片识别和 Web Search", "Generic image understanding tool for OCR, screenshot analysis, chart reading, UI comparison, error diagnosis, and other multi-image tasks.": "通用图片理解工具,支持 OCR、截图分析、图表解读、UI 对比、错误诊断等多图任务。", "Generic web search tool supporting Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.": "通用网络搜索工具,支持 Brave、Bing、Google CSE、Serper、SerpAPI、Tavily、Exa。", + "Generic web search tool supporting hidden in-app browser search plus Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.": "通用网络搜索工具,支持隐藏内置浏览器搜索,以及 Brave、Bing、Google CSE、Serper、SerpAPI、Tavily、Exa。", "Web Search": "网页搜索", "New model": "新模型", "New model is required.": "新模型不能为空。", diff --git a/src/renderer/pages/home/shared/options.ts b/src/renderer/pages/home/shared/options.ts index 25cf305..a47466f 100644 --- a/src/renderer/pages/home/shared/options.ts +++ b/src/renderer/pages/home/shared/options.ts @@ -265,7 +265,7 @@ export const fusionToolOptions: Array<{ description: string; label: string; valu value: BUILTIN_FUSION_VISION_TOOL_NAME }, { - description: "Generic web search tool supporting Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.", + description: "Generic web search tool supporting hidden in-app browser search plus Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.", label: `${BUILTIN_FUSION_TOOL_SERVER_NAME} / ${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}`, value: BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME } @@ -277,6 +277,7 @@ export const customFusionToolName = "custom_mcp_tool"; export const defaultFusionWebSearchProvider: VirtualModelFusionWebSearchProvider = "brave"; export const fusionWebSearchProviderOptions: Array<{ label: string; value: VirtualModelFusionWebSearchProvider }> = [ + { label: "In-app Browser", value: "browser" }, { label: "Brave", value: "brave" }, { label: "Bing", value: "bing" }, { label: "Google CSE", value: "google_cse" }, @@ -288,6 +289,7 @@ export const fusionWebSearchProviderOptions: Array<{ label: string; value: Virtu export const fusionWebSearchEnvKeysByProvider: Record = { bing: ["BING_SEARCH_API_KEY", "BING_SEARCH_ENDPOINT"], + browser: ["BROWSER_SEARCH_ENGINE", "BROWSER_SEARCH_LANGUAGE", "BROWSER_SEARCH_COUNTRY", "BROWSER_SEARCH_SAFE_SEARCH"], brave: ["BRAVE_SEARCH_API_KEY", "BRAVE_SEARCH_ENDPOINT"], exa: ["EXA_API_KEY", "EXA_SEARCH_ENDPOINT"], google_cse: ["GOOGLE_SEARCH_API_KEY", "GOOGLE_SEARCH_CX", "GOOGLE_SEARCH_ENDPOINT"], diff --git a/src/renderer/pages/home/shared/virtual-models.ts b/src/renderer/pages/home/shared/virtual-models.ts index 18ca112..c71cdd0 100644 --- a/src/renderer/pages/home/shared/virtual-models.ts +++ b/src/renderer/pages/home/shared/virtual-models.ts @@ -625,7 +625,7 @@ export function fusionWebSearchConfigFromDraft(draft: VirtualModelDraft, key: st export function fusionWebSearchToolName(key: string): string { const normalized = sanitizeConfigId(key).replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, ""); - return `web_search_${normalized || "fusion"}`; + return `${normalized || "fusion"}_web_search`; } export function fusionCustomToolConfigFromProfile(profile: VirtualModelProfileConfig): VirtualModelFusionCustomToolConfig | undefined { @@ -996,7 +996,10 @@ export function isFusionVisionToolName(name: string): boolean { export function isFusionWebSearchToolName(name: string): boolean { const normalized = normalizeFusionToolName(name); - return normalized === BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME || normalized.startsWith(`${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}_`); + return normalized === BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME || + normalized.startsWith(`${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}_`) || + normalized.endsWith(`_${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}`) || + normalized.includes("search_web"); } export function selectedFusionToolName(toolsText: string): string { diff --git a/src/server/gateway/service.ts b/src/server/gateway/service.ts index 8496526..f4ab704 100644 --- a/src/server/gateway/service.ts +++ b/src/server/gateway/service.ts @@ -101,6 +101,49 @@ type GatewayStopOptions = { proxyRestoreTimeoutMs?: number; }; +type HostedWebSearchProtocolContext = { + maxUses?: number; + protocol: GatewayProviderProtocol; + queryHint?: string; + records?: BrowserWebSearchProtocolRecord[]; + requestId: string; + sinceMs: number; + toolName: string; +}; + +type AnthropicWebSearchProtocolContext = HostedWebSearchProtocolContext; + +export type BrowserWebSearchMcpRegistration = { + env?: Record; + name: string; + resultCount?: number; + timeoutMs?: number; + toolName: string; +}; + +export type BrowserWebSearchProtocolResult = { + content?: string; + snippet?: string; + title: string; + url: string; +}; + +export type BrowserWebSearchProtocolRecord = { + completedAtMs: number; + engine: string; + query: string; + results: BrowserWebSearchProtocolResult[]; + searchUrl: string; + toolName: string; +}; + +export type BrowserWebSearchMcpIntegration = { + registerBrowserWebSearchMcpServer: (options: BrowserWebSearchMcpRegistration) => Promise; + recentBrowserWebSearchResults?: (options: { sinceMs: number; toolName?: string }) => BrowserWebSearchProtocolRecord[]; + runBrowserWebSearch?: (options: { count?: number; prompt: string; timeoutMs?: number; toolName?: string }) => Promise; + stopBrowserWebSearchMcpServers: () => Promise; +}; + type CoreGatewayHealth = { runtimeId?: string; status?: string; @@ -261,6 +304,7 @@ const persistedApiKeyCacheTtlMs = 1000; let persistedApiKeyCache: { loadedAt: number; values: ApiKeyConfig[] } | undefined; class GatewayService { + private browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration; private child?: ChildProcess; private config?: AppConfig; private coreAuthToken = ""; @@ -276,6 +320,10 @@ class GatewayService { state: "stopped" }; + setBrowserWebSearchMcpIntegration(integration: BrowserWebSearchMcpIntegration): void { + this.browserWebSearchMcpIntegration = integration; + } + async start(config: AppConfig): Promise { const coreHostError = loopbackCoreHostError(config.gateway.coreHost); if (coreHostError) { @@ -324,7 +372,7 @@ class GatewayService { } if (shouldRunGateway) { - writeCoreGatewayConfig(config, this.rawTraceSyncToken); + await writeCoreGatewayConfig(config, this.rawTraceSyncToken, this.browserWebSearchMcpIntegration); await stopPreviousManagedCoreGateway(config, this.status.coreEndpoint); if (await isCoreGatewayHealthy(this.status.coreEndpoint)) { throw new Error(`Core gateway endpoint is already in use: ${this.status.coreEndpoint}`); @@ -379,6 +427,9 @@ class GatewayService { await proxyService.stop(options.proxyRestoreTimeoutMs); await pluginService.stop(); await backendService.stopAll(); + await this.browserWebSearchMcpIntegration?.stopBrowserWebSearchMcpServers().catch((error) => { + console.warn(`[gateway] Failed to stop browser web search MCP: ${formatError(error)}`); + }); this.status = { ...this.status, @@ -739,6 +790,39 @@ class GatewayService { routeFallback = providerCapabilityRouting.fallback; routedModel = providerCapabilityRouting.routedModel; + const hostedWebSearchProtocolContext = createHostedWebSearchProtocolContext({ + body: bodyToForward, + config: this.config, + method, + path, + requestId, + routedModel, + sinceMs: startedAt - 1_000 + }); + + if (hostedWebSearchProtocolContext && this.browserWebSearchMcpIntegration) { + const records = await selectHostedWebSearchProtocolRecords( + hostedWebSearchProtocolContext, + this.browserWebSearchMcpIntegration + ).catch((error) => { + console.warn(`[gateway] Failed to prefetch hosted web search results: ${formatError(error)}`); + return [] as BrowserWebSearchProtocolRecord[]; + }); + if (records.length > 0) { + hostedWebSearchProtocolContext.records = records; + const webSearchContextBody = prepareHostedWebSearchProtocolRequestBody( + bodyToForward, + records, + hostedWebSearchProtocolContext + ); + if (webSearchContextBody) { + bodyToForward = webSearchContextBody; + headers["content-type"] = "application/json"; + headers["x-ccr-hosted-web-search-context"] = hostedWebSearchProtocolContext.protocol; + } + } + } + delete headers["content-length"]; const upstreamUrl = new URL(request.url || "/", this.status.coreEndpoint).toString(); let upstreamResult: UpstreamFetchResult; @@ -795,6 +879,14 @@ class GatewayService { if (codexApplyPatchBridgeActive) { responseHeaders.delete("content-length"); } + const hostedWebSearchResponseContentType = responseHeaders.get("content-type")?.toLowerCase() ?? ""; + if ( + hostedWebSearchProtocolContext && + hostedWebSearchResponseContentType.includes("application/json") && + (this.browserWebSearchMcpIntegration?.recentBrowserWebSearchResults || this.browserWebSearchMcpIntegration?.runBrowserWebSearch) + ) { + responseHeaders.delete("content-length"); + } recordProviderCredentialOutcome(this.config, method, upstreamResult.attempt, upstreamResponse.status, responseHeaders); response.writeHead(upstreamResponse.status, Object.fromEntries(filteredResponseHeaders(responseHeaders))); if (!upstreamResponse.body) { @@ -819,9 +911,17 @@ class GatewayService { } const upstreamBody = Readable.fromWeb(upstreamResponse.body as unknown as import("node:stream/web").ReadableStream); - const responseBody = codexApplyPatchBridgeActive + const patchedResponseBody = codexApplyPatchBridgeActive ? codexApplyPatchBridgeResponseStream(upstreamBody, responseHeaders) : upstreamBody; + const responseBody = hostedWebSearchProtocolContext + ? hostedWebSearchProtocolResponseStream( + patchedResponseBody, + responseHeaders, + hostedWebSearchProtocolContext, + this.browserWebSearchMcpIntegration + ) + : patchedResponseBody; const sampler = createBodySampler(); const sseErrorDetector = createSseErrorDetector(responseHeaders.get("content-type") ?? undefined); let streamDetectedError: string | undefined; @@ -946,7 +1046,11 @@ class GatewayService { export const gatewayService = new GatewayService(); -function writeCoreGatewayConfig(config: AppConfig, rawTraceSyncToken: string): void { +async function writeCoreGatewayConfig( + config: AppConfig, + rawTraceSyncToken: string, + browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration +): Promise { assertLoopbackCoreHost(config.gateway.coreHost); mkdirSync(dirname(config.gateway.generatedConfigFile), { mode: privateDirMode, recursive: true }); const pluginCoreGatewayConfig = pluginService.getCoreGatewayConfig(); @@ -960,7 +1064,7 @@ function writeCoreGatewayConfig(config: AppConfig, rawTraceSyncToken: string): v ...pluginService.getVirtualModelProfiles() ])), config); const coreEndpoint = endpoint(config.gateway.coreHost, config.gateway.corePort); - const builtinToolArtifacts = fusionBuiltinToolArtifacts(virtualModelProfiles, coreEndpoint); + const builtinToolArtifacts = await fusionBuiltinToolArtifacts(virtualModelProfiles, coreEndpoint, browserWebSearchMcpIntegration); const providers = [ ...config.Providers .flatMap((provider) => toCoreGatewayProviders(withCodexOauthProviderBaseUrl(provider, codexOauthProviderNames))) @@ -1078,7 +1182,9 @@ function normalizeCoreGatewayVirtualModelProfile(profile: unknown, config: AppCo }; } - return nextProfile ?? profile; + const profileAfterVision = nextProfile ?? profile; + const profileAfterWebSearchToolName = normalizeFusionWebSearchProfileToolName(profileAfterVision) ?? profileAfterVision; + return withFusionWebSearchToolInstructions(profileAfterWebSearchToolName) ?? profileAfterWebSearchToolName; } function rewriteModelSelectorForCoreGatewayProfile( @@ -1241,18 +1347,19 @@ function hasOwn(value: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(value, key); } -function fusionBuiltinToolArtifacts( +async function fusionBuiltinToolArtifacts( profiles: unknown[], - coreEndpoint: string -): { mcpServers: GatewayMcpServerConfig[]; providers: CoreGatewayProvider[] } { + coreEndpoint: string, + browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration +): Promise<{ mcpServers: GatewayMcpServerConfig[]; providers: CoreGatewayProvider[] }> { const providers: CoreGatewayProvider[] = []; const mcpServers: GatewayMcpServerConfig[] = []; const toolServerKeys = new Set(); const entry = bundledFusionBuiltinMcpEntryPath(); - profiles.forEach((profile, index) => { + for (const [index, profile] of profiles.entries()) { if (!isRecord(profile) || profile.enabled === false) { - return; + continue; } const metadata = isRecord(profile.metadata) ? profile.metadata : undefined; const profileId = stringValue(profile.id) || stringValue(profile.key) || `fusion-${index + 1}`; @@ -1285,21 +1392,35 @@ function fusionBuiltinToolArtifacts( const toolServerKey = `web_search:${webSearchConfig.toolName}`; if (!toolServerKeys.has(toolServerKey)) { toolServerKeys.add(toolServerKey); - mcpServers.push(fusionBuiltinMcpServer({ - entry, - env: { - FUSION_BUILTIN_TOOL_KIND: "web_search", - FUSION_TOOL_NAME: webSearchConfig.toolName, - SEARCH_PROVIDER: webSearchConfig.provider ?? defaultFusionWebSearchProvider, - ...(webSearchConfig.resultCount ? { SEARCH_RESULT_COUNT: String(webSearchConfig.resultCount) } : {}), - ...(webSearchConfig.timeoutMs ? { SEARCH_TIMEOUT_MS: String(webSearchConfig.timeoutMs) } : {}), - ...(webSearchConfig.env ?? {}) - }, - name: `fusion-web-search-${sanitizedProfileId}` - })); + const provider = webSearchConfig.provider ?? defaultFusionWebSearchProvider; + if (provider === "browser") { + const browserMcpServer = await browserWebSearchMcpIntegration?.registerBrowserWebSearchMcpServer({ + env: webSearchConfig.env ?? {}, + name: `fusion-browser-web-search-${sanitizedProfileId}`, + resultCount: webSearchConfig.resultCount, + timeoutMs: webSearchConfig.timeoutMs, + toolName: webSearchConfig.toolName + }); + if (browserMcpServer) { + mcpServers.push(browserMcpServer); + } + } else { + mcpServers.push(fusionBuiltinMcpServer({ + entry, + env: { + FUSION_BUILTIN_TOOL_KIND: "web_search", + FUSION_TOOL_NAME: webSearchConfig.toolName, + SEARCH_PROVIDER: provider, + ...(webSearchConfig.resultCount ? { SEARCH_RESULT_COUNT: String(webSearchConfig.resultCount) } : {}), + ...(webSearchConfig.timeoutMs ? { SEARCH_TIMEOUT_MS: String(webSearchConfig.timeoutMs) } : {}), + ...(webSearchConfig.env ?? {}) + }, + name: `fusion-web-search-${sanitizedProfileId}` + })); + } } } - }); + } return { mcpServers, providers }; } @@ -1337,7 +1458,7 @@ function fusionToolFallbackMcpServer( profiles: unknown[], existingServers: unknown[] ): GatewayMcpServerConfig | undefined { - const tools = fusionFallbackToolDefinitions(profiles); + const tools = fusionFallbackToolDefinitions(profiles, fusionToolNamesBackedByMcpServers(existingServers)); if (tools.length === 0) { return undefined; } @@ -1362,8 +1483,9 @@ function bundledFusionToolFallbackMcpEntryPath(): string { return pathJoin(__dirname, "fusion-tool-fallback-mcp.js"); } -function fusionFallbackToolDefinitions( - profiles: unknown[] +export function fusionFallbackToolDefinitions( + profiles: unknown[], + backedToolNames: Set = new Set() ): Array<{ description?: string; inputSchema?: Record; name: string }> { const byName = new Map; name: string }>(); @@ -1379,6 +1501,9 @@ function fusionFallbackToolDefinitions( if (!name) { continue; } + if (backedToolNames.has(name)) { + continue; + } const existing = byName.get(name); const description = stringValue(tool.description); @@ -1408,6 +1533,26 @@ function fusionFallbackToolDefinitions( return [...byName.values()]; } +export function fusionToolNamesBackedByMcpServers(servers: unknown[]): Set { + const names = new Set(); + for (const server of servers) { + if (!isRecord(server)) { + continue; + } + const serverName = stringValue(server.name); + if (serverName) { + names.add(serverName); + } + + const env = isRecord(server.env) ? server.env : undefined; + const fusionToolName = stringValue(env?.FUSION_TOOL_NAME); + if (fusionToolName) { + names.add(fusionToolName); + } + } + return names; +} + function uniqueMcpServerName(baseName: string, servers: unknown[]): string { const used = new Set( servers @@ -1517,7 +1662,7 @@ function legacyFusionBuiltinToolName( const tools = Array.isArray(profile.tools) ? profile.tools : []; const toolName = tools .map((tool) => isRecord(tool) ? stringValue(tool.name) ?? "" : "") - .find((name) => name === baseToolName || name.startsWith(`${baseToolName}_`)); + .find((name) => fusionBuiltinToolNameMatches(name, baseToolName)); if (toolName) { return toolName; } @@ -1525,6 +1670,133 @@ function legacyFusionBuiltinToolName( return execution[executionFlag] === true ? baseToolName : undefined; } +function fusionBuiltinToolNameMatches(name: string, baseToolName: string): boolean { + if (name === baseToolName || name.startsWith(`${baseToolName}_`)) { + return true; + } + if (baseToolName !== BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME) { + return false; + } + return coreGatewayWebSearchToolNameMatches(name); +} + +function normalizeFusionWebSearchProfileToolName(profile: Record): Record | undefined { + const metadata = isRecord(profile.metadata) ? profile.metadata : undefined; + const fusionWebSearch = isRecord(metadata?.fusionWebSearch) ? metadata.fusionWebSearch : undefined; + const configuredToolName = stringValue(fusionWebSearch?.toolName); + const legacyToolName = configuredToolName ? undefined : legacyFusionWebSearchConfig(profile)?.toolName; + const toolName = configuredToolName || legacyToolName; + if (!toolName) { + return undefined; + } + + const nextToolName = coreGatewayCompatibleWebSearchToolName(toolName, stringValue(profile.key) || stringValue(profile.id)); + if (nextToolName === toolName) { + return undefined; + } + + const tools = Array.isArray(profile.tools) + ? profile.tools.map((tool) => { + if (!isRecord(tool) || stringValue(tool.name) !== toolName) { + return tool; + } + return { + ...tool, + name: nextToolName + }; + }) + : profile.tools; + + return { + ...profile, + ...(metadata && fusionWebSearch + ? { + metadata: { + ...metadata, + fusionWebSearch: { + ...fusionWebSearch, + toolName: nextToolName + } + } + } + : {}), + ...(tools ? { tools } : {}) + }; +} + +function withFusionWebSearchToolInstructions(profile: Record): Record | undefined { + const metadata = isRecord(profile.metadata) ? profile.metadata : undefined; + const fusionWebSearch = isRecord(metadata?.fusionWebSearch) ? metadata.fusionWebSearch : undefined; + const toolName = stringValue(fusionWebSearch?.toolName) || legacyFusionWebSearchConfig(profile)?.toolName; + if (!toolName) { + return undefined; + } + const execution = isRecord(profile.execution) ? profile.execution : {}; + if (execution.matchWebSearch !== true) { + return undefined; + } + + const instruction = [ + `When the client request includes a hosted web_search tool declaration, call the ${toolName} function tool before answering.`, + "Pass the user's search query in the prompt field.", + "Do not use provider-native web search or claim that web search is unavailable unless this function tool returns an error." + ].join(" "); + const instructions = isRecord(profile.instructions) ? profile.instructions : {}; + if ([instructions.prepend, instructions.append, instructions.replace].some((value) => stringValue(value)?.includes(instruction))) { + return undefined; + } + const replace = stringValue(instructions.replace); + const append = stringValue(instructions.append); + return { + ...profile, + instructions: { + ...instructions, + ...(replace + ? { replace: `${replace.trim()}\n\n${instruction}` } + : { append: [append, instruction].filter(Boolean).join("\n\n") }) + } + }; +} + +function coreGatewayCompatibleWebSearchToolName(toolName: string, fallbackName?: string): string { + if (coreGatewayWebSearchToolNameMatches(toolName)) { + return toolName; + } + + const normalized = sanitizeFusionToolName(toolName); + const prefix = `${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}_`; + if (normalized.startsWith(prefix) && normalized.length > prefix.length) { + return truncateFusionToolName(`${normalized.slice(prefix.length)}_${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}`); + } + + const fallback = sanitizeFusionToolName(fallbackName || normalized || "fusion"); + return truncateFusionToolName(`${fallback}_${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}`); +} + +function coreGatewayWebSearchToolNameMatches(name: string): boolean { + const normalized = name.toLowerCase().replace(/[-.]/g, "_"); + return normalized === BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME || + normalized.endsWith(`_${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}`) || + normalized.includes("search_web"); +} + +function sanitizeFusionToolName(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9_]+/g, "_") + .replace(/^_+|_+$/g, "") || "fusion"; +} + +function truncateFusionToolName(value: string): string { + const maxToolNameLength = 64; + if (value.length <= maxToolNameLength) { + return value; + } + const suffix = `_${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}`; + const available = Math.max(1, maxToolNameLength - suffix.length); + return `${value.slice(0, available).replace(/_+$/g, "")}${suffix}`; +} + function readFusionVisionConfig(value: unknown): VirtualModelFusionVisionConfig | undefined { if (!isRecord(value)) { return undefined; @@ -1630,7 +1902,8 @@ function parseFusionWebSearchProvider(value: unknown): VirtualModelFusionWebSear normalized === "serper" || normalized === "serpapi" || normalized === "tavily" || - normalized === "exa" + normalized === "exa" || + normalized === "browser" ) { return normalized; } @@ -2312,6 +2585,1811 @@ export function transformCodexApplyPatchBridgeSseEvent(block: string): string { } } +function createHostedWebSearchProtocolContext(input: { + body: Buffer | undefined; + config: AppConfig; + method: string; + path: string; + requestId: string; + routedModel?: string; + sinceMs: number; +}): HostedWebSearchProtocolContext | undefined { + const protocol = requestProtocolForPath(input.path); + if (input.method !== "POST" || !protocol) { + return undefined; + } + const body = parseJsonObjectSafe(input.body); + if (!body || !hasHostedWebSearchDeclaration(body, protocol)) { + return undefined; + } + const toolName = fusionWebSearchToolNameForRequest(input.config, stringValue(body.model) || input.routedModel); + if (!toolName) { + return undefined; + } + return { + maxUses: readHostedWebSearchMaxUses(body, protocol), + protocol, + queryHint: extractHostedWebSearchQueryHint(body, protocol), + requestId: input.requestId, + sinceMs: input.sinceMs, + toolName + }; +} + +function createAnthropicWebSearchProtocolContext(input: { + body: Buffer | undefined; + config: AppConfig; + method: string; + path: string; + requestId: string; + sinceMs: number; +}): AnthropicWebSearchProtocolContext | undefined { + const context = createHostedWebSearchProtocolContext(input); + return context?.protocol === "anthropic_messages" ? context : undefined; +} + +export function prepareHostedWebSearchProtocolRequestBody( + body: Buffer | undefined, + records: BrowserWebSearchProtocolRecord[], + context: Pick +): Buffer | undefined { + if (context.protocol === "anthropic_messages") { + return prepareAnthropicWebSearchProtocolRequestBody(body, records, context); + } + const parsed = parseJsonObjectSafe(body); + if (!parsed || records.length === 0) { + return undefined; + } + const evidence = hostedWebSearchEvidenceText(records, context.queryHint); + if (!evidence) { + return undefined; + } + let next: Record | undefined; + if (context.protocol === "openai_chat_completions") { + next = prepareOpenAiChatHostedWebSearchRequestBody(parsed, evidence); + } else if (context.protocol === "openai_responses") { + next = prepareOpenAiResponsesHostedWebSearchRequestBody(parsed, evidence); + } else if (context.protocol === "gemini_generate_content") { + next = prepareGeminiHostedWebSearchRequestBody(parsed, evidence); + } + return next ? Buffer.from(`${JSON.stringify(next)}\n`, "utf8") : undefined; +} + +export function prepareAnthropicWebSearchProtocolRequestBody( + body: Buffer | undefined, + records: BrowserWebSearchProtocolRecord[], + context: Pick +): Buffer | undefined { + const parsed = parseJsonObjectSafe(body); + if (!parsed || records.length === 0) { + return undefined; + } + const evidence = hostedWebSearchEvidenceText(records, context.queryHint); + if (!evidence) { + return undefined; + } + const next = applyAnthropicWebSearchSynthesisControls(stripAnthropicHostedWebSearchTools({ + ...parsed, + system: appendAnthropicSystemText(parsed.system, evidence) + })); + return Buffer.from(`${JSON.stringify(next)}\n`, "utf8"); +} + +function applyAnthropicWebSearchSynthesisControls(body: Record): Record { + const next = { ...body }; + const outputConfig = isRecord(next.output_config) ? { ...next.output_config } : {}; + outputConfig.effort = "low"; + next.output_config = outputConfig; + delete next.thinking; + delete next.reasoning; + return next; +} + +function prepareOpenAiChatHostedWebSearchRequestBody(body: Record, evidence: string): Record { + const next = stripOpenAiHostedWebSearchTools({ + ...body, + messages: appendOpenAiChatSystemText(body.messages, evidence) + }); + return applyOpenAiHostedWebSearchSynthesisControls(next); +} + +function prepareOpenAiResponsesHostedWebSearchRequestBody(body: Record, evidence: string): Record { + const next = stripOpenAiHostedWebSearchTools({ + ...body, + instructions: appendStringInstruction(body.instructions, evidence) + }); + return applyOpenAiHostedWebSearchSynthesisControls(next); +} + +function prepareGeminiHostedWebSearchRequestBody(body: Record, evidence: string): Record { + return stripGeminiHostedWebSearchTools({ + ...body, + systemInstruction: appendGeminiSystemInstruction(body.systemInstruction, evidence) + }); +} + +function applyOpenAiHostedWebSearchSynthesisControls(body: Record): Record { + const next = { ...body }; + if (typeof next.reasoning_effort === "string") { + next.reasoning_effort = "low"; + } + if (isRecord(next.reasoning)) { + next.reasoning = { ...next.reasoning, effort: "low" }; + } + return next; +} + +function stripAnthropicHostedWebSearchTools(body: Record): Record { + if (!Array.isArray(body.tools)) { + return body; + } + const tools = body.tools.filter((tool) => !isAnthropicHostedWebSearchTool(tool)); + if (tools.length === body.tools.length) { + return body; + } + const next = { ...body }; + if (tools.length > 0) { + next.tools = tools; + } else { + delete next.tools; + } + const toolChoice = isRecord(next.tool_choice) ? next.tool_choice : undefined; + const toolChoiceName = stringValue(toolChoice?.name); + if (tools.length === 0 || toolChoiceName === "web_search") { + delete next.tool_choice; + } + return next; +} + +function stripOpenAiHostedWebSearchTools(body: Record): Record { + const next = { ...body }; + let removedTools = false; + if (Array.isArray(body.tools)) { + const tools = body.tools.filter((tool) => !isOpenAiHostedWebSearchTool(tool)); + removedTools = tools.length !== body.tools.length; + if (tools.length > 0) { + next.tools = tools; + } else { + delete next.tools; + } + } + if (next.web_search_options !== undefined || next.webSearchOptions !== undefined) { + delete next.web_search_options; + delete next.webSearchOptions; + removedTools = true; + } + if (removedTools && (!Array.isArray(next.tools) || next.tools.length === 0 || openAiToolChoiceNamesWebSearch(next.tool_choice))) { + delete next.tool_choice; + delete next.parallel_tool_calls; + } + return next; +} + +function stripGeminiHostedWebSearchTools(body: Record): Record { + if (!Array.isArray(body.tools)) { + return body; + } + let changed = false; + const tools = body.tools.flatMap((tool) => { + const transformed = stripGeminiHostedWebSearchTool(tool); + changed ||= transformed.changed; + return transformed.value ? [transformed.value] : []; + }); + if (!changed) { + return body; + } + const next = { ...body }; + if (tools.length > 0) { + next.tools = tools; + } else { + delete next.tools; + } + return next; +} + +function stripGeminiHostedWebSearchTool(tool: unknown): { changed: boolean; value?: unknown } { + if (!isRecord(tool)) { + return { changed: false, value: tool }; + } + let changed = false; + const next: Record = { ...tool }; + for (const key of ["google_search", "googleSearch", "google_search_retrieval", "googleSearchRetrieval"]) { + if (key in next) { + delete next[key]; + changed = true; + } + } + return Object.keys(next).length === 0 ? { changed, value: undefined } : { changed, value: next }; +} + +function appendAnthropicSystemText(system: unknown, text: string): unknown { + if (typeof system === "string") { + return `${system.trimEnd()}\n\n${text}`; + } + const block = { text, type: "text" }; + if (Array.isArray(system)) { + return [...system, block]; + } + return [block]; +} + +function appendOpenAiChatSystemText(messages: unknown, text: string): unknown[] { + const message = { content: text, role: "system" }; + return Array.isArray(messages) ? [message, ...messages] : [message]; +} + +function appendStringInstruction(value: unknown, text: string): string { + const existing = rawStringValue(value); + return existing ? `${existing.trimEnd()}\n\n${text}` : text; +} + +function appendGeminiSystemInstruction(value: unknown, text: string): Record { + const part = { text }; + if (typeof value === "string") { + return { parts: [{ text: value }, part] }; + } + if (isRecord(value)) { + const parts = Array.isArray(value.parts) ? value.parts : []; + return { + ...value, + parts: [...parts, part] + }; + } + return { parts: [part] }; +} + +function hostedWebSearchEvidenceText(records: BrowserWebSearchProtocolRecord[], queryHint: string | undefined): string { + const sections = records.flatMap((record, recordIndex) => { + const resultLines = record.results.slice(0, 8).map((result, resultIndex) => { + const content = focusedWebSearchContent(result.content, queryHint); + const details = [ + result.snippet ? `Search snippet: ${result.snippet}` : "", + content ? `Extracted page content: ${content}` : "" + ].filter(Boolean).join("\n"); + return [ + `${resultIndex + 1}. ${result.title}`, + `URL: ${result.url}`, + details + ].filter(Boolean).join("\n"); + }); + if (resultLines.length === 0) { + return []; + } + return [ + [ + `Search ${recordIndex + 1}`, + `Query: ${record.query}`, + `Engine: ${record.engine}`, + `Search URL: ${record.searchUrl}`, + ...resultLines + ].join("\n\n") + ]; + }); + if (sections.length === 0) { + return ""; + } + return [ + "A hidden in-app browser web search has already been performed for this request.", + "Use the evidence below to answer the user's question directly in the visible final response, within 5 concise sentences. Do not call another web search tool, do not merely list links, do not expose hidden reasoning, and do not ask the user to open links. If the evidence is insufficient for an exact value, say that clearly and summarize the most relevant findings with source names.", + queryHint ? `Original search intent: ${queryHint}` : "", + "Web search evidence:", + ...sections + ].filter(Boolean).join("\n\n").slice(0, 10_000); +} + +function focusedWebSearchContent(content: string | undefined, queryHint: string | undefined): string | undefined { + const text = content?.replace(/\s+/g, " ").trim(); + if (!text) { + return undefined; + } + const queryTerms = normalizeSearchComparisonText(queryHint ?? "") + .split(" ") + .filter((term) => term.length >= 2); + const weatherTerms = /天气|weather/i.test(queryHint ?? "") + ? ["天气", "气温", "温度", "体感", "空气质量", "湿度", "风", "降水", "℃", "晴", "多云", "阴", "雨"] + : []; + const terms = uniqueStrings([...queryTerms, ...weatherTerms]); + const indexes = terms.flatMap((term) => { + const index = text.toLowerCase().indexOf(term.toLowerCase()); + return index >= 0 ? [index] : []; + }); + if (indexes.length === 0) { + return text.slice(0, 1_000); + } + const center = Math.min(...indexes); + const start = Math.max(0, center - 300); + return `${start > 0 ? "..." : ""}${text.slice(start, start + 1_200)}${start + 1_200 < text.length ? "..." : ""}`; +} + +function hostedWebSearchProtocolResponseStream( + input: Readable, + headers: Headers, + context: HostedWebSearchProtocolContext, + integration: BrowserWebSearchMcpIntegration | undefined +): Readable { + if (!integration?.recentBrowserWebSearchResults && !integration?.runBrowserWebSearch) { + return input; + } + const contentType = headers.get("content-type")?.toLowerCase() ?? ""; + if (contentType.includes("text/event-stream")) { + return input; + } + if (!contentType.includes("application/json")) { + return input; + } + + const chunks: Buffer[] = []; + return input.pipe(new Transform({ + transform(chunk, _encoding, callback) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + callback(); + }, + flush(callback) { + const body = Buffer.concat(chunks).toString("utf8"); + void (async () => { + const records = await selectHostedWebSearchProtocolRecords(context, integration); + if (records.length === 0) { + this.push(body); + return; + } + + const parsed = JSON.parse(body) as unknown; + const transformed = transformHostedWebSearchProtocolResponseValue(parsed, records, context); + this.push(transformed.changed ? JSON.stringify(transformed.value) : body); + })().catch((error) => { + console.warn(`[gateway] Hosted web search protocol bridge failed: ${formatError(error)}`); + this.push(body); + }).finally(() => callback()); + } + })); +} + +function transformHostedWebSearchProtocolResponseValue( + value: unknown, + records: BrowserWebSearchProtocolRecord[], + context: Pick +): { changed: boolean; value: unknown } { + if (context.protocol === "anthropic_messages") { + return transformAnthropicWebSearchProtocolResponseValue(value, records, context.requestId, context.queryHint); + } + if (context.protocol === "openai_chat_completions") { + return transformOpenAiChatHostedWebSearchResponseValue(value, records, context.queryHint); + } + if (context.protocol === "openai_responses") { + return transformOpenAiResponsesHostedWebSearchResponseValue(value, records, context.requestId, context.queryHint); + } + if (context.protocol === "gemini_generate_content") { + return transformGeminiHostedWebSearchResponseValue(value, records, context.queryHint); + } + return { changed: false, value }; +} + +function transformHostedWebSearchProtocolSseText( + body: string, + records: BrowserWebSearchProtocolRecord[], + context: Pick +): string { + if (context.protocol === "anthropic_messages") { + return transformAnthropicWebSearchProtocolSseText(body, records, context.requestId, context.queryHint); + } + if (context.protocol === "openai_chat_completions") { + return transformOpenAiChatHostedWebSearchSseText(body, records, context.queryHint); + } + if (context.protocol === "openai_responses") { + return transformOpenAiResponsesHostedWebSearchSseText(body, records, context.requestId, context.queryHint); + } + if (context.protocol === "gemini_generate_content") { + return transformGeminiHostedWebSearchSseText(body, records, context.queryHint); + } + return body; +} + +export function transformAnthropicWebSearchProtocolResponseValue( + value: unknown, + records: BrowserWebSearchProtocolRecord[], + requestId: string, + queryHint?: string +): { changed: boolean; value: unknown } { + if (!isRecord(value) || !Array.isArray(value.content)) { + return { changed: false, value }; + } + const hasWebSearchBlocks = responseValueContainsAnthropicWebSearchBlocks(value); + const blocks = hasWebSearchBlocks ? [] : anthropicWebSearchProtocolBlocks(records, requestId); + const answer = responseValueContainsVisibleText(value) + ? undefined + : synthesizeWebSearchAnswer(records, queryHint); + const injectedBlocks = [ + ...blocks, + ...(answer ? [{ text: answer, type: "text" }] : []) + ]; + const shouldUpdateUsage = hasWebSearchBlocks || blocks.length > 0; + if (injectedBlocks.length === 0 && !shouldUpdateUsage) { + return { changed: false, value }; + } + const insertAt = webSearchProtocolInsertIndex(value.content, hasWebSearchBlocks); + const nextValue = { + ...value, + ...(shouldUpdateUsage ? { usage: mergeAnthropicWebSearchUsage(value.usage, records.length) } : {}), + ...(answer && stringValue(value.stop_reason) === "max_tokens" ? { stop_reason: "end_turn" } : {}), + content: injectedBlocks.length > 0 + ? [ + ...value.content.slice(0, insertAt), + ...injectedBlocks, + ...value.content.slice(insertAt) + ] + : value.content + }; + return { + changed: true, + value: nextValue + }; +} + +export function transformAnthropicWebSearchProtocolSseText( + body: string, + records: BrowserWebSearchProtocolRecord[], + requestId: string, + queryHint?: string +): string { + const events = parseSseEvents(body); + if (events.length === 0) { + return body; + } + const hasWebSearchBlocks = sseEventsContainAnthropicWebSearchBlocks(events); + const blocks = hasWebSearchBlocks ? [] : anthropicWebSearchProtocolBlocks(records, requestId); + const answer = sseEventsContainVisibleText(events) + ? undefined + : synthesizeWebSearchAnswer(records, queryHint); + const injectedBlocks = [ + ...blocks, + ...(answer ? [{ text: answer, type: "text" }] : []) + ]; + const shouldUpdateUsage = hasWebSearchBlocks || blocks.length > 0; + if (injectedBlocks.length === 0 && !shouldUpdateUsage) { + return body; + } + + let maxIndex = -1; + for (const event of events) { + if (isRecord(event.data) && Number.isFinite(event.data.index)) { + maxIndex = Math.max(maxIndex, Number(event.data.index)); + } + } + + const firstTextIndex = events.findIndex((event) => { + const data = isRecord(event.data) ? event.data : undefined; + const contentBlock = isRecord(data?.content_block) ? data.content_block : undefined; + return data?.type === "content_block_start" && contentBlock?.type === "text"; + }); + const messageEndIndex = events.findIndex((event) => { + const type = isRecord(event.data) ? stringValue(event.data.type) : undefined; + return type === "message_delta" || type === "message_stop"; + }); + const insertPosition = firstTextIndex >= 0 + ? firstTextIndex + : messageEndIndex >= 0 + ? messageEndIndex + : events.length; + const insertIndex = firstTextIndex >= 0 && isRecord(events[firstTextIndex].data) && Number.isFinite(events[firstTextIndex].data.index) + ? Number(events[firstTextIndex].data.index) + : maxIndex + 1; + + const shiftedEvents = events + .map((event) => shiftSseContentBlockIndex(event, insertIndex, injectedBlocks.length)) + .map((event) => updateAnthropicWebSearchSseUsage(event, records.length, Boolean(answer))); + const injectedEvents = injectedBlocks.flatMap((block, offset) => anthropicWebSearchSseEventsForBlock(block, insertIndex + offset)); + shiftedEvents.splice(insertPosition, 0, ...injectedEvents); + return `${shiftedEvents.map(serializeSseEvent).join("\n\n")}\n\n`; +} + +export function transformOpenAiChatHostedWebSearchResponseValue( + value: unknown, + records: BrowserWebSearchProtocolRecord[], + queryHint?: string +): { changed: boolean; value: unknown } { + if (!isRecord(value) || openAiChatResponseContainsVisibleText(value)) { + return { changed: false, value }; + } + const answer = synthesizeWebSearchAnswer(records, queryHint); + if (!answer || !Array.isArray(value.choices) || value.choices.length === 0) { + return { changed: false, value }; + } + const choices = value.choices.map((choice, index) => { + if (!isRecord(choice) || index !== 0) { + return choice; + } + const message = isRecord(choice.message) ? choice.message : {}; + return { + ...choice, + finish_reason: stringValue(choice.finish_reason) === "length" ? "stop" : choice.finish_reason, + message: { + ...message, + content: answer, + role: stringValue(message.role) || "assistant" + } + }; + }); + return { + changed: true, + value: { + ...value, + choices + } + }; +} + +export function transformOpenAiChatHostedWebSearchSseText( + body: string, + records: BrowserWebSearchProtocolRecord[], + queryHint?: string +): string { + const events = parseSseEvents(body); + if (events.length === 0 || openAiChatSseContainsVisibleText(events)) { + return body; + } + const answer = synthesizeWebSearchAnswer(records, queryHint); + if (!answer) { + return body; + } + const template = firstSseDataRecord(events); + const injected = sseEventFromValue({ + ...(template?.id ? { id: template.id } : {}), + ...(template?.model ? { model: template.model } : {}), + object: stringValue(template?.object) || "chat.completion.chunk", + choices: [ + { + delta: { content: answer }, + finish_reason: null, + index: 0 + } + ] + }); + const shifted = events.map((event) => updateOpenAiChatSseFinishReason(event)); + const insertAt = doneSseEventIndex(shifted); + shifted.splice(insertAt >= 0 ? insertAt : shifted.length, 0, injected); + return `${shifted.map(serializeSseEvent).join("\n\n")}\n\n`; +} + +export function transformOpenAiResponsesHostedWebSearchResponseValue( + value: unknown, + records: BrowserWebSearchProtocolRecord[], + requestId: string, + queryHint?: string +): { changed: boolean; value: unknown } { + if (!isRecord(value)) { + return { changed: false, value }; + } + const output = Array.isArray(value.output) ? value.output : []; + const hasSearchCall = output.some((item) => isRecord(item) && stringValue(item.type) === "web_search_call"); + const answer = openAiResponsesValueContainsVisibleText(value) ? undefined : synthesizeWebSearchAnswer(records, queryHint); + const injected = [ + ...(hasSearchCall ? [] : openAiResponsesWebSearchCallItems(records, requestId)), + ...(answer ? [openAiResponsesMessageItem(answer, requestId)] : []) + ]; + if (injected.length === 0) { + return { changed: false, value }; + } + const next: Record = { + ...value, + output: [...injected, ...output] + }; + if (answer && stringValue(next.status) === "incomplete") { + next.status = "completed"; + delete next.incomplete_details; + } + return { changed: true, value: next }; +} + +export function transformOpenAiResponsesHostedWebSearchSseText( + body: string, + records: BrowserWebSearchProtocolRecord[], + requestId: string, + queryHint?: string +): string { + const events = parseSseEvents(body); + if (events.length === 0) { + return body; + } + const visibleText = openAiResponsesSseVisibleText(events); + if (visibleText) { + return serializeOpenAiResponsesSseEvents(normalizeOpenAiResponsesSseEvents(events, visibleText)); + } + const answer = synthesizeWebSearchAnswer(records, queryHint); + if (!answer) { + return serializeOpenAiResponsesSseEvents(normalizeOpenAiResponsesSseEvents(events)); + } + const outputIndex = nextOpenAiResponsesOutputIndex(events); + const itemId = `msg_${sanitizeAnthropicToolUseId(requestId)}_web_search_answer`; + const contentIndex = 0; + const injected = [ + sseEventFromValue({ + item: { + id: itemId, + role: "assistant", + status: "in_progress", + type: "message" + }, + output_index: outputIndex, + type: "response.output_item.added" + }), + sseEventFromValue({ + content_index: contentIndex, + item_id: itemId, + output_index: outputIndex, + part: { annotations: [], text: "", type: "output_text" }, + type: "response.content_part.added" + }), + sseEventFromValue({ + content_index: contentIndex, + delta: answer, + item_id: itemId, + output_index: outputIndex, + type: "response.output_text.delta" + }), + sseEventFromValue({ + content_index: contentIndex, + item_id: itemId, + output_index: outputIndex, + text: answer, + type: "response.output_text.done" + }), + sseEventFromValue({ + content_index: contentIndex, + item_id: itemId, + output_index: outputIndex, + part: { annotations: [], text: answer, type: "output_text" }, + type: "response.content_part.done" + }), + sseEventFromValue({ + item: { + content: [{ annotations: [], text: answer, type: "output_text" }], + id: itemId, + role: "assistant", + status: "completed", + type: "message" + }, + output_index: outputIndex, + type: "response.output_item.done" + }) + ]; + const shifted = events.map(updateOpenAiResponsesCompletedStatus); + const insertAt = shifted.findIndex((event) => isRecord(event.data) && stringValue(event.data.type) === "response.completed"); + shifted.splice(insertAt >= 0 ? insertAt : doneSseEventIndex(shifted) >= 0 ? doneSseEventIndex(shifted) : shifted.length, 0, ...injected); + return serializeOpenAiResponsesSseEvents(normalizeOpenAiResponsesSseEvents(shifted, answer)); +} + +export function transformGeminiHostedWebSearchResponseValue( + value: unknown, + records: BrowserWebSearchProtocolRecord[], + queryHint?: string +): { changed: boolean; value: unknown } { + if (Array.isArray(value)) { + if (geminiResponseArrayContainsVisibleText(value)) { + return { changed: false, value }; + } + const answer = synthesizeWebSearchAnswer(records, queryHint); + return answer + ? { changed: true, value: [...value, geminiAnswerCandidateChunk(answer)] } + : { changed: false, value }; + } + if (!isRecord(value) || geminiResponseValueContainsVisibleText(value)) { + return { changed: false, value }; + } + const answer = synthesizeWebSearchAnswer(records, queryHint); + if (!answer) { + return { changed: false, value }; + } + const candidates = Array.isArray(value.candidates) ? value.candidates : []; + const nextCandidate = candidates.length > 0 && isRecord(candidates[0]) + ? { + ...candidates[0], + content: { + ...(isRecord(candidates[0].content) ? candidates[0].content : {}), + parts: [{ text: answer }], + role: "model" + }, + finishReason: stringValue(candidates[0].finishReason) === "MAX_TOKENS" ? "STOP" : candidates[0].finishReason + } + : geminiAnswerCandidate(answer); + return { + changed: true, + value: { + ...value, + candidates: [nextCandidate, ...candidates.slice(1)] + } + }; +} + +export function transformGeminiHostedWebSearchSseText( + body: string, + records: BrowserWebSearchProtocolRecord[], + queryHint?: string +): string { + const events = parseSseEvents(body); + if (events.length === 0 || geminiSseContainsVisibleText(events)) { + return body; + } + const answer = synthesizeWebSearchAnswer(records, queryHint); + if (!answer) { + return body; + } + const injected = sseEventFromValue(geminiAnswerCandidateChunk(answer)); + const insertAt = doneSseEventIndex(events); + events.splice(insertAt >= 0 ? insertAt : events.length, 0, injected); + return `${events.map(serializeSseEvent).join("\n\n")}\n\n`; +} + +function openAiChatResponseContainsVisibleText(value: Record): boolean { + if (!Array.isArray(value.choices)) { + return false; + } + return value.choices.some((choice) => { + const message = isRecord(choice) && isRecord(choice.message) ? choice.message : undefined; + return Boolean(stringValue(message?.content)?.trim()); + }); +} + +function openAiChatSseContainsVisibleText(events: ParsedSseEvent[]): boolean { + return events.some((event) => { + const choices = isRecord(event.data) && Array.isArray(event.data.choices) ? event.data.choices : []; + return choices.some((choice) => { + const delta = isRecord(choice) && isRecord(choice.delta) ? choice.delta : undefined; + return Boolean(stringValue(delta?.content)?.trim()); + }); + }); +} + +function updateOpenAiChatSseFinishReason(event: ParsedSseEvent): ParsedSseEvent { + if (!isRecord(event.data) || !Array.isArray(event.data.choices)) { + return event; + } + let changed = false; + const choices = event.data.choices.map((choice) => { + if (!isRecord(choice) || stringValue(choice.finish_reason) !== "length") { + return choice; + } + changed = true; + return { ...choice, finish_reason: "stop" }; + }); + return changed ? { ...event, data: { ...event.data, choices } } : event; +} + +function openAiResponsesValueContainsVisibleText(value: Record): boolean { + return Array.isArray(value.output) && value.output.some(openAiResponsesItemContainsVisibleText); +} + +function openAiResponsesItemContainsVisibleText(item: unknown): boolean { + if (!isRecord(item)) { + return false; + } + if (stringValue(item.type) === "message" && Array.isArray(item.content)) { + return item.content.some((part) => isRecord(part) && Boolean(stringValue(part.text)?.trim())); + } + return Boolean(stringValue(item.text)?.trim()); +} + +function openAiResponsesSseContainsVisibleText(events: ParsedSseEvent[]): boolean { + return Boolean(openAiResponsesSseVisibleText(events)); +} + +function openAiResponsesSseVisibleText(events: ParsedSseEvent[]): string | undefined { + let deltaText = ""; + let doneText = ""; + let itemText = ""; + for (const event of events) { + const data = isRecord(event.data) ? event.data : undefined; + if (!data) { + continue; + } + const type = stringValue(data.type); + if (type === "response.output_text.delta") { + deltaText += stringValue(data.delta) ?? ""; + continue; + } + if (type === "response.output_text.done") { + doneText = stringValue(data.text) ?? doneText; + continue; + } + const item = isRecord(data.item) ? data.item : undefined; + if (item && openAiResponsesItemContainsVisibleText(item)) { + itemText = openAiResponsesItemText(item) ?? itemText; + } + } + return nonEmptyText(deltaText) ?? nonEmptyText(doneText) ?? nonEmptyText(itemText); +} + +function openAiResponsesItemText(item: unknown): string | undefined { + if (!isRecord(item)) { + return undefined; + } + if (stringValue(item.type) === "message" && Array.isArray(item.content)) { + const text = item.content + .flatMap((part) => isRecord(part) ? [stringValue(part.text) ?? ""] : []) + .join(""); + return text.trim() ? text : undefined; + } + return stringValue(item.text); +} + +function nonEmptyText(value: string | undefined): string | undefined { + return value && value.trim() ? value : undefined; +} + +function openAiResponsesWebSearchCallItems(records: BrowserWebSearchProtocolRecord[], requestId: string): Record[] { + return records.map((record, index) => ({ + action: { + query: record.query, + type: "search" + }, + id: `ws_${sanitizeAnthropicToolUseId(requestId)}_${index + 1}`, + status: "completed", + type: "web_search_call" + })); +} + +function openAiResponsesMessageItem(answer: string, requestId: string): Record { + return { + content: [{ annotations: [], text: answer, type: "output_text" }], + id: `msg_${sanitizeAnthropicToolUseId(requestId)}_web_search_answer`, + role: "assistant", + status: "completed", + type: "message" + }; +} + +function nextOpenAiResponsesOutputIndex(events: ParsedSseEvent[]): number { + const indexes = events.flatMap((event) => { + const data = isRecord(event.data) ? event.data : undefined; + const index = numberValue(data?.output_index); + return index === undefined ? [] : [index]; + }); + return indexes.length === 0 ? 0 : Math.max(...indexes) + 1; +} + +function updateOpenAiResponsesCompletedStatus(event: ParsedSseEvent): ParsedSseEvent { + if (!isRecord(event.data) || stringValue(event.data.type) !== "response.completed") { + return event; + } + const response = isRecord(event.data.response) ? event.data.response : undefined; + if (!response || stringValue(response.status) !== "incomplete") { + return event; + } + const nextResponse: Record = { ...response, status: "completed" }; + delete nextResponse.incomplete_details; + return { + ...event, + data: { + ...event.data, + response: nextResponse + } + }; +} + +function normalizeOpenAiResponsesSseEvents(events: ParsedSseEvent[], visibleText?: string): ParsedSseEvent[] { + const outputIndexMap = openAiResponsesOutputIndexMap(events); + return events.flatMap((event) => { + if (isOpenAiResponsesReasoningSseEvent(event)) { + return []; + } + return [updateOpenAiResponsesCompletedOutput( + remapOpenAiResponsesOutputIndex(event, outputIndexMap), + visibleText + )]; + }); +} + +function serializeOpenAiResponsesSseEvents(events: ParsedSseEvent[]): string { + return `${events.map(serializeSseEvent).join("\n\n")}\n\n`; +} + +function openAiResponsesOutputIndexMap(events: ParsedSseEvent[]): Map { + const indexes: number[] = []; + for (const event of events) { + if (isOpenAiResponsesReasoningSseEvent(event)) { + continue; + } + const data = isRecord(event.data) ? event.data : undefined; + const index = numberValue(data?.output_index); + if (index !== undefined && !indexes.includes(index)) { + indexes.push(index); + } + } + return new Map(indexes.sort((left, right) => left - right).map((index, nextIndex) => [index, nextIndex])); +} + +function remapOpenAiResponsesOutputIndex(event: ParsedSseEvent, outputIndexMap: Map): ParsedSseEvent { + if (!isRecord(event.data)) { + return event; + } + const index = numberValue(event.data.output_index); + if (index === undefined || !outputIndexMap.has(index)) { + return event; + } + return { + ...event, + data: { + ...event.data, + output_index: outputIndexMap.get(index) + } + }; +} + +function updateOpenAiResponsesCompletedOutput(event: ParsedSseEvent, visibleText?: string): ParsedSseEvent { + if (!isRecord(event.data) || stringValue(event.data.type) !== "response.completed") { + return event; + } + const response = isRecord(event.data.response) ? event.data.response : undefined; + if (!response) { + return event; + } + const nextResponse: Record = { ...response }; + if (Array.isArray(response.output)) { + nextResponse.output = response.output.filter((item) => !(isRecord(item) && stringValue(item.type) === "reasoning")); + } + if (visibleText) { + nextResponse.output_text = visibleText; + } + if (visibleText && stringValue(nextResponse.status) === "incomplete") { + nextResponse.status = "completed"; + delete nextResponse.incomplete_details; + } + return { + ...event, + data: { + ...event.data, + response: nextResponse + } + }; +} + +function isOpenAiResponsesReasoningSseEvent(event: ParsedSseEvent): boolean { + const data = isRecord(event.data) ? event.data : undefined; + if (!data) { + return false; + } + const type = stringValue(data.type); + if (type?.startsWith("response.reasoning")) { + return true; + } + const item = isRecord(data.item) ? data.item : undefined; + if (stringValue(item?.type) === "reasoning") { + return true; + } + const part = isRecord(data.part) ? data.part : undefined; + return stringValue(part?.type) === "reasoning_text"; +} + +function geminiResponseValueContainsVisibleText(value: Record): boolean { + return Array.isArray(value.candidates) && value.candidates.some(geminiCandidateContainsVisibleText); +} + +function geminiResponseArrayContainsVisibleText(values: unknown[]): boolean { + return values.some((value) => isRecord(value) && geminiResponseValueContainsVisibleText(value)); +} + +function geminiCandidateContainsVisibleText(candidate: unknown): boolean { + const content = isRecord(candidate) && isRecord(candidate.content) ? candidate.content : undefined; + const parts = Array.isArray(content?.parts) ? content.parts : []; + return parts.some((part) => isRecord(part) && Boolean(stringValue(part.text)?.trim())); +} + +function geminiSseContainsVisibleText(events: ParsedSseEvent[]): boolean { + return events.some((event) => isRecord(event.data) && geminiResponseValueContainsVisibleText(event.data)); +} + +function geminiAnswerCandidate(answer: string): Record { + return { + content: { + parts: [{ text: answer }], + role: "model" + }, + finishReason: "STOP", + index: 0 + }; +} + +function geminiAnswerCandidateChunk(answer: string): Record { + return { + candidates: [geminiAnswerCandidate(answer)] + }; +} + +function firstSseDataRecord(events: ParsedSseEvent[]): Record | undefined { + return events.map((event) => isRecord(event.data) ? event.data : undefined).find(Boolean); +} + +function doneSseEventIndex(events: ParsedSseEvent[]): number { + return events.findIndex((event) => event.raw?.includes("[DONE]")); +} + +function hasAnthropicHostedWebSearchTool(tools: unknown): boolean { + if (!Array.isArray(tools)) { + return false; + } + return tools.some(isAnthropicHostedWebSearchTool); +} + +function hasHostedWebSearchDeclaration(body: Record, protocol: GatewayProviderProtocol): boolean { + if (protocol === "anthropic_messages") { + return hasAnthropicHostedWebSearchTool(body.tools); + } + if (protocol === "openai_chat_completions" || protocol === "openai_responses") { + return hasOpenAiHostedWebSearchDeclaration(body); + } + if (protocol === "gemini_generate_content") { + return hasGeminiHostedWebSearchTool(body.tools); + } + return false; +} + +function hasOpenAiHostedWebSearchDeclaration(body: Record): boolean { + if (body.web_search_options !== undefined || body.webSearchOptions !== undefined) { + return true; + } + return Array.isArray(body.tools) && body.tools.some(isOpenAiHostedWebSearchTool); +} + +function hasGeminiHostedWebSearchTool(tools: unknown): boolean { + if (!Array.isArray(tools)) { + return false; + } + return tools.some((tool) => { + if (!isRecord(tool)) { + return false; + } + if (tool.google_search !== undefined || tool.googleSearch !== undefined || tool.google_search_retrieval !== undefined || tool.googleSearchRetrieval !== undefined) { + return true; + } + return false; + }); +} + +function isAnthropicHostedWebSearchTool(tool: unknown): boolean { + if (!isRecord(tool)) { + return false; + } + return anthropicHostedWebSearchType(stringValue(tool.type)); +} + +function isOpenAiHostedWebSearchTool(tool: unknown): boolean { + if (!isRecord(tool)) { + return false; + } + return openAiHostedWebSearchType(stringValue(tool.type)); +} + +function openAiToolChoiceNamesWebSearch(value: unknown): boolean { + if (typeof value === "string") { + return openAiHostedWebSearchType(value); + } + if (!isRecord(value)) { + return false; + } + return openAiHostedWebSearchType(stringValue(value.type)); +} + +function anthropicHostedWebSearchType(value: string | undefined): boolean { + const normalized = normalizedToolProtocolName(value); + return normalized === "web_search" || normalized === "web_search_20250305"; +} + +function openAiHostedWebSearchType(value: string | undefined): boolean { + const normalized = normalizedToolProtocolName(value); + return normalized === "web_search" || + normalized === "web_search_preview" || + normalized.startsWith("web_search_preview_"); +} + +function normalizedToolProtocolName(value: string | undefined): string { + return value?.trim().toLowerCase().replace(/[-.]/g, "_") ?? ""; +} + +function readAnthropicWebSearchMaxUses(tools: unknown): number | undefined { + if (!Array.isArray(tools)) { + return undefined; + } + const tool = tools.find((item) => isRecord(item) && stringValue(item.type)?.toLowerCase() === "web_search_20250305"); + return isRecord(tool) ? numberValue(tool.max_uses ?? tool.maxUses) : undefined; +} + +function readHostedWebSearchMaxUses(body: Record, protocol: GatewayProviderProtocol): number | undefined { + if (protocol === "anthropic_messages") { + return readAnthropicWebSearchMaxUses(body.tools); + } + if (protocol === "openai_chat_completions" || protocol === "openai_responses") { + const tool = Array.isArray(body.tools) ? body.tools.find(isOpenAiHostedWebSearchTool) : undefined; + return isRecord(tool) ? numberValue(tool.max_uses ?? tool.maxUses) : undefined; + } + return undefined; +} + +export function extractHostedWebSearchQueryHint(body: Record, protocol: GatewayProviderProtocol): string | undefined { + if (protocol === "anthropic_messages") { + return extractAnthropicWebSearchQueryHint(body); + } + if (protocol === "openai_chat_completions") { + return normalizedWebSearchQueryHintFromParts(textPartsFromOpenAiChatMessages(body.messages)); + } + if (protocol === "openai_responses") { + return normalizedWebSearchQueryHintFromParts(textPartsFromOpenAiResponsesInput(body.input)); + } + if (protocol === "gemini_generate_content") { + return normalizedWebSearchQueryHintFromParts(textPartsFromGeminiContents(body.contents)); + } + return undefined; +} + +function extractAnthropicWebSearchQueryHint(body: Record): string | undefined { + const userTexts = Array.isArray(body.messages) + ? body.messages.flatMap((message) => { + if (!isRecord(message) || stringValue(message.role) !== "user") { + return []; + } + return textPartsFromAnthropicContent(message.content); + }) + : []; + return normalizedWebSearchQueryHintFromParts(userTexts); +} + +function normalizedWebSearchQueryHintFromParts(parts: string[]): string | undefined { + const candidates = parts + .map((part) => part.trim()) + .filter(Boolean); + for (const candidate of [...candidates].reverse()) { + const explicit = extractExplicitWebSearchQuery(candidate); + if (explicit) { + return normalizedWebSearchQueryHint(explicit); + } + } + for (const candidate of [...candidates].reverse()) { + if (isRuntimeContextText(candidate)) { + continue; + } + return normalizedWebSearchQueryHint(stripSearchIntentPrefix(candidate)); + } + return normalizedWebSearchQueryHint(candidates.join("\n")); +} + +function normalizedWebSearchQueryHint(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + const joined = value.trim(); + if (!joined) { + return undefined; + } + return joined.trim().slice(0, 500); +} + +function extractExplicitWebSearchQuery(value: string): string | undefined { + const explicit = /perform\s+a\s+web\s+search\s+for\s+the\s+query:\s*([\s\S]+)$/i.exec(value.trim()); + return normalizedWebSearchQueryHint(explicit?.[1]); +} + +function stripSearchIntentPrefix(value: string): string { + const trimmed = value.trim(); + const match = /^(?:请)?(?:帮我)?(?:搜索|查询|查一下|帮我查一下|搜一下)\s*[::]?\s*([\s\S]+)$/i.exec(trimmed); + return (match?.[1] || trimmed).trim(); +} + +function isRuntimeContextText(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + if (/^<(?:environment_context|permissions instructions|collaboration_mode|skills_instructions|plugins_instructions|apps_instructions)>/i.test(trimmed)) { + return true; + } + return ( + trimmed.includes("") || + trimmed.includes("") || + trimmed.includes("") || + trimmed.includes("") + ); +} + +function textPartsFromAnthropicContent(content: unknown): string[] { + if (typeof content === "string") { + return [content]; + } + if (!Array.isArray(content)) { + return []; + } + return content.flatMap((part) => isRecord(part) && typeof part.text === "string" ? [part.text] : []); +} + +function textPartsFromOpenAiChatMessages(messages: unknown): string[] { + if (!Array.isArray(messages)) { + return []; + } + return messages.flatMap((message) => { + if (!isRecord(message) || stringValue(message.role)?.toLowerCase() !== "user") { + return []; + } + return textPartsFromOpenAiContent(message.content); + }); +} + +function textPartsFromOpenAiContent(content: unknown): string[] { + if (typeof content === "string") { + return [content]; + } + if (!Array.isArray(content)) { + return []; + } + return content.flatMap((part) => { + if (!isRecord(part)) { + return []; + } + const type = stringValue(part.type); + if (type === "text" || type === "input_text" || type === "output_text") { + return stringValue(part.text) ? [stringValue(part.text) as string] : []; + } + return []; + }); +} + +function textPartsFromOpenAiResponsesInput(input: unknown): string[] { + if (typeof input === "string") { + return [input]; + } + if (!Array.isArray(input)) { + return []; + } + return input.flatMap((item) => { + if (!isRecord(item)) { + return []; + } + const role = stringValue(item.role)?.toLowerCase(); + if (role && role !== "user") { + return []; + } + return textPartsFromOpenAiContent(item.content); + }); +} + +function textPartsFromGeminiContents(contents: unknown): string[] { + if (!Array.isArray(contents)) { + return []; + } + return contents.flatMap((content) => { + if (!isRecord(content)) { + return []; + } + const role = stringValue(content.role)?.toLowerCase(); + if (role && role !== "user") { + return []; + } + const parts = Array.isArray(content.parts) ? content.parts : []; + return parts.flatMap((part) => isRecord(part) && typeof part.text === "string" ? [part.text] : []); + }); +} + +export function fusionWebSearchToolNameForRequest(config: AppConfig, model: string | undefined): string | undefined { + const normalizedModel = model ? fusionModelNameFromSelector(model) : ""; + const rawProfiles = Array.isArray(config.virtualModelProfiles) ? config.virtualModelProfiles : []; + const profiles = normalizeCoreGatewayVirtualModelProfiles( + withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases(rawProfiles)), + config + ); + 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 || webSearchConfig.provider !== "browser") { + 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))); + if (!normalizedModel || aliases.some((alias) => fusionModelNameFromSelector(alias).toLowerCase() === normalizedModel.toLowerCase())) { + return webSearchConfig.toolName; + } + } + return undefined; +} + +async function selectHostedWebSearchProtocolRecords( + context: HostedWebSearchProtocolContext, + integration: BrowserWebSearchMcpIntegration +): Promise { + const records = [ + ...(context.records ?? []), + ...(integration.recentBrowserWebSearchResults?.({ sinceMs: context.sinceMs, toolName: context.toolName }) ?? []) + ] + .filter((record) => record.results.length > 0) + .filter(uniqueSearchRecordFilter()) + .sort((left, right) => { + const queryScoreDelta = queryMatchScore(context.queryHint, right.query) - queryMatchScore(context.queryHint, left.query); + return queryScoreDelta || left.completedAtMs - right.completedAtMs; + }); + if (records.length > 0) { + return records.slice(0, 8); + } + if (!context.queryHint || !integration.runBrowserWebSearch) { + return []; + } + const record = await integration.runBrowserWebSearch({ + count: Math.trunc(clampNumber(context.maxUses ?? 5, 1, 10)), + prompt: context.queryHint, + timeoutMs: 30_000, + toolName: context.toolName + }); + return record?.results.length ? [record] : []; +} + +async function selectAnthropicWebSearchProtocolRecords( + context: AnthropicWebSearchProtocolContext, + integration: BrowserWebSearchMcpIntegration +): Promise { + return selectHostedWebSearchProtocolRecords(context, integration); +} + +function uniqueSearchRecordFilter(): (record: BrowserWebSearchProtocolRecord) => boolean { + const seen = new Set(); + return (record) => { + const key = `${record.toolName}\n${record.query}\n${record.searchUrl}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }; +} + +function queryMatchScore(queryHint: string | undefined, query: string): number { + if (!queryHint) { + return 0; + } + const left = normalizeSearchComparisonText(queryHint); + const right = normalizeSearchComparisonText(query); + if (!left || !right) { + return 0; + } + if (left === right) { + return 4; + } + if (left.includes(right) || right.includes(left)) { + return 3; + } + const leftTerms = new Set(left.split(" ").filter((item) => item.length > 2)); + const rightTerms = right.split(" ").filter((item) => item.length > 2); + return rightTerms.reduce((score, term) => score + (leftTerms.has(term) ? 1 : 0), 0); +} + +function normalizeSearchComparisonText(value: string): string { + return value.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").replace(/\s+/g, " ").trim(); +} + +function responseValueContainsAnthropicWebSearchBlocks(value: Record): boolean { + return Array.isArray(value.content) && value.content.some((block) => { + const type = isRecord(block) ? stringValue(block.type) : undefined; + return type === "server_tool_use" || type === "web_search_tool_result"; + }); +} + +function responseValueContainsVisibleText(value: Record): boolean { + return Array.isArray(value.content) && value.content.some((block) => { + if (!isRecord(block) || stringValue(block.type) !== "text") { + return false; + } + return Boolean(stringValue(block.text)?.trim()); + }); +} + +function leadingThinkingBlockCount(content: unknown[]): number { + let index = 0; + while (index < content.length) { + const block = content[index]; + if (!isRecord(block) || stringValue(block.type) !== "thinking") { + break; + } + index += 1; + } + return index; +} + +function webSearchProtocolInsertIndex(content: unknown[], hasWebSearchBlocks: boolean): number { + let index = leadingThinkingBlockCount(content); + if (!hasWebSearchBlocks) { + return index; + } + while (index < content.length) { + const block = content[index]; + const type = isRecord(block) ? stringValue(block.type) : undefined; + if (type !== "server_tool_use" && type !== "web_search_tool_result") { + break; + } + index += 1; + } + return index; +} + +function mergeAnthropicWebSearchUsage(usage: unknown, searchCount: number): Record { + const nextUsage = isRecord(usage) ? { ...usage } : {}; + const serverToolUse = isRecord(nextUsage.server_tool_use) ? { ...nextUsage.server_tool_use } : {}; + const webSearchRequests = Math.max(1, Math.trunc(searchCount)); + serverToolUse.web_search_requests = Math.max(numberValue(serverToolUse.web_search_requests) ?? 0, webSearchRequests); + nextUsage.server_tool_use = serverToolUse; + return nextUsage; +} + +function sseEventsContainAnthropicWebSearchBlocks(events: ParsedSseEvent[]): boolean { + return events.some((event) => { + const data = isRecord(event.data) ? event.data : undefined; + const block = isRecord(data?.content_block) ? data.content_block : undefined; + const type = stringValue(block?.type) || stringValue(data?.type); + return type === "server_tool_use" || type === "web_search_tool_result"; + }); +} + +function sseEventsContainVisibleText(events: ParsedSseEvent[]): boolean { + return events.some((event) => { + const data = isRecord(event.data) ? event.data : undefined; + if (!data) { + return false; + } + const block = isRecord(data.content_block) ? data.content_block : undefined; + if (stringValue(data.type) === "content_block_start" && stringValue(block?.type) === "text") { + return Boolean(stringValue(block?.text)?.trim()); + } + const delta = isRecord(data.delta) ? data.delta : undefined; + return stringValue(data.type) === "content_block_delta" && + stringValue(delta?.type) === "text_delta" && + Boolean(stringValue(delta?.text)?.trim()); + }); +} + +function anthropicWebSearchSseEventsForBlock(block: Record, index: number): ParsedSseEvent[] { + if (stringValue(block.type) === "text") { + const text = stringValue(block.text) ?? ""; + return [ + sseEventFromValue({ + content_block: { text: "", type: "text" }, + index, + type: "content_block_start" + }), + sseEventFromValue({ + delta: { text, type: "text_delta" }, + index, + type: "content_block_delta" + }), + sseEventFromValue({ + index, + type: "content_block_stop" + }) + ]; + } + return [ + sseEventFromValue({ + content_block: block, + index, + type: "content_block_start" + }), + sseEventFromValue({ + index, + type: "content_block_stop" + }) + ]; +} + +function updateAnthropicWebSearchSseUsage(event: ParsedSseEvent, searchCount: number, didSynthesizeAnswer: boolean): ParsedSseEvent { + if (!isRecord(event.data) || stringValue(event.data.type) !== "message_delta") { + return event; + } + const delta = isRecord(event.data.delta) ? { ...event.data.delta } : event.data.delta; + const nextData: Record = { + ...event.data, + usage: mergeAnthropicWebSearchUsage(event.data.usage, searchCount) + }; + if (didSynthesizeAnswer && isRecord(delta) && stringValue(delta.stop_reason) === "max_tokens") { + nextData.delta = { ...delta, stop_reason: "end_turn" }; + } + return { + ...event, + data: nextData + }; +} + +function synthesizeWebSearchAnswer(records: BrowserWebSearchProtocolRecord[], queryHint: string | undefined): string | undefined { + const query = queryHint || records.map((record) => record.query).find(Boolean) || ""; + const weatherAnswer = synthesizeWeatherWebSearchAnswer(records, query); + if (weatherAnswer) { + return weatherAnswer; + } + const evidence = topWebSearchEvidenceSentences(records, query, 3); + if (evidence.length === 0) { + const sources = webSearchSourceNames(records, 3); + if (!sources) { + return undefined; + } + return containsCjkText(query) + ? `搜索已完成,但页面可提取正文不足。较相关的来源包括:${sources}。` + : `The search completed, but the pages did not expose enough extractable text. The most relevant sources are: ${sources}.`; + } + const sources = webSearchSourceNames(records, 3); + return containsCjkText(query) + ? `根据搜索结果,${evidence.join(";")}。${sources ? `来源:${sources}。` : ""}` + : `Based on the search results, ${evidence.join("; ")}.${sources ? ` Sources: ${sources}.` : ""}`; +} + +function synthesizeWeatherWebSearchAnswer(records: BrowserWebSearchProtocolRecord[], query: string): string | undefined { + if (!/天气|气温|温度|weather|forecast|temperature/i.test(query)) { + return undefined; + } + const items = webSearchEvidenceItems(records); + const text = items.map((item) => item.text).join(" "); + if (!text) { + return undefined; + } + const cjk = containsCjkText(query); + const location = extractWeatherLocation(query); + const temperatureRange = weatherTemperatureRange(text); + const currentTemperature = firstRegexGroup(text, [ + /(?:当前|现在|实时|实况|气温|温度)[^。;,,\d-]{0,12}(-?\d{1,2}(?:\.\d+)?)\s*℃/i, + location ? new RegExp(`${escapeRegExp(location)}\\s+(-?\\d{1,2}(?:\\.\\d+)?)\\s*℃`) : undefined + ]); + const feelsLike = firstRegexGroup(text, [/体感温度[::\s]*(-?\d{1,2}(?:\.\d+)?)\s*℃/]); + const high = firstRegexGroup(text, [/最高气温[::\s]*(-?\d{1,2}(?:\.\d+)?)\s*℃/]); + const low = firstRegexGroup(text, [/最低气温[::\s]*(-?\d{1,2}(?:\.\d+)?)\s*℃/]); + const humidity = firstRegexGroup(text, [/(?:最大相对湿度|相对湿度)[::\s]*(-?\d{1,3}(?:\.\d+)?%)/]); + const aqi = firstRegexGroup(text, [/AQI最高值[::\s]*(\d{1,3})/i]); + const airQuality = firstRegexGroup(text, [/空气质量[::\s]*([^\s,。;,;]{1,12})/]); + const rain = firstRegexGroup(text, [/(?:过去24小时总降水量|总降水量|降水量)[::\s]*(-?\d+(?:\.\d+)?mm)/i]); + const wind = firstRegexGroup(text, [/最大风力[::\s]*([<>]?\d+级|微风)/, /(东风|东南风|南风|西南风|西风|西北风|北风|东北风)\s*([<>]?\d+级|微风)/]); + + const facts = [ + currentTemperature ? (cjk ? `当前约 ${currentTemperature}℃` : `currently about ${currentTemperature}°C`) : undefined, + !currentTemperature && temperatureRange ? (cjk ? `气温约 ${temperatureRange}` : `temperatures are around ${temperatureRange}`) : undefined, + feelsLike ? (cjk ? `体感约 ${feelsLike}℃` : `feels like about ${feelsLike}°C`) : undefined, + high || low ? (cjk + ? `过去24小时${high ? `最高 ${high}℃` : ""}${high && low ? "、" : ""}${low ? `最低 ${low}℃` : ""}` + : `over the past 24 hours ${high ? `the high was ${high}°C` : ""}${high && low ? " and " : ""}${low ? `the low was ${low}°C` : ""}`) : undefined, + humidity ? (cjk ? `相对湿度最高 ${humidity}` : `relative humidity reached ${humidity}`) : undefined, + aqi ? (cjk ? `AQI 最高 ${aqi}` : `AQI reached ${aqi}`) : undefined, + airQuality && !aqi ? (cjk ? `空气质量 ${airQuality}` : `air quality is ${airQuality}`) : undefined, + rain ? (cjk ? `过去24小时降水量 ${rain}` : `24-hour rainfall is ${rain}`) : undefined, + wind ? (cjk ? `风力 ${wind}` : `wind ${wind}`) : undefined + ].filter((item): item is string => Boolean(item)); + + if (facts.length === 0) { + return undefined; + } + const sources = webSearchSourceNames(records, 2); + if (cjk) { + return `${location ? `${location}天气` : "天气"}:${facts.slice(0, 6).join(",")}。${sources ? `来源:${sources}。` : ""}`; + } + return `${location ? `${location} weather` : "Weather"}: ${facts.slice(0, 6).join(", ")}.${sources ? ` Sources: ${sources}.` : ""}`; +} + +function webSearchEvidenceItems(records: BrowserWebSearchProtocolRecord[]): Array<{ source: string; text: string; url: string }> { + return records.flatMap((record) => record.results.map((result) => ({ + source: result.title || hostnameFromUrl(result.url) || record.engine, + text: sanitizeWebSearchEvidenceText(result.content || result.snippet || ""), + url: result.url + }))).filter((item) => item.text); +} + +function topWebSearchEvidenceSentences(records: BrowserWebSearchProtocolRecord[], query: string, limit: number): string[] { + const terms = relevantSearchTerms(query); + const scored = webSearchEvidenceItems(records).flatMap((item, itemIndex) => { + const sentences = splitEvidenceSentences(item.text).slice(0, 12); + return sentences.map((sentence, sentenceIndex) => { + const normalizedSentence = normalizeSearchComparisonText(sentence); + const termScore = terms.reduce((score, term) => score + (normalizedSentence.includes(term) ? 2 : 0), 0); + const sourceBonus = itemIndex === 0 ? 2 : itemIndex === 1 ? 1 : 0; + const positionBonus = Math.max(0, 4 - sentenceIndex) / 4; + return { + score: termScore + sourceBonus + positionBonus, + sentence + }; + }); + }).filter((item) => item.sentence.length >= 12 && item.sentence.length <= 260); + scored.sort((left, right) => right.score - left.score || left.sentence.length - right.sentence.length); + const seen = new Set(); + return scored.flatMap((item) => { + const key = normalizeSearchComparisonText(item.sentence).slice(0, 120); + if (!key || seen.has(key)) { + return []; + } + seen.add(key); + return [item.sentence]; + }).slice(0, limit); +} + +function splitEvidenceSentences(text: string): string[] { + return text + .replace(/\s+/g, " ") + .split(/[。!?!?]\s*|\n+/g) + .map((sentence) => sentence.trim().replace(/[,,;;::]\s*$/, "")) + .filter((sentence) => sentence && !looksLikeNavigationText(sentence)); +} + +function looksLikeNavigationText(text: string): boolean { + const punctuationCount = (text.match(/[,,。;;::]/g) ?? []).length; + const digitCount = (text.match(/\d/g) ?? []).length; + return text.length > 160 && punctuationCount < 2 && digitCount < 2; +} + +function relevantSearchTerms(query: string): string[] { + const normalizedTerms = normalizeSearchComparisonText(query) + .split(" ") + .filter((term) => term.length >= 2); + const cjkTerms = query.match(/[\p{Script=Han}]{2,}/gu) ?? []; + return uniqueStrings([...normalizedTerms, ...cjkTerms].map((term) => term.toLowerCase())); +} + +function weatherTemperatureRange(text: string): string | undefined { + const values = Array.from(text.matchAll(/(-?\d{1,2}(?:\.\d+)?)\s*℃/g)) + .map((match) => Number(match[1])) + .filter((value) => Number.isFinite(value) && value > -80 && value < 60) + .slice(0, 8); + if (values.length === 0) { + return undefined; + } + const min = Math.min(...values); + const max = Math.max(...values); + const format = (value: number) => Number.isInteger(value) ? String(value) : value.toFixed(1); + return min === max ? `${format(min)}℃` : `${format(min)}-${format(max)}℃`; +} + +function extractWeatherLocation(query: string): string | undefined { + const cleaned = query + .replace(/perform\s+a\s+web\s+search\s+for\s+the\s+query:\s*/i, "") + .replace(/天气预报|天气|气温|温度|怎么样|如何|查询|搜索|今天|今日|现在|当前|请问|weather|forecast|temperature/gi, " ") + .replace(/\s+/g, " ") + .trim(); + if (!cleaned || cleaned.length > 24) { + return undefined; + } + return cleaned; +} + +function firstRegexGroup(text: string, patterns: Array): string | undefined { + for (const pattern of patterns) { + if (!pattern) { + continue; + } + const match = pattern.exec(text); + const value = match?.[1]; + if (value) { + return value.trim(); + } + } + return undefined; +} + +function sanitizeWebSearchEvidenceText(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} + +function containsCjkText(text: string): boolean { + return /\p{Script=Han}/u.test(text); +} + +function webSearchSourceNames(records: BrowserWebSearchProtocolRecord[], limit: number): string { + return uniqueStrings(records.flatMap((record) => record.results.map((result) => { + const title = result.title?.trim(); + return title || hostnameFromUrl(result.url) || record.engine; + }))).slice(0, limit).join("、"); +} + +function hostnameFromUrl(value: string): string | undefined { + try { + return new URL(value).hostname.replace(/^www\./, ""); + } catch { + return undefined; + } +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function anthropicWebSearchProtocolBlocks(records: BrowserWebSearchProtocolRecord[], requestId: string): Record[] { + const blocks: Record[] = []; + records.forEach((record, index) => { + const toolUseId = `srvtoolu_${sanitizeAnthropicToolUseId(requestId)}_${index + 1}`; + blocks.push({ + id: toolUseId, + input: { query: record.query }, + name: "web_search", + type: "server_tool_use" + }); + blocks.push({ + content: record.results.map(anthropicWebSearchResultBlock), + tool_use_id: toolUseId, + type: "web_search_tool_result" + }); + }); + return blocks; +} + +function anthropicWebSearchResultBlock(result: BrowserWebSearchProtocolResult): Record { + const snippet = result.snippet || result.content; + return { + encrypted_content: "", + ...(snippet ? { snippet: snippet.slice(0, 1_200) } : {}), + title: result.title, + type: "web_search_result", + url: result.url + }; +} + +function sanitizeAnthropicToolUseId(value: string): string { + return value.replace(/[^a-zA-Z0-9]/g, "").slice(0, 24) || randomBytes(8).toString("hex"); +} + +type ParsedSseEvent = { + data?: unknown; + event?: string; + raw?: string; +}; + +function parseSseEvents(body: string): ParsedSseEvent[] { + return body + .split(/\r?\n\r?\n/g) + .filter((block) => block.trim()) + .map((raw) => { + const lines = raw.split(/\r?\n/g); + const event = lines + .filter((line) => line.startsWith("event:")) + .map((line) => line.slice(6).trim()) + .find(Boolean); + const data = lines + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).replace(/^ /, "")) + .join("\n"); + if (!data || data === "[DONE]") { + return { event, raw }; + } + try { + return { data: JSON.parse(data) as unknown, event, raw }; + } catch { + return { event, raw }; + } + }); +} + +function shiftSseContentBlockIndex(event: ParsedSseEvent, startIndex: number, delta: number): ParsedSseEvent { + if (!isRecord(event.data) || !Number.isFinite(event.data.index) || Number(event.data.index) < startIndex) { + return event; + } + return { + ...event, + data: { + ...event.data, + index: Number(event.data.index) + delta + } + }; +} + +function sseEventFromValue(data: Record): ParsedSseEvent { + return { + data, + event: stringValue(data.type) + }; +} + +function serializeSseEvent(event: ParsedSseEvent): string { + if (event.data === undefined) { + return event.raw ?? ""; + } + const type = isRecord(event.data) ? stringValue(event.data.type) : undefined; + return [ + event.event || type ? `event: ${event.event || type}` : undefined, + `data: ${JSON.stringify(event.data)}` + ].filter(Boolean).join("\n"); +} + function requestProtocolForPath(path: string): GatewayProviderProtocol | undefined { const normalized = path.toLowerCase(); if (normalized === "/v1/messages" || normalized === "/messages" || normalized.endsWith("/v1/messages")) { @@ -3159,6 +5237,16 @@ function createGatewayProcessEnv(config: AppConfig, upstreamProxyUrl: string | u PORT: String(config.gateway.corePort) }; + const noProxy = mergeNoProxy(env.NO_PROXY || env.no_proxy, [ + "127.0.0.1", + "localhost", + "::1", + config.gateway.host, + config.gateway.coreHost + ]); + env.NO_PROXY = noProxy; + env.no_proxy = noProxy; + if (!upstreamProxyUrl) { return env; } @@ -3166,13 +5254,9 @@ function createGatewayProcessEnv(config: AppConfig, upstreamProxyUrl: string | u env.HTTP_PROXY = upstreamProxyUrl; env.HTTPS_PROXY = upstreamProxyUrl; env.ALL_PROXY = upstreamProxyUrl; - env.NO_PROXY = mergeNoProxy(env.NO_PROXY, [ - "127.0.0.1", - "localhost", - "::1", - config.gateway.host, - config.gateway.coreHost - ]); + env.http_proxy = upstreamProxyUrl; + env.https_proxy = upstreamProxyUrl; + env.all_proxy = upstreamProxyUrl; env.CCR_UPSTREAM_PROXY_URL = upstreamProxyUrl; env.CCR_UNDICI_MODULE = requireFromHere.resolve("undici"); return env; @@ -3187,8 +5271,79 @@ function writeGatewayProxyPreloadFile(config: AppConfig, upstreamProxyUrl: strin "const proxyUrl = process.env.CCR_UPSTREAM_PROXY_URL;", "if (proxyUrl) {", " const undiciModule = process.env.CCR_UNDICI_MODULE || \"undici\";", - " const { ProxyAgent, setGlobalDispatcher } = require(undiciModule);", - " setGlobalDispatcher(new ProxyAgent(proxyUrl));", + " const { Agent, ProxyAgent, setGlobalDispatcher } = require(undiciModule);", + " const directAgent = new Agent();", + " const proxyAgent = new ProxyAgent(proxyUrl);", + " const noProxyEntries = String(process.env.NO_PROXY || process.env.no_proxy || \"\")", + " .split(\",\")", + " .map((entry) => entry.trim())", + " .filter(Boolean);", + " function normalizeHost(host) {", + " return String(host || \"\").replace(/^\\[/, \"\").replace(/\\]$/, \"\").replace(/\\.$/, \"\").toLowerCase();", + " }", + " function isLoopbackHost(host) {", + " return host === \"localhost\" || host === \"::1\" || host === \"0:0:0:0:0:0:0:1\" || host === \"127.0.0.1\" || host.startsWith(\"127.\");", + " }", + " function splitNoProxyEntry(entry) {", + " let value = String(entry || \"\").trim().toLowerCase();", + " if (!value || value === \"*\") {", + " return { host: value, port: \"\" };", + " }", + " if (value.startsWith(\"[\")) {", + " const end = value.indexOf(\"]\");", + " if (end >= 0) {", + " const remainder = value.slice(end + 1);", + " return { host: normalizeHost(value.slice(1, end)), port: remainder.startsWith(\":\") ? remainder.slice(1) : \"\" };", + " }", + " }", + " const colon = value.lastIndexOf(\":\");", + " if (colon > 0 && value.indexOf(\":\") === colon) {", + " return { host: normalizeHost(value.slice(0, colon)), port: value.slice(colon + 1) };", + " }", + " return { host: normalizeHost(value), port: \"\" };", + " }", + " function noProxyMatches(host, port, entry) {", + " const parsed = splitNoProxyEntry(entry);", + " if (parsed.host === \"*\") {", + " return true;", + " }", + " if (!parsed.host || (parsed.port && parsed.port !== port)) {", + " return false;", + " }", + " if (parsed.host.startsWith(\"*.\")) {", + " const suffix = parsed.host.slice(1);", + " return host.endsWith(suffix);", + " }", + " if (parsed.host.startsWith(\".\")) {", + " return host === parsed.host.slice(1) || host.endsWith(parsed.host);", + " }", + " return host === parsed.host || host.endsWith(`.${parsed.host}`);", + " }", + " function shouldBypassProxy(origin) {", + " try {", + " const url = new URL(String(origin));", + " const host = normalizeHost(url.hostname);", + " const port = url.port || (url.protocol === \"https:\" ? \"443\" : url.protocol === \"http:\" ? \"80\" : \"\");", + " return isLoopbackHost(host) || noProxyEntries.some((entry) => noProxyMatches(host, port, entry));", + " } catch {", + " return false;", + " }", + " }", + " setGlobalDispatcher({", + " dispatch(options, handler) {", + " return shouldBypassProxy(options && options.origin)", + " ? directAgent.dispatch(options, handler)", + " : proxyAgent.dispatch(options, handler);", + " },", + " close(callback) {", + " Promise.all([directAgent.close(), proxyAgent.close()]).then(() => callback && callback(), callback);", + " },", + " destroy(error, callback) {", + " directAgent.destroy(error);", + " proxyAgent.destroy(error);", + " if (callback) callback();", + " }", + " });", "}" ].join("\n"), "utf8" diff --git a/src/server/mcp/browser-web-search-proxy-mcp.ts b/src/server/mcp/browser-web-search-proxy-mcp.ts new file mode 100644 index 0000000..d034adf --- /dev/null +++ b/src/server/mcp/browser-web-search-proxy-mcp.ts @@ -0,0 +1,146 @@ +const targetUrl = process.env.BROWSER_WEB_SEARCH_MCP_URL?.trim(); +const requestTimeoutMs = clampInteger(Number(process.env.BROWSER_WEB_SEARCH_PROXY_TIMEOUT_MS), 1_000, 600_000, 120_000); + +let stdinBuffer = Buffer.alloc(0); + +if (!targetUrl) { + process.stderr.write("BROWSER_WEB_SEARCH_MCP_URL is required.\n"); + process.exit(1); +} + +process.stdin.on("data", (chunk: Buffer | string) => { + stdinBuffer = Buffer.concat([stdinBuffer, typeof chunk === "string" ? Buffer.from(chunk) : chunk]); + void drainFrames(); +}); + +process.stdin.on("end", () => { + process.exit(0); +}); + +async function drainFrames(): Promise { + while (true) { + const headerEnd = stdinBuffer.indexOf("\r\n\r\n"); + if (headerEnd < 0) { + return; + } + + const headerText = stdinBuffer.slice(0, headerEnd).toString("utf8"); + const contentLength = readContentLength(headerText); + if (contentLength === undefined || contentLength < 0) { + stdinBuffer = Buffer.alloc(0); + writeFrame(jsonRpcError(null, -32600, "Invalid MCP frame header.")); + return; + } + + const payloadStart = headerEnd + 4; + const payloadEnd = payloadStart + contentLength; + if (stdinBuffer.length < payloadEnd) { + return; + } + + const body = stdinBuffer.slice(payloadStart, payloadEnd).toString("utf8"); + stdinBuffer = stdinBuffer.slice(payloadEnd); + await forwardPayload(body); + } +} + +async function forwardPayload(body: string): Promise { + const requestId = readJsonRpcId(body); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), requestTimeoutMs); + try { + const response = await fetch(targetUrl!, { + body, + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json" + }, + method: "POST", + signal: controller.signal + }); + + if (response.status === 204) { + return; + } + + const text = await response.text(); + if (!response.ok) { + writeFrame(jsonRpcError(requestId, -32603, text || `In-app browser MCP returned HTTP ${response.status}.`)); + return; + } + if (text.trim()) { + writeRawFrame(text); + } + } catch (error) { + writeFrame(jsonRpcError(requestId, -32603, formatError(error))); + } finally { + clearTimeout(timeout); + } +} + +function readContentLength(headerText: string): number | undefined { + for (const line of headerText.split(/\r?\n/)) { + const match = /^content-length\s*:\s*(\d+)\s*$/i.exec(line); + if (match) { + return Number(match[1]); + } + } + return undefined; +} + +function readJsonRpcId(body: string): null | number | string { + try { + const payload = JSON.parse(body) as unknown; + if (isRecord(payload)) { + return isJsonRpcId(payload.id) ? payload.id : null; + } + if (Array.isArray(payload)) { + const first = payload.find((item) => isRecord(item) && isJsonRpcId(item.id)); + return isRecord(first) && isJsonRpcId(first.id) ? first.id : null; + } + } catch { + // The upstream MCP endpoint will return the parse error for valid JSON-RPC framing. + } + return null; +} + +function isJsonRpcId(value: unknown): value is null | number | string { + return value === null || typeof value === "number" || typeof value === "string"; +} + +function jsonRpcError(id: null | number | string, code: number, message: string): Record { + return { + error: { + code, + message + }, + id, + jsonrpc: "2.0" + }; +} + +function writeFrame(payload: Record): void { + writeRawFrame(JSON.stringify(payload)); +} + +function writeRawFrame(body: string): void { + process.stdout.write(`Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`); +} + +function clampInteger(value: number, min: number, max: number, fallback: number): number { + if (!Number.isFinite(value)) { + return fallback; + } + return Math.min(max, Math.max(min, Math.trunc(value))); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function formatError(error: unknown): string { + if (error instanceof Error) { + return error.name === "AbortError" ? "In-app browser MCP proxy request timed out." : error.message; + } + return String(error); +} diff --git a/src/server/mcp/fusion-vision-mcp.ts b/src/server/mcp/fusion-vision-mcp.ts index c6a285e..956866c 100644 --- a/src/server/mcp/fusion-vision-mcp.ts +++ b/src/server/mcp/fusion-vision-mcp.ts @@ -90,6 +90,10 @@ const visionTool = { const webSearchTool = { description: "Search the web with this Fusion profile's configured search provider.", inputSchema: objectSchema({ + allowedDomains: { items: { type: "string" }, type: "array" }, + allowed_domains: { items: { type: "string" }, type: "array" }, + blockedDomains: { items: { type: "string" }, type: "array" }, + blocked_domains: { items: { type: "string" }, type: "array" }, count: { maximum: 20, minimum: 1, type: "number" }, country: { type: "string" }, excludeDomains: { items: { type: "string" }, type: "array" }, @@ -280,9 +284,17 @@ async function analyzeWebSearch(args: Record): Promise const input = { count, country: readString(args.country), - excludeDomains: readStringArray(args.excludeDomains), + excludeDomains: uniqueStrings([ + ...readStringArray(args.excludeDomains), + ...readStringArray(args.blockedDomains), + ...readStringArray(args.blocked_domains) + ]), freshness: readString(args.freshness), - includeDomains: readStringArray(args.includeDomains), + includeDomains: uniqueStrings([ + ...readStringArray(args.includeDomains), + ...readStringArray(args.allowedDomains), + ...readStringArray(args.allowed_domains) + ]), includeRaw: args.includeRaw === true, language: readString(args.language), prompt, @@ -651,12 +663,19 @@ function isSearchResult(value: SearchResult): value is Required item.trim()).filter(Boolean); + } if (!Array.isArray(value)) { return []; } return value.map(readString).filter((item): item is string => Boolean(item)); } +function uniqueStrings(values: string[]): string[] { + return [...new Set(values.filter(Boolean))]; +} + function textResult(text: string): ToolCallResult { return { content: [{ text, type: "text" }] diff --git a/src/shared/app.ts b/src/shared/app.ts index bc91702..56bd581 100644 --- a/src/shared/app.ts +++ b/src/shared/app.ts @@ -675,6 +675,7 @@ export type VirtualModelFusionVisionConfig = { }; export type VirtualModelFusionWebSearchProvider = + | "browser" | "brave" | "bing" | "google_cse" diff --git a/tests/main/codex-model-catalog.test.mjs b/tests/main/codex-model-catalog.test.mjs index 263c2b5..b08f94f 100644 --- a/tests/main/codex-model-catalog.test.mjs +++ b/tests/main/codex-model-catalog.test.mjs @@ -193,7 +193,7 @@ test("codex catalog marks prefixed Fusion virtual models with legacy web search key: "web-prefix", match: { exactAliases: [], prefixes: ["web-"], suffixes: [] }, materialization: { enabled: true, includeInGatewayModels: true }, - tools: [{ name: "web_search", visibility: "internal" }] + tools: [{ name: "web_prefix_web_search", visibility: "internal" }] } ] }, "deepseek/web-deepseek-chat"); diff --git a/tests/main/gateway-virtual-models.test.mjs b/tests/main/gateway-virtual-models.test.mjs index 4931a1f..2f0898c 100644 --- a/tests/main/gateway-virtual-models.test.mjs +++ b/tests/main/gateway-virtual-models.test.mjs @@ -1,6 +1,22 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { normalizeCoreGatewayVirtualModelProfiles } from "../../src/server/gateway/service.ts"; +import { + fusionFallbackToolDefinitions, + fusionWebSearchToolNameForRequest, + fusionToolNamesBackedByMcpServers, + extractHostedWebSearchQueryHint, + prepareAnthropicWebSearchProtocolRequestBody, + prepareHostedWebSearchProtocolRequestBody, + transformAnthropicWebSearchProtocolResponseValue, + transformAnthropicWebSearchProtocolSseText, + transformGeminiHostedWebSearchResponseValue, + transformGeminiHostedWebSearchSseText, + transformOpenAiChatHostedWebSearchResponseValue, + transformOpenAiChatHostedWebSearchSseText, + transformOpenAiResponsesHostedWebSearchResponseValue, + transformOpenAiResponsesHostedWebSearchSseText, + normalizeCoreGatewayVirtualModelProfiles +} from "../../src/server/gateway/service.ts"; test("gateway config rewrites Fusion fixed base and vision models to core provider selectors", () => { const providerName = "Zhipu AI (China) - Coding Plan"; @@ -59,3 +75,561 @@ test("gateway config rewrites Fusion fixed base and vision models to core provid ); assert.equal(profiles[0].baseModel.fixedModel, `${providerName}/glm-5.2`); }); + +test("gateway config normalizes Fusion web search tool names for native Anthropic search triggers", () => { + const profiles = [ + { + displayName: "Kimi Search", + enabled: true, + execution: { + clientToolsPolicy: "allow", + matchWebSearch: true, + maxToolCalls: 8, + maxTurns: 6, + mode: "tool_loop", + streamMode: "optimistic" + }, + id: "kimisearch", + key: "kimisearch", + match: { exactAliases: ["kimisearch"], prefixes: [], suffixes: [] }, + materialization: { enabled: true, includeInGatewayModels: true }, + metadata: { + fusionWebSearch: { provider: "browser", toolName: "web_search_kimisearch" } + }, + tools: [{ name: "web_search_kimisearch", visibility: "internal" }] + } + ]; + + const [profile] = normalizeCoreGatewayVirtualModelProfiles(profiles, { + Providers: [], + Router: { fallback: { mode: "off", models: [], retryCount: 0 } }, + gateway: {} + }); + + assert.equal(profile.metadata.fusionWebSearch.toolName, "kimisearch_web_search"); + assert.deepEqual(profile.tools.map((tool) => tool.name), ["kimisearch_web_search"]); + assert.match(profile.instructions.append, /call the kimisearch_web_search function tool before answering/); +}); + +test("gateway resolves normalized Fusion web search tool names for Anthropic protocol bridging", () => { + const config = { + Providers: [], + Router: { fallback: { mode: "off", models: [], retryCount: 0 } }, + gateway: {}, + virtualModelProfiles: [ + { + displayName: "Kimisearch", + enabled: true, + execution: { + clientToolsPolicy: "allow", + matchWebSearch: true, + maxToolCalls: 8, + maxTurns: 6, + mode: "tool_loop", + streamMode: "optimistic" + }, + id: "fusion-2", + key: "kimisearch", + match: { exactAliases: ["kimisearch", "Fusion/kimisearch"], prefixes: [], suffixes: [] }, + materialization: { enabled: true, includeInGatewayModels: true }, + metadata: { + fusionWebSearch: { provider: "browser", toolName: "web_search_fusion_2" } + }, + tools: [{ name: "web_search_fusion_2", visibility: "internal" }] + } + ] + }; + + assert.equal(fusionWebSearchToolNameForRequest(config, "Fusion/kimisearch"), "fusion_2_web_search"); +}); + +test("gateway resolves only browser-backed Fusion web search tools for hosted protocol bridging", () => { + const config = { + Providers: [], + Router: { fallback: { mode: "off", models: [], retryCount: 0 } }, + gateway: {}, + virtualModelProfiles: [ + { + displayName: "Research", + enabled: true, + execution: { + clientToolsPolicy: "allow", + matchWebSearch: true, + maxToolCalls: 8, + maxTurns: 6, + mode: "tool_loop", + streamMode: "optimistic" + }, + id: "research", + key: "research", + match: { exactAliases: ["research", "Fusion/research"], prefixes: [], suffixes: [] }, + materialization: { enabled: true, includeInGatewayModels: true }, + metadata: { + fusionWebSearch: { provider: "brave", toolName: "research_web_search" } + }, + tools: [{ name: "research_web_search", visibility: "internal" }] + } + ] + }; + + assert.equal(fusionWebSearchToolNameForRequest(config, "Fusion/research"), undefined); + assert.equal(fusionWebSearchToolNameForRequest(config, "gpt-5"), undefined); +}); + +test("gateway config does not create fallback tools for MCP-backed Fusion tools", () => { + const profiles = [ + { + enabled: true, + tools: [ + { description: "Browser search", name: "fusion_2_web_search", visibility: "internal" }, + { description: "Vision", name: "vision_understand_glm_5_2v", visibility: "internal" }, + { description: "Missing", name: "missing_fusion_tool", visibility: "internal" } + ] + } + ]; + const servers = [ + { name: "fusion_2_web_search" }, + { env: { FUSION_TOOL_NAME: "vision_understand_glm_5_2v" }, name: "fusion-vision-glm-5.2v" } + ]; + + const definitions = fusionFallbackToolDefinitions(profiles, fusionToolNamesBackedByMcpServers(servers)); + + assert.deepEqual(definitions.map((definition) => definition.name), ["missing_fusion_tool"]); +}); + +test("gateway response injects Anthropic web search protocol blocks into JSON responses", () => { + const response = { + content: [ + { thinking: "searched", type: "thinking" }, + { text: "answer", type: "text" } + ], + id: "msg_1", + role: "assistant", + type: "message", + usage: { server_tool_use: { web_search_requests: 1 } } + }; + const transformed = transformAnthropicWebSearchProtocolResponseValue(response, [sampleSearchRecord()], "req-1"); + + assert.equal(transformed.changed, true); + assert.deepEqual( + transformed.value.content.map((block) => block.type), + ["thinking", "server_tool_use", "web_search_tool_result", "text"] + ); + assert.equal(transformed.value.content[1].name, "web_search"); + assert.equal(transformed.value.content[2].content[0].type, "web_search_result"); + assert.equal(transformed.value.content[2].content[0].snippet, "Spot gold traded near $3,340 per ounce."); +}); + +test("gateway injects prefetched web search evidence into Anthropic requests", () => { + const body = Buffer.from(JSON.stringify({ + messages: [{ role: "user", content: [{ type: "text", text: "北京天气怎么样" }] }], + model: "Fusion/kimisearch", + output_config: { effort: "high" }, + system: [{ type: "text", text: "Answer in Chinese." }], + thinking: { type: "enabled" }, + tools: [{ name: "web_search", type: "web_search_20250305" }] + })); + const record = sampleSearchRecord(); + record.results[0].content = "北京市当前天气晴,气温 28 摄氏度,空气质量良。"; + + const transformed = prepareAnthropicWebSearchProtocolRequestBody(body, [record], { queryHint: "北京天气怎么样" }); + const parsed = JSON.parse(transformed.toString("utf8")); + + assert.match(parsed.system[1].text, /Use the evidence below to answer the user's question directly/); + assert.equal(parsed.tools, undefined); + assert.equal(parsed.tool_choice, undefined); + assert.equal(parsed.output_config.effort, "low"); + assert.equal(parsed.thinking, undefined); + assert.match(parsed.system[1].text, /北京市当前天气晴/); +}); + +test("gateway synthesizes final Anthropic text when web search response has no visible answer", () => { + const response = { + content: [ + { thinking: "searched but did not answer", type: "thinking" } + ], + id: "msg_1", + role: "assistant", + stop_reason: "max_tokens", + type: "message", + usage: { output_tokens: 0 } + }; + const transformed = transformAnthropicWebSearchProtocolResponseValue( + response, + [sampleSearchRecord()], + "req-1", + "today gold price per ounce USD July 2026" + ); + + assert.equal(transformed.changed, true); + assert.deepEqual( + transformed.value.content.map((block) => block.type), + ["thinking", "server_tool_use", "web_search_tool_result", "text"] + ); + assert.match(transformed.value.content[3].text, /Spot gold traded near \$3,340 per ounce/); + assert.equal(transformed.value.usage.server_tool_use.web_search_requests, 1); + assert.equal(transformed.value.stop_reason, "end_turn"); +}); + +test("gateway response injects Anthropic web search protocol blocks into SSE responses", () => { + const sse = [ + sseEvent({ type: "message_start", message: { content: [], id: "msg_1", role: "assistant", type: "message" } }), + sseEvent({ type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } }), + sseEvent({ type: "content_block_stop", index: 0 }), + sseEvent({ type: "content_block_start", index: 1, content_block: { type: "text", text: "" } }), + sseEvent({ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "answer" } }), + sseEvent({ type: "content_block_stop", index: 1 }), + sseEvent({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { server_tool_use: { web_search_requests: 1 } } }), + sseEvent({ type: "message_stop" }) + ].join("\n\n") + "\n\n"; + + const transformed = transformAnthropicWebSearchProtocolSseText(sse, [sampleSearchRecord()], "req-1"); + + assert.match(transformed, /"type":"server_tool_use"/); + assert.match(transformed, /"type":"web_search_tool_result"/); + assert.match(transformed, /"type":"web_search_result"/); + assert.match(transformed, /"index":3,"content_block":\{"type":"text"/); + assert.match(transformed, /"server_tool_use":\{"web_search_requests":1\}/); +}); + +test("gateway synthesizes final Anthropic SSE text when model exhausts tokens in thinking", () => { + const sse = [ + sseEvent({ type: "message_start", message: { content: [], id: "msg_1", role: "assistant", type: "message" } }), + sseEvent({ type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } }), + sseEvent({ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "long reasoning" } }), + sseEvent({ type: "content_block_stop", index: 0 }), + sseEvent({ type: "message_delta", delta: { stop_reason: "max_tokens" }, usage: { output_tokens: 0 } }), + sseEvent({ type: "message_stop" }) + ].join("\n\n") + "\n\n"; + + const transformed = transformAnthropicWebSearchProtocolSseText( + sse, + [sampleSearchRecord()], + "req-1", + "today gold price per ounce USD July 2026" + ); + + assert.match(transformed, /"type":"server_tool_use"/); + assert.match(transformed, /"type":"web_search_tool_result"/); + assert.match(transformed, /"delta":\{"text":"Based on the search results, Spot gold traded near \$3,340 per ounce/); + assert.match(transformed, /"stop_reason":"end_turn"/); + assert.match(transformed, /"server_tool_use":\{"web_search_requests":1\}/); +}); + +test("gateway injects prefetched web search evidence into OpenAI chat requests", () => { + const body = Buffer.from(JSON.stringify({ + messages: [{ role: "user", content: "Perform a web search for the query: today gold price per ounce USD July 2026" }], + model: "Fusion/kimisearch", + tool_choice: "auto", + tools: [{ type: "web_search_preview" }], + web_search_options: { search_context_size: "low" } + })); + + const transformed = prepareHostedWebSearchProtocolRequestBody(body, [sampleSearchRecord()], { + protocol: "openai_chat_completions", + queryHint: "today gold price per ounce USD July 2026" + }); + const parsed = JSON.parse(transformed.toString("utf8")); + + assert.equal(parsed.tools, undefined); + assert.equal(parsed.tool_choice, undefined); + assert.equal(parsed.web_search_options, undefined); + assert.equal(parsed.messages[0].role, "system"); + assert.match(parsed.messages[0].content, /hidden in-app browser web search/); +}); + +test("gateway hosted web search rewrites preserve custom web_search-named tools", () => { + const anthropicBody = Buffer.from(JSON.stringify({ + messages: [{ role: "user", content: "search docs" }], + model: "Fusion/kimisearch", + tool_choice: { name: "web_search_docs", type: "tool" }, + tools: [ + { input_schema: { type: "object" }, name: "web_search_docs" }, + { name: "web_search", type: "web_search_20250305" } + ] + })); + const anthropicParsed = JSON.parse(prepareAnthropicWebSearchProtocolRequestBody( + anthropicBody, + [sampleSearchRecord()], + { queryHint: "search docs" } + ).toString("utf8")); + + assert.deepEqual(anthropicParsed.tools.map((tool) => tool.name), ["web_search_docs"]); + assert.equal(anthropicParsed.tool_choice.name, "web_search_docs"); + + const openAiBody = Buffer.from(JSON.stringify({ + messages: [{ role: "user", content: "search docs" }], + model: "Fusion/kimisearch", + parallel_tool_calls: true, + tool_choice: { function: { name: "web_search_docs" }, type: "function" }, + tools: [ + { function: { name: "web_search_docs", parameters: { type: "object" } }, type: "function" }, + { type: "web_search_preview" } + ], + web_search_options: { search_context_size: "low" } + })); + const openAiParsed = JSON.parse(prepareHostedWebSearchProtocolRequestBody( + openAiBody, + [sampleSearchRecord()], + { protocol: "openai_chat_completions", queryHint: "search docs" } + ).toString("utf8")); + + assert.deepEqual(openAiParsed.tools.map((tool) => tool.function.name), ["web_search_docs"]); + assert.equal(openAiParsed.tool_choice.function.name, "web_search_docs"); + assert.equal(openAiParsed.parallel_tool_calls, true); + + const geminiBody = Buffer.from(JSON.stringify({ + contents: [{ role: "user", parts: [{ text: "search docs" }] }], + tools: [ + { + functionDeclarations: [{ name: "web_search_docs", parameters: { type: "object" } }], + google_search: {} + } + ] + })); + const geminiParsed = JSON.parse(prepareHostedWebSearchProtocolRequestBody( + geminiBody, + [sampleSearchRecord()], + { protocol: "gemini_generate_content", queryHint: "search docs" } + ).toString("utf8")); + + assert.deepEqual(geminiParsed.tools[0].functionDeclarations.map((declaration) => declaration.name), ["web_search_docs"]); + assert.equal(geminiParsed.tools[0].google_search, undefined); +}); + +test("gateway synthesizes OpenAI chat final text for hosted web search fallback", () => { + const response = { + choices: [ + { finish_reason: "length", index: 0, message: { role: "assistant", content: "" } } + ], + id: "chatcmpl_1", + object: "chat.completion" + }; + const transformed = transformOpenAiChatHostedWebSearchResponseValue( + response, + [sampleSearchRecord()], + "today gold price per ounce USD July 2026" + ); + + assert.equal(transformed.changed, true); + assert.match(transformed.value.choices[0].message.content, /Spot gold traded near \$3,340 per ounce/); + assert.equal(transformed.value.choices[0].finish_reason, "stop"); +}); + +test("gateway synthesizes OpenAI chat SSE final text for hosted web search fallback", () => { + const sse = [ + openAiSseEvent({ id: "chatcmpl_1", object: "chat.completion.chunk", choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] }), + openAiSseEvent({ id: "chatcmpl_1", object: "chat.completion.chunk", choices: [{ index: 0, delta: {}, finish_reason: "length" }] }), + "data: [DONE]" + ].join("\n\n") + "\n\n"; + + const transformed = transformOpenAiChatHostedWebSearchSseText( + sse, + [sampleSearchRecord()], + "today gold price per ounce USD July 2026" + ); + + assert.match(transformed, /"delta":\{"content":"Based on the search results, Spot gold traded near \$3,340 per ounce/); + assert.match(transformed, /"finish_reason":"stop"/); +}); + +test("gateway injects prefetched web search evidence into OpenAI responses requests", () => { + const body = Buffer.from(JSON.stringify({ + input: "Perform a web search for the query: today gold price per ounce USD July 2026", + model: "Fusion/kimisearch", + tools: [{ type: "web_search_preview" }], + tool_choice: "auto" + })); + + const transformed = prepareHostedWebSearchProtocolRequestBody(body, [sampleSearchRecord()], { + protocol: "openai_responses", + queryHint: "today gold price per ounce USD July 2026" + }); + const parsed = JSON.parse(transformed.toString("utf8")); + + assert.equal(parsed.tools, undefined); + assert.equal(parsed.tool_choice, undefined); + assert.match(parsed.instructions, /hidden in-app browser web search/); +}); + +test("gateway extracts OpenAI responses web search query after Codex runtime context", () => { + const body = { + input: [ + { + type: "message", + role: "developer", + content: [{ type: "input_text", text: "\nNetwork access is restricted.\n" }] + }, + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "\n /tmp/project\n 2026-07-02\n \n" }] + }, + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "之前的回答" }] + }, + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "搜索今天黄金价格" }] + } + ], + model: "Fusion/kimisearch", + tools: [{ type: "web_search_preview" }] + }; + + assert.equal(extractHostedWebSearchQueryHint(body, "openai_responses"), "今天黄金价格"); +}); + +test("gateway synthesizes OpenAI responses output for hosted web search fallback", () => { + const response = { + id: "resp_1", + output: [], + status: "incomplete", + incomplete_details: { reason: "max_output_tokens" } + }; + const transformed = transformOpenAiResponsesHostedWebSearchResponseValue( + response, + [sampleSearchRecord()], + "req-1", + "today gold price per ounce USD July 2026" + ); + + assert.equal(transformed.changed, true); + assert.deepEqual(transformed.value.output.map((item) => item.type), ["web_search_call", "message"]); + assert.match(transformed.value.output[1].content[0].text, /Spot gold traded near \$3,340 per ounce/); + assert.equal(transformed.value.status, "completed"); +}); + +test("gateway synthesizes OpenAI responses SSE output for hosted web search fallback", () => { + const sse = [ + openAiSseEvent({ type: "response.created", response: { id: "resp_1", status: "in_progress" } }), + openAiSseEvent({ type: "response.completed", response: { id: "resp_1", status: "incomplete", incomplete_details: { reason: "max_output_tokens" } } }), + "data: [DONE]" + ].join("\n\n") + "\n\n"; + + const transformed = transformOpenAiResponsesHostedWebSearchSseText( + sse, + [sampleSearchRecord()], + "req-1", + "today gold price per ounce USD July 2026" + ); + + assert.match(transformed, /event: response.output_text.delta/); + assert.match(transformed, /"type":"response.output_text.delta"/); + assert.match(transformed, /Spot gold traded near \$3,340 per ounce/); + assert.match(transformed, /"status":"completed"/); +}); + +test("gateway normalizes OpenAI responses SSE with visible text for hosted web search", () => { + const answer = "杭州今天湿润有雨,气温约24℃到29.3℃。"; + const sse = [ + openAiSseEvent({ type: "response.created", response: { id: "resp_1", status: "in_progress", output: [] } }), + openAiSseEvent({ type: "response.output_item.added", output_index: 0, item: { id: "rs_1", type: "reasoning", status: "in_progress", content: [] } }), + openAiSseEvent({ type: "response.reasoning_text.delta", output_index: 0, content_index: 0, item_id: "rs_1", delta: "hidden reasoning" }), + openAiSseEvent({ type: "response.output_item.added", output_index: 1, item: { id: "msg_1", type: "message", role: "assistant", status: "in_progress", content: [] } }), + openAiSseEvent({ type: "response.content_part.added", output_index: 1, content_index: 0, item_id: "msg_1", part: { type: "output_text", text: "", annotations: [] } }), + openAiSseEvent({ type: "response.output_text.delta", output_index: 1, content_index: 0, item_id: "msg_1", delta: answer }), + openAiSseEvent({ type: "response.output_text.done", output_index: 1, content_index: 0, item_id: "msg_1", text: answer }), + openAiSseEvent({ type: "response.output_item.done", output_index: 0, item: { id: "rs_1", type: "reasoning", status: "completed", content: [{ type: "reasoning_text", text: "hidden reasoning" }] } }), + openAiSseEvent({ type: "response.output_item.done", output_index: 1, item: { id: "msg_1", type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: answer, annotations: [] }] } }), + openAiSseEvent({ + type: "response.completed", + response: { + id: "resp_1", + status: "completed", + output_text: answer, + output: [ + { id: "rs_1", type: "reasoning", status: "completed", content: [{ type: "reasoning_text", text: "hidden reasoning" }] }, + { id: "msg_1", type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: answer, annotations: [] }] } + ] + } + }), + "data: [DONE]" + ].join("\n\n") + "\n\n"; + + const transformed = transformOpenAiResponsesHostedWebSearchSseText( + sse, + [sampleSearchRecord()], + "req-1", + "杭州天气怎么样" + ); + + assert.match(transformed, /event: response.output_text.delta/); + assert.match(transformed, /"output_index":0/); + assert.match(transformed, /"output_text":"杭州今天湿润有雨/); + assert.doesNotMatch(transformed, /"output_index":1/); + assert.doesNotMatch(transformed, /response.reasoning_text.delta/); + assert.doesNotMatch(transformed, /"type":"reasoning"/); +}); + +test("gateway injects prefetched web search evidence into Gemini requests", () => { + const body = Buffer.from(JSON.stringify({ + contents: [{ role: "user", parts: [{ text: "Perform a web search for the query: today gold price per ounce USD July 2026" }] }], + tools: [{ google_search: {} }] + })); + + const transformed = prepareHostedWebSearchProtocolRequestBody(body, [sampleSearchRecord()], { + protocol: "gemini_generate_content", + queryHint: "today gold price per ounce USD July 2026" + }); + const parsed = JSON.parse(transformed.toString("utf8")); + + assert.equal(parsed.tools, undefined); + assert.match(parsed.systemInstruction.parts[0].text, /hidden in-app browser web search/); +}); + +test("gateway synthesizes Gemini response text for hosted web search fallback", () => { + const response = { candidates: [{ content: { parts: [], role: "model" }, finishReason: "MAX_TOKENS", index: 0 }] }; + const transformed = transformGeminiHostedWebSearchResponseValue( + response, + [sampleSearchRecord()], + "today gold price per ounce USD July 2026" + ); + + assert.equal(transformed.changed, true); + assert.match(transformed.value.candidates[0].content.parts[0].text, /Spot gold traded near \$3,340 per ounce/); + assert.equal(transformed.value.candidates[0].finishReason, "STOP"); +}); + +test("gateway synthesizes Gemini SSE text for hosted web search fallback", () => { + const sse = [ + openAiSseEvent({ candidates: [{ content: { parts: [], role: "model" }, finishReason: "MAX_TOKENS", index: 0 }] }), + "data: [DONE]" + ].join("\n\n") + "\n\n"; + + const transformed = transformGeminiHostedWebSearchSseText( + sse, + [sampleSearchRecord()], + "today gold price per ounce USD July 2026" + ); + + assert.match(transformed, /"candidates":\[\{"content":\{"parts":\[\{"text":"Based on the search results, Spot gold traded near \$3,340 per ounce/); +}); + +function sampleSearchRecord() { + return { + completedAtMs: Date.now(), + engine: "google", + query: "today gold price per ounce USD July 2026", + results: [ + { + snippet: "Spot gold traded near $3,340 per ounce.", + title: "Gold Price Today", + url: "https://example.test/gold" + } + ], + searchUrl: "https://www.google.com/search?q=gold", + toolName: "fusion_2_web_search" + }; +} + +function sseEvent(value) { + return `event: ${value.type}\ndata: ${JSON.stringify(value)}`; +} + +function openAiSseEvent(value) { + return `data: ${JSON.stringify(value)}`; +} diff --git a/tests/renderer/virtual-models.test.ts b/tests/renderer/virtual-models.test.ts index 5377160..1617e19 100644 --- a/tests/renderer/virtual-models.test.ts +++ b/tests/renderer/virtual-models.test.ts @@ -27,7 +27,7 @@ test("Fusion draft saves multiple selected tools into one profile", () => { assert.deepEqual(profile.tools.map((tool) => tool.name), [ "vision_understand_fusion_plus", - "web_search_fusion_plus", + "fusion_plus_web_search", "lookup_customer" ]); assert.equal(profile.execution.matchMultimodal, true); @@ -36,7 +36,7 @@ test("Fusion draft saves multiple selected tools into one profile", () => { assert.equal(profile.execution.clientToolsPolicy, "allow"); assert.equal(profile.execution.streamMode, "optimistic"); assert.equal(metadataString(profile.metadata, "fusionVision", "toolName"), "vision_understand_fusion_plus"); - assert.equal(metadataString(profile.metadata, "fusionWebSearch", "toolName"), "web_search_fusion_plus"); + assert.equal(metadataString(profile.metadata, "fusionWebSearch", "toolName"), "fusion_plus_web_search"); assert.equal(metadataString(profile.metadata, "fusionTool", "mcpServerName"), "customer-tools"); });