fix fusion error

This commit is contained in:
musistudio 2026-07-07 20:34:11 +08:00
parent c8cbc09b96
commit bc872a29b3
5 changed files with 514 additions and 26 deletions

View file

@ -68,6 +68,7 @@ function runtimeEntryPointsForSuite(suiteName) {
return [];
}
return [
path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"),
path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts")
];
}

View file

@ -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 = "<CCR-SUBAGENT-MODEL>";
const ccrSubagentModelCloseTag = "</CCR-SUBAGENT-MODEL>";
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);
}

View file

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

View file

@ -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 () => {

View file

@ -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 <CCR-SUBAGENT-MODEL>provider/model</CCR-SUBAGENT-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 <CCR-SUBAGENT-MODEL>Provider/model</CCR-SUBAGENT-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({