mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-09 17:18:24 +00:00
Refactor router and add model fallback handling
This commit is contained in:
parent
32db838b0b
commit
109b83bd80
15 changed files with 4368 additions and 55 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | undefined {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
|
|
|
|||
1265
src/main/electron-web-search-mcp.ts
Normal file
1265
src/main/electron-web-search-mcp.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
|
|
@ -452,17 +474,112 @@ function WebSearchToolConfigurationPanel({
|
|||
<Field label={t("Search provider")}>
|
||||
<SelectControl onChange={updateProvider} options={providerOptions} value={draft.webSearchProvider} />
|
||||
</Field>
|
||||
<Field label={t("Provider configuration")}>
|
||||
<KeyValueRowsControl
|
||||
addLabel={t("Add variable")}
|
||||
{draft.webSearchProvider === "browser" ? (
|
||||
<BrowserWebSearchConfigurationPanel
|
||||
onChange={(webSearchEnvRows) => onChange({ webSearchEnvRows })}
|
||||
rows={draft.webSearchEnvRows}
|
||||
/>
|
||||
) : (
|
||||
<Field label={t("Provider configuration")}>
|
||||
<KeyValueRowsControl
|
||||
addLabel={t("Add variable")}
|
||||
onChange={(webSearchEnvRows) => onChange({ webSearchEnvRows })}
|
||||
rows={draft.webSearchEnvRows}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<Field label={t("Search engine")}>
|
||||
<SelectControl
|
||||
onChange={(value) => updateKnownValue(browserWebSearchEngineEnvKey, value)}
|
||||
options={browserWebSearchEngineOptions}
|
||||
value={browserWebSearchEngineOptions.some((option) => option.value === engine) ? engine : "bing"}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("Safe search")}>
|
||||
<SelectControl
|
||||
onChange={(value) => updateKnownValue(browserWebSearchSafeSearchEnvKey, value)}
|
||||
options={translateOptions(browserWebSearchSafeSearchOptions, t)}
|
||||
value={browserWebSearchSafeSearchOptions.some((option) => option.value === safeSearch) ? safeSearch : "default"}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("Language")}>
|
||||
<Input
|
||||
onChange={(event) => updateKnownValue(browserWebSearchLanguageEnvKey, event.target.value)}
|
||||
placeholder="en, zh-CN"
|
||||
value={language}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("Country")}>
|
||||
<Input
|
||||
onChange={(event) => updateKnownValue(browserWebSearchCountryEnvKey, event.target.value)}
|
||||
placeholder="US, CN"
|
||||
value={country}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={t("Advanced variables")}>
|
||||
<KeyValueRowsControl
|
||||
addLabel={t("Add variable")}
|
||||
onChange={updateExtraRows}
|
||||
rows={extraRows}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -380,7 +380,17 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
|||
"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<ResolvedLanguage, AppCopy> = {
|
|||
"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<ResolvedLanguage, AppCopy> = {
|
|||
"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<ResolvedLanguage, AppCopy> = {
|
|||
"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.": "新模型不能为空。",
|
||||
|
|
|
|||
|
|
@ -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<VirtualModelFusionWebSearchProvider, string[]> = {
|
||||
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"],
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
146
src/server/mcp/browser-web-search-proxy-mcp.ts
Normal file
146
src/server/mcp/browser-web-search-proxy-mcp.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<string, unknown> {
|
||||
return {
|
||||
error: {
|
||||
code,
|
||||
message
|
||||
},
|
||||
id,
|
||||
jsonrpc: "2.0"
|
||||
};
|
||||
}
|
||||
|
||||
function writeFrame(payload: Record<string, unknown>): 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<string, unknown> {
|
||||
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);
|
||||
}
|
||||
|
|
@ -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<string, unknown>): Promise<string>
|
|||
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<Pick<SearchResul
|
|||
}
|
||||
|
||||
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 textResult(text: string): ToolCallResult {
|
||||
return {
|
||||
content: [{ text, type: "text" }]
|
||||
|
|
|
|||
|
|
@ -675,6 +675,7 @@ export type VirtualModelFusionVisionConfig = {
|
|||
};
|
||||
|
||||
export type VirtualModelFusionWebSearchProvider =
|
||||
| "browser"
|
||||
| "brave"
|
||||
| "bing"
|
||||
| "google_cse"
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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: "<permissions instructions>\nNetwork access is restricted.\n</permissions instructions>" }]
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "<environment_context>\n <cwd>/tmp/project</cwd>\n <current_date>2026-07-02</current_date>\n <filesystem></filesystem>\n</environment_context>" }]
|
||||
},
|
||||
{
|
||||
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)}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue