Refactor router and add model fallback handling

This commit is contained in:
musistudio 2026-07-02 18:23:58 +08:00
parent 32db838b0b
commit 109b83bd80
15 changed files with 4368 additions and 55 deletions

View file

@ -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")

View file

@ -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>

File diff suppressed because it is too large Load diff

View file

@ -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);

View file

@ -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,

View file

@ -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.": "新模型不能为空。",

View file

@ -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"],

View file

@ -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

View 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);
}

View file

@ -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" }]

View file

@ -675,6 +675,7 @@ export type VirtualModelFusionVisionConfig = {
};
export type VirtualModelFusionWebSearchProvider =
| "browser"
| "brave"
| "bing"
| "google_cse"

View file

@ -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");

View file

@ -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)}`;
}

View file

@ -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");
});