From bc872a29b3e1d63ba8e9c382579085970715d975 Mon Sep 17 00:00:00 2001 From: musistudio Date: Tue, 7 Jul 2026 20:34:11 +0800 Subject: [PATCH] fix fusion error --- build/test.mjs | 1 + .../src/gateway/claude-code-router-plugin.ts | 7 +- tests/main/fusion-vision-mcp.test.mjs | 188 ++++++++++++++++ tests/main/gateway-virtual-models.test.mjs | 136 ++++++++++-- tests/main/router-builtins.test.mjs | 208 +++++++++++++++++- 5 files changed, 514 insertions(+), 26 deletions(-) create mode 100644 tests/main/fusion-vision-mcp.test.mjs diff --git a/build/test.mjs b/build/test.mjs index 9e5d67e..be7b4ad 100644 --- a/build/test.mjs +++ b/build/test.mjs @@ -68,6 +68,7 @@ function runtimeEntryPointsForSuite(suiteName) { return []; } return [ + path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"), path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts") ]; } diff --git a/packages/core/src/gateway/claude-code-router-plugin.ts b/packages/core/src/gateway/claude-code-router-plugin.ts index c39b3f9..579ea5d 100644 --- a/packages/core/src/gateway/claude-code-router-plugin.ts +++ b/packages/core/src/gateway/claude-code-router-plugin.ts @@ -246,7 +246,7 @@ function resolveBuiltInClaudeCodeSubagentRouteDecision( return undefined; } const target = normalizeRouteSelector(request.builtInSubagentModel); - if (!target) { + if (!target || isSubagentModelPlaceholder(target) || !isKnownInlineRoute(target, config)) { return undefined; } return { @@ -322,6 +322,7 @@ function builtInAgentUserAgentNeedle(agent: RouterBuiltInAgentRuleId): string { const ccrSubagentModelOpenTag = ""; const ccrSubagentModelCloseTag = ""; const ccrSubagentModelTagExample = `${ccrSubagentModelOpenTag}Provider/model${ccrSubagentModelCloseTag}`; +const ccrSubagentModelPlaceholder = "provider/model"; const claudeCodeBillingSystemHeaderPrefix = "x-anthropic-billing-header"; const ccrSubagentToolModelInstruction = `CCR subagent routing is enabled. When calling this tool, the prompt parameter MUST start with ` + @@ -1200,6 +1201,10 @@ function isKnownInlineRoute(model: string | undefined, config: AppConfig): boole return config.Providers.some((provider) => provider.name.trim().toLowerCase() === providerName); } +function isSubagentModelPlaceholder(model: string): boolean { + return model.trim().toLowerCase() === ccrSubagentModelPlaceholder; +} + function calculateTokenCount(messages: unknown, system: unknown, tools: unknown): number { return countMessageTokens(messages) + countSystemTokens(system) + countToolTokens(tools); } diff --git a/tests/main/fusion-vision-mcp.test.mjs b/tests/main/fusion-vision-mcp.test.mjs new file mode 100644 index 0000000..9e7a0e1 --- /dev/null +++ b/tests/main/fusion-vision-mcp.test.mjs @@ -0,0 +1,188 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import http from "node:http"; +import path from "node:path"; +import test from "node:test"; + +test("Fusion vision MCP sends the injected core gateway API key", async (t) => { + const seen = { + authorization: "", + body: undefined, + path: "" + }; + const server = http.createServer(async (request, response) => { + seen.authorization = request.headers.authorization ?? ""; + seen.path = request.url ?? ""; + const body = await readRequestBody(request); + seen.body = JSON.parse(body); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ + choices: [ + { + message: { + content: "vision ok" + } + } + ] + })); + }); + + try { + await listen(server); + } catch (error) { + if (isLocalListenUnavailable(error)) { + t.skip(`Local HTTP listen is unavailable: ${formatError(error)}`); + return; + } + throw error; + } + t.after(() => server.close()); + + const address = server.address(); + assert.ok(address && typeof address === "object"); + const child = spawn(process.execPath, [path.join(process.cwd(), "dist", "tests", "main", "fusion-vision-mcp.js")], { + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: "1", + FUSION_BUILTIN_TOOL_KIND: "vision", + FUSION_TOOL_NAME: "vision_understand_glm_5_2v", + VISION_GATEWAY_API_KEY: "core-token", + VISION_GATEWAY_BASE_URL: `http://127.0.0.1:${address.port}/v1`, + VISION_MODEL: "provider-zhipu-ai-china---coding-plan-d63a2c4b21::openai_chat_completions::cred:test-1/glm-5v-turbo" + }, + stdio: ["pipe", "pipe", "pipe"] + }); + t.after(() => { + if (!child.killed) { + child.kill(); + } + }); + + const response = await sendJsonRpc(child, { + id: 1, + jsonrpc: "2.0", + method: "tools/call", + params: { + arguments: { + imageBase64: "iVBORw0KGgo=", + mimeType: "image/png", + prompt: "Describe this image." + }, + name: "vision_understand_glm_5_2v" + } + }); + + assert.equal(response.error, undefined); + assert.equal(response.result?.isError, undefined); + assert.equal(response.result?.content?.[0]?.text, "vision ok"); + assert.equal(seen.authorization, "Bearer core-token"); + assert.equal(seen.path, "/v1/chat/completions"); + assert.equal(seen.body?.model, "provider-zhipu-ai-china---coding-plan-d63a2c4b21::openai_chat_completions::cred:test-1/glm-5v-turbo"); + assert.equal(seen.body?.messages?.[0]?.content?.[1]?.type, "image_url"); +}); + +function listen(server) { + return new Promise((resolve, reject) => { + const onError = (error) => { + cleanup(); + reject(error); + }; + const onListening = () => { + cleanup(); + resolve(); + }; + const cleanup = () => { + server.off("error", onError); + server.off("listening", onListening); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(0, "127.0.0.1"); + }); +} + +function readRequestBody(request) { + return new Promise((resolve, reject) => { + const chunks = []; + request.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + request.on("error", reject); + request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + }); +} + +function sendJsonRpc(child, payload) { + return new Promise((resolve, reject) => { + let stdout = Buffer.alloc(0); + let stderr = ""; + const cleanup = () => { + clearTimeout(timer); + child.stdout.off("data", onStdout); + child.stderr.off("data", onStderr); + child.off("exit", onExit); + child.off("error", onError); + }; + const finish = (value) => { + cleanup(); + resolve(value); + }; + const fail = (error) => { + cleanup(); + reject(error); + }; + const onStdout = (chunk) => { + stdout = Buffer.concat([stdout, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); + const parsed = readJsonRpcFrame(stdout); + if (!parsed) { + return; + } + finish(parsed.value); + }; + const onStderr = (chunk) => { + stderr += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); + }; + const onExit = (code, signal) => { + fail(new Error(`fusion-vision MCP exited before response: code=${code ?? ""} signal=${signal ?? ""} stderr=${stderr}`)); + }; + const onError = (error) => fail(error); + const timer = setTimeout(() => fail(new Error(`Timed out waiting for fusion-vision MCP response. stderr=${stderr}`)), 5000); + + child.stdout.on("data", onStdout); + child.stderr.on("data", onStderr); + child.once("exit", onExit); + child.once("error", onError); + + const message = Buffer.from(JSON.stringify(payload), "utf8"); + child.stdin.write(`Content-Length: ${message.byteLength}\r\n\r\n`); + child.stdin.write(message); + }); +} + +function readJsonRpcFrame(buffer) { + const headerEnd = buffer.indexOf("\r\n\r\n"); + if (headerEnd < 0) { + return undefined; + } + const header = buffer.subarray(0, headerEnd).toString("utf8"); + const match = header.match(/content-length:\s*(\d+)/i); + if (!match) { + throw new Error(`Missing Content-Length in MCP response: ${header}`); + } + const length = Number(match[1]); + const bodyStart = headerEnd + 4; + const bodyEnd = bodyStart + length; + if (buffer.length < bodyEnd) { + return undefined; + } + return { + rest: buffer.subarray(bodyEnd), + value: JSON.parse(buffer.subarray(bodyStart, bodyEnd).toString("utf8")) + }; +} + +function isLocalListenUnavailable(error) { + return error && typeof error === "object" && (error.code === "EPERM" || error.code === "EACCES"); +} + +function formatError(error) { + return error instanceof Error ? error.message : String(error); +} diff --git a/tests/main/gateway-virtual-models.test.mjs b/tests/main/gateway-virtual-models.test.mjs index 9269686..df4f146 100644 --- a/tests/main/gateway-virtual-models.test.mjs +++ b/tests/main/gateway-virtual-models.test.mjs @@ -81,28 +81,136 @@ 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 injects core auth token into Fusion vision MCP gateway runtime", async () => { - const profiles = [ - { - enabled: true, - id: "glm-vision", - key: "glm-vision", - metadata: { - fusionVision: { - modelSelector: "VisionProvider/glm-5v", - toolName: "vision_understand_glm" - } +test("issue 1480 Fusion vision config injects core auth token into MCP gateway runtime", async () => { + const providerName = "Zhipu AI (China) - Coding Plan"; + const config = { + CUSTOM_ROUTER_PATH: "", + Providers: [ + { + api_base_url: "https://nebulacoder.example/v1/chat/completions", + credentials: [{ apiKey: "nebulacoder-key", id: "nebulacoder-main" }], + models: ["nebulacoder-v8.0", "nebulacoder-cot-v8.0"], + name: "NebulaCoder" + }, + { + api_base_url: "https://coclaw.example/v1/chat/completions", + credentials: [{ apiKey: "coclaw-key", id: "coclaw-main" }], + models: ["Qwen3-235B-A22B"], + name: "CoClaw" + }, + { + api_base_url: "https://opencode.ai/zen/go/v1/chat/completions", + capabilities: [ + { baseUrl: "https://opencode.ai/zen/go/v1/chat/completions", type: "openai_chat_completions" } + ], + credentials: [{ apiKey: "opencode-key", id: "opencode-main" }], + models: ["kimi-k2.6", "deepseek-v4-pro", "deepseek-v4-flash"], + name: "OpenCode" + }, + { + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + capabilities: [ + { baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", type: "openai_chat_completions" }, + { baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", type: "anthropic_messages" } + ], + credentials: [{ apiKey: "zhipu-key", id: "test-1" }], + models: ["glm-5.2", "glm-4.5-air", "glm-5v-turbo"], + name: providerName, + type: "openai_chat_completions" } - } - ]; + ], + Router: { + fallback: { mode: "retry", models: [], retryCount: 1 }, + rules: [ + { + condition: { left: "request.url", operator: "contains", right: "/v1" }, + enabled: true, + id: "rule-default", + name: "Default route", + rewrites: [ + { key: "request.header.x-target-provider", operation: "set", value: "OpenCode" }, + { key: "request.body.model", operation: "set", value: "deepseek-v4-flash" } + ], + type: "condition" + }, + { + condition: { left: "request.header.anthropic-background", operator: "==", right: "true" }, + enabled: true, + id: "rule-background", + name: "Background tasks", + rewrites: [ + { key: "request.header.x-target-provider", operation: "set", value: "CoClaw" }, + { key: "request.body.model", operation: "set", value: "Qwen3-235B-A22B" } + ], + type: "condition" + }, + { + condition: { left: "request.header.anthropic-thinking", operator: "==", right: "true" }, + enabled: true, + id: "rule-thinking", + name: "Thinking/reasoning tasks", + rewrites: [ + { key: "request.header.x-target-provider", operation: "set", value: "OpenCode" }, + { key: "request.body.model", operation: "set", value: "deepseek-v4-pro" } + ], + type: "condition" + } + ] + }, + gateway: { coreHost: "127.0.0.1", corePort: 3457 }, + profile: { + enabled: true, + profiles: [ + { + agent: "claude-code", + enabled: true, + id: "claude-code-main", + model: "kimi-k2.6", + name: "Claude Code", + scope: "global" + } + ] + }, + virtualModelProfiles: [ + { + baseModel: { fixedModel: `${providerName}/glm-5.2`, mode: "fixed" }, + displayName: "GLM 5 2V", + enabled: true, + execution: { + clientToolsPolicy: "allow", + matchMultimodal: true, + maxToolCalls: 8, + maxTurns: 6, + mode: "tool_loop", + streamMode: "buffered" + }, + id: "glm-5.2v", + key: "glm-5.2v", + match: { exactAliases: ["GLM-5.2V", "Fusion/GLM-5.2V"], prefixes: [], suffixes: [] }, + materialization: { enabled: true, includeInGatewayModels: true }, + metadata: { + fusionVision: { + modelSelector: `${providerName}/glm-5v-turbo`, + toolName: "vision_understand_glm_5_2v" + } + }, + tools: [{ name: "vision_understand_glm_5_2v", visibility: "internal" }] + } + ] + }; + const profiles = normalizeCoreGatewayVirtualModelProfiles(config.virtualModelProfiles, config); const artifacts = await fusionBuiltinToolArtifactsForTest(profiles, "http://127.0.0.1:3457", "core-token"); - const server = artifacts.mcpServers.find((item) => item.name === "fusion-vision-glm-vision"); + const server = artifacts.mcpServers.find((item) => item.name === "fusion-vision-glm-5.2v"); assert.ok(server); assert.equal(server.env.VISION_GATEWAY_BASE_URL, "http://127.0.0.1:3457/v1"); assert.equal(server.env.VISION_GATEWAY_API_KEY, "core-token"); assert.equal(server.env.VISION_API_KEY, undefined); + assert.match( + server.env.VISION_MODEL, + /^provider-zhipu-ai-china---coding-plan-[a-f0-9]{10}::openai_chat_completions::cred:test-1\/glm-5v-turbo$/ + ); }); test("gateway config does not inject core auth token into external Fusion vision MCP runtime", async () => { diff --git a/tests/main/router-builtins.test.mjs b/tests/main/router-builtins.test.mjs index 486705c..1928fc5 100644 --- a/tests/main/router-builtins.test.mjs +++ b/tests/main/router-builtins.test.mjs @@ -42,13 +42,14 @@ function createRouterPlugin(options = {}) { }); } -function createIssue1480RouterConfig() { +function createIssue1480UserConfig() { return { + APIKEY: "gateway-key", CUSTOM_ROUTER_PATH: "", Providers: [ { api_base_url: "https://nebulacoder.example/v1/chat/completions", - credentials: [{ apiKey: "nebulacoder-key", id: "nebulacoder-main" }], + api_key: "nebulacoder-key", modelDescriptions: { "nebulacoder-v8.0": "Code reading model.", "nebulacoder-cot-v8.0": "Code reading reasoning model." @@ -58,7 +59,7 @@ function createIssue1480RouterConfig() { }, { api_base_url: "https://coclaw.example/v1/chat/completions", - credentials: [{ apiKey: "coclaw-key", id: "coclaw-main" }], + api_key: "coclaw-key", modelDescriptions: { "Qwen3-235B-A22B": "Background task model." }, @@ -67,24 +68,43 @@ function createIssue1480RouterConfig() { }, { api_base_url: "https://opencode.ai/zen/go/v1/chat/completions", + api_key: "opencode-key", capabilities: [ { baseUrl: "https://opencode.ai/zen/go/v1/chat/completions", type: "openai_chat_completions" } ], - credentials: [{ apiKey: "opencode-key", id: "opencode-main" }], modelDescriptions: { + "glm-5.1": "Previous generation flagship model.", + "glm-5.2": "Flagship model.", "deepseek-v4-flash": "Default route model.", "deepseek-v4-pro": "Thinking route model.", - "kimi-k2.6": "Claude Code profile model." + "kimi-k2.6": "Claude Code profile model.", + "kimi-k2.7": "Fast model.", + "mimo-v2.5": "Lightweight model.", + "mimo-v2.5-pro": "Enhanced lightweight model." }, - models: ["kimi-k2.6", "deepseek-v4-pro", "deepseek-v4-flash"], + models: [ + "glm-5.2", + "glm-5.1", + "kimi-k2.7", + "kimi-k2.6", + "deepseek-v4-pro", + "deepseek-v4-flash", + "mimo-v2.5", + "mimo-v2.5-pro" + ], name: "OpenCode" + }, + { + api_base_url: "https://reasoning.example/v1/chat/completions", + api_key: "reasoning-key", + modelDescriptions: { + "sap-glm-2-2": "Reasoning model." + }, + models: ["sap-glm-2-2"], + name: "Reasoning" } ], Router: { - builtInRules: { - "claude-code": { enabled: true }, - codex: { enabled: true } - }, fallback: { mode: "retry", models: [], retryCount: 1 }, rules: [ { @@ -134,6 +154,13 @@ function createIssue1480RouterConfig() { } ] }, + gateway: { + coreHost: "0.0.0.0", + corePort: 3457, + enabled: true, + host: "0.0.0.0", + port: 3456 + }, profile: { enabled: true, profiles: [ @@ -147,7 +174,68 @@ function createIssue1480RouterConfig() { } ] }, - virtualModelProfiles: [] + providerPlugins: [ + { + enabled: true, + key: "opencode", + opencode: { enabled: true }, + provider: "openai" + } + ], + virtualModelProfiles: [ + { + description: "Automatically search the web using Tavily", + displayName: "Fusion Tavily", + enabled: true, + execution: { + clientToolsPolicy: "allow", + matchWebSearch: true, + maxToolCalls: 10, + maxTurns: 20, + mode: "tool_loop", + streamMode: "optimistic" + }, + id: "fusion-tavily-web-search", + key: "fusion-tavily", + match: { + exactAliases: [], + prefixes: [], + suffixes: [] + }, + materialization: { + enabled: true, + includeInGatewayModels: true + }, + metadata: { + fusionVision: { + apiKey: "opencode-key", + baseUrl: "https://opencode.ai/zen/go/v1", + model: "kimi-k2.6", + toolName: "vision_understand" + }, + fusionWebSearch: { + env: { + TAVILY_API_KEY: "tavily-key" + }, + provider: "tavily", + resultCount: 5, + toolName: "web_search" + } + }, + tools: [] + } + ] + }; +} + +function createIssue1480RouterConfig() { + const config = createIssue1480UserConfig(); + return { + ...config, + Providers: config.Providers.map((provider) => ({ + ...provider, + credentials: [{ apiKey: provider.api_key, id: `${provider.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-main` }] + })) }; } @@ -367,6 +455,83 @@ test("router rules can add headers after the built-in Claude Code profile route" assert.equal(result.decision.reason, "rule:target-provider"); }); +test("issue 1480 raw user config no longer reproduces the Claude Code profile routing failure", async () => { + const config = createIssue1480UserConfig(); + const plugin = new ClaudeCodeRouterPlugin(config); + const headers = { + "user-agent": "claude-cli/2.1.187 (external, cli)" + }; + const result = await plugin.routeRequest({ + body: { + messages: [], + model: "claude-sonnet-4-6", + tools: [] + }, + headers, + method: "POST", + url: "/v1/messages?beta=true" + }); + + assert.equal(headers["x-target-provider"], "OpenCode"); + assert.equal(result.body.model, "deepseek-v4-flash"); + assert.equal(result.decision.model, "deepseek-v4-flash"); + assert.equal(result.decision.reason, "rule:rule-default"); +}); + +test("issue 1480 raw user config reproduces the old failure precondition when Router rules are skipped", async () => { + const config = createIssue1480UserConfig(); + const plugin = new ClaudeCodeRouterPlugin({ + ...config, + Router: { + ...config.Router, + rules: [] + } + }); + const headers = { + "user-agent": "claude-cli/2.1.187 (external, cli)" + }; + const result = await plugin.routeRequest({ + body: { + messages: [], + model: "claude-sonnet-4-6", + tools: [] + }, + headers, + method: "POST", + url: "/v1/messages?beta=true" + }); + + assert.equal(headers["x-target-provider"], undefined); + assert.equal(result.body.model, "kimi-k2.6"); + assert.equal(result.decision.model, "kimi-k2.6"); + assert.equal(result.decision.reason, "builtin:claude-code"); +}); + +test("issue 1480 raw user config ignores an unreplaced Provider/model subagent placeholder", async () => { + const config = createIssue1480UserConfig(); + const plugin = new ClaudeCodeRouterPlugin(config); + const headers = { + "user-agent": "claude-cli/2.1.187 (external, cli)" + }; + const result = await plugin.routeRequest({ + body: { + messages: [], + model: "claude-sonnet-4-6", + system: "Use provider/model for this subagent.", + tools: [] + }, + headers, + method: "POST", + url: "/v1/messages?beta=true" + }); + + assert.equal(headers["x-target-provider"], "OpenCode"); + assert.equal(result.body.model, "deepseek-v4-flash"); + assert.equal(result.body.system, "Use for this subagent."); + assert.equal(result.decision.model, "deepseek-v4-flash"); + assert.equal(result.decision.reason, "rule:rule-default"); +}); + test("issue 1480 config routes Claude Code profile traffic through the user default OpenCode rule", async () => { const config = createIssue1480RouterConfig(); const plugin = new ClaudeCodeRouterPlugin(config); @@ -854,6 +1019,27 @@ test("built-in Claude Code subagent route uses model tag from system", async () assert.equal(result.decision.reason, "builtin:claude-code-subagent"); }); +test("built-in Claude Code subagent route ignores the Provider/model placeholder", async () => { + const plugin = createRouterPlugin({ profileModel: "Provider/claude-sonnet" }); + const result = await plugin.routeRequest({ + body: { + messages: [], + model: "claude-default", + system: "Use Provider/model for this subagent." + }, + headers: { + "user-agent": "Claude Code" + }, + method: "POST", + url: "/v1/messages" + }); + + assert.equal(result.body.model, "Provider/claude-sonnet"); + assert.equal(result.body.system, "Use for this subagent."); + assert.equal(result.decision.model, "Provider/claude-sonnet"); + assert.equal(result.decision.reason, "builtin:claude-code"); +}); + test("built-in Claude Code route removes the first billing system block before subagent tag extraction", async () => { const plugin = createRouterPlugin({ profileModel: "Provider/claude-sonnet" }); const result = await plugin.routeRequest({