Add router support for multi-provider model handling

This commit is contained in:
musistudio
2026-07-02 20:28:25 +08:00
parent ccd2694d43
commit 01b3c3bc3b
11 changed files with 1789 additions and 119 deletions
+30
View File
@@ -55,6 +55,7 @@ export async function launchClaudeAppProfile(configDir: string, profile: Profile
const env: NodeJS.ProcessEnv = {
...process.env,
...profileEnv(profile),
...claudeCodeModelEnv(profile),
...(config ? botGatewayProfileEnv(config, profile, "app") : {}),
CLAUDE_CONFIG_DIR: settingsDir,
CLAUDE_USER_DATA_DIR: userDataDir,
@@ -314,6 +315,35 @@ function profileEnv(profile: ProfileConfig): Record<string, string> {
}, {});
}
function claudeCodeModelEnv(profile: ProfileConfig): Record<string, string> {
const env: Record<string, string> = {};
const model = normalizeClientModel(profile.model);
if (model) {
env.ANTHROPIC_MODEL = model;
env.CCR_CLAUDE_CODE_MODEL = model;
env.CODEXL_CLAUDE_CODE_MODEL = model;
}
const smallFastModel = normalizeClientModel(profile.smallFastModel);
if (smallFastModel) {
env.ANTHROPIC_SMALL_FAST_MODEL = smallFastModel;
}
return env;
}
function normalizeClientModel(value: string | undefined): string {
const trimmed = value?.trim() || "";
if (!trimmed) {
return "";
}
const commaIndex = trimmed.indexOf(",");
if (commaIndex > 0 && commaIndex < trimmed.length - 1) {
const provider = trimmed.slice(0, commaIndex).trim();
const model = trimmed.slice(commaIndex + 1).trim();
return provider && model ? `${provider}/${model}` : "";
}
return trimmed;
}
function isEnvName(value: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
}
+137 -7
View File
@@ -87,7 +87,8 @@ async function main() {
async function runClaudeCodeCliWrapper(args) {
const realCli = expandHome(nonEmptyEnv("CCR_REAL_CLAUDE_CODE_BIN") || nonEmptyEnv("CCR_CLAUDE_CODE_BIN") || nonEmptyEnv("CODEXL_CLAUDE_CODE_BIN") || "claude");
log("claude_code_wrapper_start", { realCli, args });
const realArgs = claudeCodeCliWrapperArgs(args);
log("claude_code_wrapper_start", { realCli, args, realArgs });
const captureStdout = shouldCaptureClaudeCodeCliStdout(args);
const remoteSync = createRemoteSyncClient({
args,
@@ -96,7 +97,7 @@ async function runClaudeCodeCliWrapper(args) {
title: nonEmptyEnv("CCR_REMOTE_SYNC_PROFILE_NAME") || "Claude Code"
});
const injectRemoteStdin = boolEnv("CCR_REMOTE_SYNC_INJECT_STDIN");
const child = childProcess.spawn(realCli, args, {
const child = childProcess.spawn(realCli, realArgs, {
env: {
...withoutKeys(process.env, ["CCR_CLAUDE_CODE_WRAPPER", "CCR_REAL_CLAUDE_CODE_BIN"]),
...claudeCodeUtcTimezoneEnvOverride()
@@ -145,6 +146,79 @@ async function runClaudeCodeCliWrapper(args) {
process.exitCode = code;
}
function claudeCodeCliWrapperArgs(args) {
const model = nonEmptyEnv("CCR_CLAUDE_CODE_MODEL") || nonEmptyEnv("CODEXL_CLAUDE_CODE_MODEL") || nonEmptyEnv("ANTHROPIC_MODEL");
if (!model || claudeCodeArgsHaveModel(args) || claudeCodeArgsShouldSkipModelInjection(args)) {
return args;
}
return ["--model", model, ...args];
}
function claudeCodeArgsHaveModel(args) {
for (const arg of args) {
if (arg === "--model" || arg === "-m" || arg.startsWith("--model=")) {
return true;
}
}
return false;
}
function claudeCodeArgsShouldSkipModelInjection(args) {
if (args.some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-v")) {
return true;
}
const command = firstClaudeCodePositionalArg(args);
return Boolean(command && new Set([
"config",
"doctor",
"help",
"install",
"login",
"logout",
"mcp",
"update",
"upgrade",
"version"
]).has(command.toLowerCase()));
}
function firstClaudeCodePositionalArg(args) {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--") {
return undefined;
}
if (!arg.startsWith("-")) {
return arg;
}
if (claudeCodeOptionTakesValue(arg) && !arg.includes("=")) {
index += 1;
}
}
return undefined;
}
function claudeCodeOptionTakesValue(arg) {
return new Set([
"--add-dir",
"--append-system-prompt",
"--config",
"--continue",
"--debug-to",
"--fallback-model",
"--model",
"--output-format",
"--permission-mode",
"--resume",
"--settings",
"--system-prompt",
"-c",
"-m",
"-p",
"-r"
]).has(arg);
}
function shouldCaptureClaudeCodeCliStdout(args) {
if (boolEnv("CCR_CLAUDE_CODE_CAPTURE_STDOUT") || boolEnv("CODEXL_CLAUDE_CODE_CAPTURE_STDOUT")) {
return true;
@@ -736,7 +810,14 @@ async function runClaudeCodeBotWorker(args) {
try {
const server = new ClaudeCodeAppServer(options);
server.ensureBotBridgeRegistered();
log("claude_bot_worker_start", { workspaceName: options.workspaceName, pid: process.pid, lockPath: lock.path });
log("claude_bot_worker_start", {
workspaceName: options.workspaceName,
pid: process.pid,
lockPath: lock.path,
claudeConfigDir: nonEmptyEnv("CLAUDE_CONFIG_DIR"),
claudeUserDataDir: currentClaudeAppUserDataDir(),
model: nonEmptyEnv("CCR_CLAUDE_CODE_MODEL") || nonEmptyEnv("CODEXL_CLAUDE_CODE_MODEL") || agentEnv(codexRuntimeAgent(), "MODEL") || ""
});
await waitForTerminationSignal();
await botBridge().stop();
log("claude_bot_worker_stop", { pid: process.pid });
@@ -1295,13 +1376,23 @@ class ClaudeCodeAppServer {
return null;
}
if (!entry.claudeSessionId && !entry.claudeAppSessionId) return null;
const appSession = readClaudeAppLocalAgentSession(entry.claudeAppSessionFile || "");
if (!botSessionEntryMatchesCurrentProfile(entry, appSession)) {
log("bot_gateway_session_scope_skip", {
conversationKeyPrefix: key.slice(0, 80),
threadId: entry.threadId || "",
claudeConfigDir: entry.claudeConfigDir || appSession.claudeConfigDir || "",
claudeAppSessionFile: entry.claudeAppSessionFile || "",
expectedUserDataDir: currentClaudeAppUserDataDir()
});
return null;
}
const thread = this.createThread({
cwd: entry.cwd || process.cwd(),
model: entry.model || undefined,
workspaceKind: "local",
claudeConfigDir: entry.claudeConfigDir || null
});
const appSession = readClaudeAppLocalAgentSession(entry.claudeAppSessionFile || "");
this.replaceThreadId(thread, entry.threadId || thread.id);
thread.sessionId = entry.sessionId || thread.id;
thread.claudeSessionId = entry.claudeSessionId || appSession.cliSessionId || null;
@@ -1537,7 +1628,15 @@ class ClaudeCodeAppServer {
if (!thread || !turn) return;
const started = Date.now();
const command = claudeCommand(work);
log("claude_turn_spawn", { threadId: work.threadId, turnId: work.turnId, command: command.command, args: command.args });
log("claude_turn_spawn", {
threadId: work.threadId,
turnId: work.turnId,
command: command.command,
args: command.args,
cwd: work.cwd,
claudeConfigDir: work.claudeConfigDir || "",
expectedUserDataDir: currentClaudeAppUserDataDir()
});
const child = childProcess.spawn(command.command, command.args, {
cwd: work.cwd,
env: command.env,
@@ -3414,9 +3513,9 @@ function latestClaudeAppLocalAgentSession() {
}
function claudeAppLocalAgentSessions() {
const baseDir = nonEmptyEnv("CCR_CLAUDE_APP_USER_DATA_PATH") || nonEmptyEnv("CLAUDE_USER_DATA_DIR");
const baseDir = currentClaudeAppUserDataDir();
if (!baseDir) return [];
const root = path.join(expandHome(baseDir), "local-agent-mode-sessions");
const root = path.join(baseDir, "local-agent-mode-sessions");
const files = listClaudeAppSessionFiles(root, 6);
const sessions = [];
for (const file of files) {
@@ -3449,6 +3548,37 @@ function claudeAppLocalAgentSessions() {
return sessions;
}
function currentClaudeAppUserDataDir() {
return expandHome(nonEmptyEnv("CCR_CLAUDE_APP_USER_DATA_PATH") || nonEmptyEnv("CLAUDE_USER_DATA_DIR") || "");
}
function botSessionEntryMatchesCurrentProfile(entry, appSession) {
const expectedUserDataDir = currentClaudeAppUserDataDir();
if (!expectedUserDataDir) return true;
const candidates = [
entry && entry.claudeConfigDir,
entry && entry.claudeAppSessionFile,
entry && entry.cwd,
appSession && appSession.claudeConfigDir
];
return candidates.some((candidate) => pathIsInside(candidate, expectedUserDataDir));
}
function pathIsInside(candidate, parentDir) {
const child = expandHome(String(candidate || ""));
const parent = expandHome(String(parentDir || ""));
if (!child || !parent) return false;
const childPath = normalizeComparablePath(path.resolve(child));
const parentPath = normalizeComparablePath(path.resolve(parent));
if (childPath === parentPath) return true;
const relative = path.relative(parentPath, childPath);
return Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative);
}
function normalizeComparablePath(value) {
return process.platform === "win32" ? value.toLowerCase() : value;
}
function resolveClaudeAppLocalAgentSession(selector) {
const query = String(selector || "").trim();
if (!query) return null;
+30
View File
@@ -182,6 +182,7 @@ function buildClaudeCodeLaunchPlan(
env: {
CLAUDE_CONFIG_DIR: path.dirname(settingsFile),
CCR_PROFILE_SURFACE: surface,
...claudeCodeModelEnv(profile),
...claudeCodeUtcTimezoneEnvOverride()
},
profile,
@@ -219,6 +220,35 @@ function normalizeProfileSurface(value: ProfileConfig["surface"]): "auto" | "cli
return value === "cli" || value === "app" ? value : "auto";
}
function claudeCodeModelEnv(profile: ProfileConfig): Record<string, string> {
const env: Record<string, string> = {};
const model = normalizeClientModel(profile.model);
if (model) {
env.ANTHROPIC_MODEL = model;
env.CCR_CLAUDE_CODE_MODEL = model;
env.CODEXL_CLAUDE_CODE_MODEL = model;
}
const smallFastModel = normalizeClientModel(profile.smallFastModel);
if (smallFastModel) {
env.ANTHROPIC_SMALL_FAST_MODEL = smallFastModel;
}
return env;
}
function normalizeClientModel(value: string | undefined): string {
const trimmed = value?.trim() || "";
if (!trimmed) {
return "";
}
const commaIndex = trimmed.indexOf(",");
if (commaIndex > 0 && commaIndex < trimmed.length - 1) {
const provider = trimmed.slice(0, commaIndex).trim();
const model = trimmed.slice(commaIndex + 1).trim();
return provider && model ? `${provider}/${model}` : "";
}
return trimmed;
}
function isGeneratedProfileScope(value: ProfileConfig["scope"]): boolean {
return value === "ccr" || value === "custom";
}
+86 -1
View File
@@ -60,6 +60,7 @@ export async function applyProfileConfig(config: AppConfig): Promise<ProfileAppl
: applyCodexProfile(config, profile, token, appliedAt)
);
}
result.clients.push(...restoreInactiveGlobalProfileConfigs(profiles));
return result;
}
@@ -91,9 +92,14 @@ function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token
delete env.ANTHROPIC_AUTH_TOKEN;
delete env.ANTHROPIC_API_KEY;
if (profile.model.trim()) {
env.ANTHROPIC_MODEL = normalizeClientModel(profile.model);
const model = normalizeClientModel(profile.model);
env.ANTHROPIC_MODEL = model;
env.CCR_CLAUDE_CODE_MODEL = model;
env.CODEXL_CLAUDE_CODE_MODEL = model;
} else {
delete env.ANTHROPIC_MODEL;
delete env.CCR_CLAUDE_CODE_MODEL;
delete env.CODEXL_CLAUDE_CODE_MODEL;
}
if (profile.smallFastModel?.trim()) {
env.ANTHROPIC_SMALL_FAST_MODEL = normalizeClientModel(profile.smallFastModel);
@@ -533,6 +539,7 @@ function claudeCodeWrapperShellScript(config: AppConfig, profile: ProfileConfig,
const realClaude = profile.env?.CCR_CLAUDE_CODE_BIN?.trim() || "claude";
const surface = normalizeProfileSurface(profile.surface);
const remoteEndpoint = `${gatewayEndpoint(config)}/__ccr/remote`;
const settingsDir = path.dirname(resolveClaudeCodeSettingsFile(profile));
const envExports = Object.entries(profileEnv(profile))
.filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN")
.map(([key, value]) => `export ${key}=${shellQuote(value)}`);
@@ -540,6 +547,7 @@ function claudeCodeWrapperShellScript(config: AppConfig, profile: ProfileConfig,
return [
"#!/bin/sh",
...envExports,
...shellEnvExports(claudeCodeRuntimeEnv(config, profile, settingsDir)),
...shellEnvExports(claudeCodeUtcTimezoneEnvOverride()),
`: "\${CCR_PROFILE_SURFACE:=${surface}}"`,
"export CCR_PROFILE_SURFACE",
@@ -562,6 +570,7 @@ function claudeCodeWrapperCmdScript(config: AppConfig, profile: ProfileConfig, r
const realClaude = profile.env?.CCR_CLAUDE_CODE_BIN?.trim() || "claude";
const surface = normalizeProfileSurface(profile.surface);
const remoteEndpoint = `${gatewayEndpoint(config)}/__ccr/remote`;
const settingsDir = path.dirname(resolveClaudeCodeSettingsFile(profile));
const envExports = Object.entries(profileEnv(profile))
.filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN")
.map(([key, value]) => cmdSetLine(key, value));
@@ -569,6 +578,7 @@ function claudeCodeWrapperCmdScript(config: AppConfig, profile: ProfileConfig, r
return [
"@echo off",
...envExports,
...cmdEnvExports(claudeCodeRuntimeEnv(config, profile, settingsDir)),
...cmdEnvExports(claudeCodeUtcTimezoneEnvOverride()),
`if not defined CCR_PROFILE_SURFACE ${cmdSetLine("CCR_PROFILE_SURFACE", surface)}`,
...botEnvExports,
@@ -617,6 +627,27 @@ function writeCodexCliMiddleware(
};
}
function claudeCodeRuntimeEnv(config: AppConfig, profile: ProfileConfig, settingsDir: string): Record<string, string> {
const endpoint = gatewayEndpoint(config);
const env: Record<string, string> = {
ANTHROPIC_API_BASE_URL: endpoint,
ANTHROPIC_BASE_URL: endpoint,
CLAUDE_AGENT_API_BASE_URL: endpoint,
CLAUDE_CONFIG_DIR: settingsDir
};
const model = normalizeClientModel(profile.model);
if (model) {
env.ANTHROPIC_MODEL = model;
env.CCR_CLAUDE_CODE_MODEL = model;
env.CODEXL_CLAUDE_CODE_MODEL = model;
}
const smallFastModel = normalizeClientModel(profile.smallFastModel);
if (smallFastModel) {
env.ANTHROPIC_SMALL_FAST_MODEL = smallFastModel;
}
return env;
}
function codexMiddlewareRuntimeFilename(): string {
return "ccr-codex-cli-middleware.js";
}
@@ -1032,6 +1063,60 @@ function disabledProfileStatus(profile: ProfileConfig): ProfileClientApplyStatus
);
}
export function restoreInactiveGlobalProfileConfigs(profiles: ProfileConfig[]): ProfileClientApplyStatus[] {
const statuses: ProfileClientApplyStatus[] = [];
if (!profiles.some((profile) => profile.agent === "claude-code" && profile.enabled && isGlobalProfile(profile))) {
for (const file of uniqueResolvedPaths([
"~/.claude/settings.json",
...profiles
.filter((profile) => profile.agent === "claude-code")
.map((profile) => profile.settingsFile || "")
.filter(Boolean)
])) {
const restoreResult = restoreGlobalConfigFile(file, {
isManagedContent: isManagedClaudeCodeSettingsContent,
mode: privateFileMode
});
if (restoreResult.changed || restoreResult.missingBackup) {
statuses.push(inactiveGlobalCleanupStatus("claude-code", file, restoreResult));
}
}
}
return statuses;
}
function inactiveGlobalCleanupStatus(
client: ProfileClientKind,
file: string,
restoreResult: RestoreFileResult
): ProfileClientApplyStatus {
return {
backupFile: restoreResult.backupFile,
client,
enabled: false,
message: restoreResult.missingBackup
? `No active global ${codexCompatibleClientName(client)} profile is configured, but the global config is managed by CCR and no original backup was found.`
: `${codexCompatibleClientName(client)} global config was restored because no active global profile is configured.`,
ok: !restoreResult.missingBackup,
path: resolveUserPath(file)
};
}
function uniqueResolvedPaths(paths: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const item of paths) {
const resolved = resolveUserPath(item);
const key = process.platform === "win32" ? resolved.toLowerCase() : resolved;
if (seen.has(key)) {
continue;
}
seen.add(key);
result.push(resolved);
}
return result;
}
function restoreDisabledZcodeProfile(profile: ProfileConfig, configFile: string): ProfileClientApplyStatus {
const disabledMessage = "ZCode profile is disabled.";
if (!isGlobalProfile(profile)) {
@@ -2,7 +2,7 @@ import { createRequire } from "node:module";
import { EventEmitter } from "node:events";
import os from "node:os";
import path from "node:path";
import type { AppConfig, RouterBuiltInAgentRuleId, RouterConfig, RouterFallbackConfig, RouterRule, RouterRuleCondition, RouterRuleRewrite } from "../../shared/app";
import { availableGatewayModelIds, type AppConfig, type RouterBuiltInAgentRuleId, type RouterConfig, type RouterFallbackConfig, type RouterRule, type RouterRuleCondition, type RouterRuleRewrite } from "../../shared/app";
import { CONFIGDIR } from "../../main/constants";
type HeaderValue = string | string[] | undefined;
@@ -1055,16 +1055,22 @@ export function normalizeRouteSelector(value: string | undefined): string | unde
}
function isKnownInlineRoute(model: string | undefined, config: AppConfig): boolean {
if (!model) {
const normalizedModel = normalizeRouteSelector(model);
if (!normalizedModel) {
return false;
}
const separator = model.indexOf("/");
const normalizedModelLower = normalizedModel.toLowerCase();
if (availableGatewayModelIds(config).some((id) => id.toLowerCase() === normalizedModelLower)) {
return true;
}
const separator = normalizedModel.indexOf("/");
if (separator <= 0) {
return false;
}
const providerName = model.slice(0, separator).trim().toLowerCase();
const providerName = normalizedModel.slice(0, separator).trim().toLowerCase();
return config.Providers.some((provider) => provider.name.trim().toLowerCase() === providerName);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { codexCliMiddlewareRuntimeScript } from "../../src/main/codex-cli-middleware-runtime.ts";
test("generated Codex CLI middleware runtime is valid JavaScript", () => {
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-check-"));
const file = path.join(dir, "ccr-codex-cli-middleware.js");
writeFileSync(file, codexCliMiddlewareRuntimeScript());
execFileSync(process.execPath, ["--check", file], { stdio: "pipe" });
});
test("Claude Code wrapper injects the scoped profile model into real CLI args", { skip: process.platform === "win32" }, () => {
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-wrapper-"));
const runtimeFile = writeRuntimeScript(dir);
const { fakeCli, outputFile } = writeFakeClaudeCli(dir);
execFileSync(process.execPath, [runtimeFile, "-p", "hi"], {
env: {
...process.env,
ANTHROPIC_MODEL: "Fusion/kimisearch",
CCR_CLAUDE_CODE_MODEL: "Fusion/kimisearch",
CCR_CLAUDE_CODE_WRAPPER: "1",
CCR_FAKE_CLAUDE_OUT: outputFile,
CCR_REAL_CLAUDE_CODE_BIN: fakeCli,
CCR_REMOTE_SYNC_ENABLED: "0"
},
stdio: "pipe"
});
const observed = JSON.parse(readFileSync(outputFile, "utf8"));
assert.deepEqual(observed.argv, ["--model", "Fusion/kimisearch", "-p", "hi"]);
assert.equal(observed.env.ANTHROPIC_MODEL, "Fusion/kimisearch");
assert.equal(observed.env.CCR_CLAUDE_CODE_MODEL, "Fusion/kimisearch");
});
test("Claude Code wrapper does not duplicate an explicit model argument", { skip: process.platform === "win32" }, () => {
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-wrapper-"));
const runtimeFile = writeRuntimeScript(dir);
const { fakeCli, outputFile } = writeFakeClaudeCli(dir);
execFileSync(process.execPath, [runtimeFile, "--model", "Provider/manual", "-p", "hi"], {
env: {
...process.env,
ANTHROPIC_MODEL: "Fusion/kimisearch",
CCR_CLAUDE_CODE_MODEL: "Fusion/kimisearch",
CCR_CLAUDE_CODE_WRAPPER: "1",
CCR_FAKE_CLAUDE_OUT: outputFile,
CCR_REAL_CLAUDE_CODE_BIN: fakeCli,
CCR_REMOTE_SYNC_ENABLED: "0"
},
stdio: "pipe"
});
const observed = JSON.parse(readFileSync(outputFile, "utf8"));
assert.deepEqual(observed.argv, ["--model", "Provider/manual", "-p", "hi"]);
});
function writeRuntimeScript(dir) {
const file = path.join(dir, "ccr-codex-cli-middleware.js");
writeFileSync(file, codexCliMiddlewareRuntimeScript());
chmodSync(file, 0o700);
return file;
}
function writeFakeClaudeCli(dir) {
const fakeCli = path.join(dir, "fake-claude");
const outputFile = path.join(dir, "fake-claude-output.json");
writeFileSync(fakeCli, [
"#!/usr/bin/env node",
"const fs = require('node:fs');",
"fs.writeFileSync(process.env.CCR_FAKE_CLAUDE_OUT, JSON.stringify({",
" argv: process.argv.slice(2),",
" env: {",
" ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL || '',",
" CCR_CLAUDE_CODE_MODEL: process.env.CCR_CLAUDE_CODE_MODEL || ''",
" }",
"}));",
""
].join("\n"));
chmodSync(fakeCli, 0o700);
return { fakeCli, outputFile };
}
+374 -2
View File
@@ -1,11 +1,14 @@
import assert from "node:assert/strict";
import { PassThrough, Readable } from "node:stream";
import test from "node:test";
import {
fusionFallbackToolDefinitions,
fusionWebSearchToolNameForRequest,
fusionToolNamesBackedByMcpServers,
extractHostedWebSearchQueryHint,
hostedWebSearchProtocolResponseStream,
prepareAnthropicWebSearchProtocolRequestBody,
prepareClaudeCodeWebSearchContinuationRequestBody,
prepareHostedWebSearchProtocolRequestBody,
transformAnthropicWebSearchProtocolResponseValue,
transformAnthropicWebSearchProtocolSseText,
@@ -143,6 +146,76 @@ test("gateway resolves normalized Fusion web search tool names for Anthropic pro
assert.equal(fusionWebSearchToolNameForRequest(config, "Fusion/kimisearch"), "fusion_2_web_search");
});
test("gateway does not route hosted web search through an unrelated Fusion search profile", () => {
const config = {
Providers: [
{
baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4",
credentials: [{ apiKey: "test-key", id: "test-1" }],
models: ["glm-5.2", "glm-5v-turbo"],
name: "Zhipu AI (China) - Coding Plan",
type: "openai_chat_completions"
},
{
baseUrl: "https://api.moonshot.cn/anthropic",
models: ["kimi-for-coding"],
name: "Kimi Code - Coding Plan",
type: "openai_chat_completions"
}
],
Router: { fallback: { mode: "off", models: [], retryCount: 0 } },
gateway: {},
virtualModelProfiles: [
{
baseModel: { fixedModel: "Zhipu AI (China) - Coding Plan/glm-5.2", mode: "fixed" },
displayName: "GLM 5 2V",
enabled: true,
execution: {
clientToolsPolicy: "deny",
matchMultimodal: true,
matchWebSearch: false,
maxToolCalls: 8,
maxTurns: 6,
mode: "tool_loop",
streamMode: "buffered"
},
id: "glm-5.2v",
key: "glm-5.2v",
match: { exactAliases: ["GLM-5.2V"], prefixes: [], suffixes: [] },
materialization: { enabled: true, includeInGatewayModels: true },
metadata: {
fusionVision: { modelSelector: "Zhipu AI (China) - Coding Plan/glm-5v-turbo", toolName: "vision_understand_glm_5_2v" }
},
tools: [{ name: "vision_understand_glm_5_2v", visibility: "internal" }]
},
{
baseModel: { fixedModel: "Kimi Code - Coding Plan/kimi-for-coding", mode: "fixed" },
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"], 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/GLM-5.2V"), undefined);
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: [],
@@ -205,6 +278,7 @@ test("gateway response injects Anthropic web search protocol blocks into JSON re
],
id: "msg_1",
role: "assistant",
stop_reason: "tool_use",
type: "message",
usage: { server_tool_use: { web_search_requests: 1 } }
};
@@ -217,7 +291,30 @@ test("gateway response injects Anthropic web search protocol blocks into JSON re
);
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.");
assert.equal(transformed.value.content[2].content[0].snippet, "Search snippet: Spot gold traded near $3,340 per ounce.");
assert.equal(transformed.value.stop_reason, "end_turn");
});
test("gateway preserves Anthropic tool_use stop reason when client tools remain", () => {
const response = {
content: [
{ thinking: "searched", type: "thinking" },
{ id: "toolu_1", input: { command: "pwd" }, name: "Bash", type: "tool_use" }
],
id: "msg_1",
role: "assistant",
stop_reason: "tool_use",
type: "message",
usage: {}
};
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", "tool_use"]
);
assert.equal(transformed.value.stop_reason, "tool_use");
});
test("gateway injects prefetched web search evidence into Anthropic requests", () => {
@@ -243,6 +340,107 @@ test("gateway injects prefetched web search evidence into Anthropic requests", (
assert.match(parsed.system[1].text, /北京市当前天气晴/);
});
test("gateway forces Claude Code WebSearch continuations to answer without tools", () => {
const body = Buffer.from(JSON.stringify({
messages: [
{ role: "user", content: [{ type: "text", text: "搜索 shadcn官方有哪些New Components" }] },
{
role: "assistant",
content: [
{
id: "tool_search_1",
input: { query: "shadcn UI new components 2025 2026 official" },
name: "WebSearch",
type: "tool_use"
}
]
},
{
role: "user",
content: [
{
content: "Web search results for query: \"shadcn UI new components 2025 2026 official\"\n\nLinks: [{\"title\":\"Changelog - Shadcn UI\",\"url\":\"https://ui.shadcn.com/docs/changelog\"}]\n\nThe official shadcn/ui changelog lists June 2026 chat interface components. REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.",
tool_use_id: "tool_search_1",
type: "tool_result"
}
]
}
],
model: "Fusion/kimisearch",
output_config: { effort: "high" },
system: [{ type: "text", text: "You are Claude Code." }],
thinking: { type: "enabled" },
tools: [
{ name: "WebFetch", input_schema: { type: "object" } },
{ name: "WebSearch", input_schema: { type: "object" } },
{ name: "Bash", input_schema: { type: "object" } }
]
}));
const transformed = prepareClaudeCodeWebSearchContinuationRequestBody(
body,
[sampleSearchRecord()],
{ queryHint: "shadcn UI new components 2025 2026 official" }
);
assert.ok(transformed);
const parsed = JSON.parse(transformed.toString("utf8"));
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.at(-1).text, /Do not call any tool/);
assert.match(parsed.system.at(-1).text, /In-app browser extracted evidence/);
assert.match(parsed.system.at(-1).text, /Previous WebSearch tool result/);
assert.match(parsed.system.at(-1).text, /Spot gold traded near \$3,340 per ounce/);
});
test("gateway ignores stale Claude Code WebSearch results on later turns", () => {
const body = Buffer.from(JSON.stringify({
messages: [
{ role: "user", content: [{ type: "text", text: "搜索 shadcn官方有哪些New Components" }] },
{
role: "assistant",
content: [
{
id: "tool_search_1",
input: { query: "shadcn UI new components 2025 2026 official" },
name: "WebSearch",
type: "tool_use"
}
]
},
{
role: "user",
content: [
{
content: "Web search results for query: \"shadcn UI new components 2025 2026 official\"\n\nLinks: []",
tool_use_id: "tool_search_1",
type: "tool_result"
}
]
},
{ role: "assistant", content: [{ type: "text", text: "The changelog has new chat components." }] },
{ role: "user", content: [{ type: "text", text: "Now inspect package.json with Bash." }] }
],
model: "Fusion/kimisearch",
system: [{ type: "text", text: "You are Claude Code." }],
tools: [
{ name: "WebFetch", input_schema: { type: "object" } },
{ name: "WebSearch", input_schema: { type: "object" } },
{ name: "Bash", input_schema: { type: "object" } }
]
}));
const transformed = prepareClaudeCodeWebSearchContinuationRequestBody(
body,
[sampleSearchRecord()],
{ queryHint: undefined }
);
assert.equal(transformed, undefined);
});
test("gateway synthesizes final Anthropic text when web search response has no visible answer", () => {
const response = {
content: [
@@ -271,6 +469,32 @@ test("gateway synthesizes final Anthropic text when web search response has no v
assert.equal(transformed.value.stop_reason, "end_turn");
});
test("gateway synthesizes useful component changelog answers from extracted pages", () => {
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,
[sampleShadcnSearchRecord()],
"req-1",
"shadcn ui new components 2025 2026 official registry"
);
assert.equal(transformed.changed, true);
assert.match(transformed.value.content[2].content[0].snippet, /Extracted page content:/);
assert.match(transformed.value.content[3].text, /June 2026 - Components for Chat Interfaces/);
assert.match(transformed.value.content[3].text, /Message Scroller/);
assert.match(transformed.value.content[3].text, /Attachment/);
assert.doesNotMatch(transformed.value.content[3].text, /Morning, shadcn/);
});
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" } }),
@@ -279,7 +503,7 @@ test("gateway response injects Anthropic web search protocol blocks into SSE res
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_delta", delta: { stop_reason: "tool_use" }, usage: { server_tool_use: { web_search_requests: 1 } } }),
sseEvent({ type: "message_stop" })
].join("\n\n") + "\n\n";
@@ -290,6 +514,102 @@ test("gateway response injects Anthropic web search protocol blocks into SSE res
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\}/);
assert.match(transformed, /"stop_reason":"end_turn"/);
});
test("gateway preserves Anthropic SSE tool_use stop reason when client tools remain", () => {
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: { id: "toolu_1", input: { command: "pwd" }, name: "Bash", type: "tool_use" } }),
sseEvent({ type: "content_block_stop", index: 1 }),
sseEvent({ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: {} }),
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":"tool_use"/);
assert.match(transformed, /"stop_reason":"tool_use"/);
assert.doesNotMatch(transformed, /"stop_reason":"end_turn"/);
});
test("gateway hosted web search response stream transforms Anthropic SSE responses", async () => {
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: "reasoning" } }),
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: "tool_use" }, usage: {} }),
sseEvent({ type: "message_stop" })
].join("\n\n") + "\n\n";
const stream = hostedWebSearchProtocolResponseStream(
Readable.from([Buffer.from(sse, "utf8")]),
new Headers({ "content-type": "text/event-stream; charset=utf-8" }),
{
protocol: "anthropic_messages",
queryHint: "today gold price per ounce USD July 2026",
records: [sampleSearchRecord()],
requestId: "req-1",
sinceMs: Date.now() - 1000,
toolName: "fusion_2_web_search"
},
{
recentBrowserWebSearchResults: () => [],
stopBrowserWebSearchMcpServers: async () => {}
}
);
const transformed = await readStreamText(stream);
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, /"server_tool_use":\{"web_search_requests":1\}/);
assert.match(transformed, /"stop_reason":"end_turn"/);
});
test("gateway hosted web search Anthropic SSE stream emits before upstream ends", async () => {
const input = new PassThrough();
const stream = hostedWebSearchProtocolResponseStream(
input,
new Headers({ "content-type": "text/event-stream; charset=utf-8" }),
{
protocol: "anthropic_messages",
queryHint: "today gold price per ounce USD July 2026",
records: [sampleSearchRecord()],
requestId: "req-1",
sinceMs: Date.now() - 1000,
toolName: "fusion_2_web_search"
},
{
recentBrowserWebSearchResults: () => [],
stopBrowserWebSearchMcpServers: async () => {}
}
);
const injectedData = waitForStreamDataMatching(stream, /"type":"server_tool_use"/, 500);
input.write([
sseEvent({ type: "message_start", message: { content: [], id: "msg_1", role: "assistant", type: "message" } }),
sseEvent({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })
].join("\n\n") + "\n\n");
const chunk = await injectedData;
assert.ok(chunk);
assert.match(chunk.toString("utf8"), /"type":"server_tool_use"/);
input.end([
sseEvent({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "answer" } }),
sseEvent({ type: "content_block_stop", index: 0 }),
sseEvent({ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: {} }),
sseEvent({ type: "message_stop" })
].join("\n\n") + "\n\n");
const rest = await readStreamText(stream);
assert.match(`${chunk.toString("utf8")}${rest}`, /"stop_reason":"end_turn"/);
});
test("gateway synthesizes final Anthropic SSE text when model exhausts tokens in thinking", () => {
@@ -626,6 +946,28 @@ function sampleSearchRecord() {
};
}
function sampleShadcnSearchRecord() {
return {
completedAtMs: Date.now(),
engine: "google",
query: "shadcn ui new components 2025 2026 official registry",
results: [
{
content: "Sections Introduction Components Attachment Avatar Badge Bubble Button Button Group Empty Field Input Input Group Input OTP Item Marker Message Message Scroller Native Select Changelog RSS Latest updates and announcements. June 2026 - Components for Chat Interfaces New Chat How can I help you today? Morning, shadcn! What are we working on today?",
title: "Changelog - Shadcn UI",
url: "https://ui.shadcn.com/docs/changelog"
},
{
snippet: "YouTube · Web Dev Simplified 26.3K+ views · 11 months ago",
title: "How I Built My Own Shadcn Library",
url: "https://www.youtube.com/watch?v=example"
}
],
searchUrl: "https://www.google.com/search?q=shadcn",
toolName: "fusion_2_web_search"
};
}
function sseEvent(value) {
return `event: ${value.type}\ndata: ${JSON.stringify(value)}`;
}
@@ -633,3 +975,33 @@ function sseEvent(value) {
function openAiSseEvent(value) {
return `data: ${JSON.stringify(value)}`;
}
async function readStreamText(stream) {
const chunks = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString("utf8");
}
function waitForStreamDataMatching(stream, pattern, timeoutMs) {
return new Promise((resolve) => {
const timer = setTimeout(() => {
cleanup();
resolve(null);
}, timeoutMs);
const onData = (chunk) => {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
if (!pattern.test(buffer.toString("utf8"))) {
return;
}
cleanup();
resolve(buffer);
};
const cleanup = () => {
clearTimeout(timer);
stream.off("data", onData);
};
stream.on("data", onData);
});
}
+5
View File
@@ -20,6 +20,7 @@ const claudeProfile = {
model: "provider,model",
name: "Claude Main",
scope: "ccr",
smallFastModel: "provider,small",
surface: "auto"
};
@@ -82,6 +83,10 @@ test("buildProfileLaunchPlan creates CCR-managed launcher paths", () => {
assert.equal(path.basename(claudePlan.command), process.platform === "win32" ? "ccr-claude-code-wrapper-claude-main.cmd" : "ccr-claude-code-wrapper-claude-main");
assert.equal(claudePlan.env.CCR_PROFILE_SURFACE, "cli");
assert.match(claudePlan.env.CLAUDE_CONFIG_DIR, /claude$/);
assert.equal(claudePlan.env.ANTHROPIC_MODEL, "provider/model");
assert.equal(claudePlan.env.CCR_CLAUDE_CODE_MODEL, "provider/model");
assert.equal(claudePlan.env.CODEXL_CLAUDE_CODE_MODEL, "provider/model");
assert.equal(claudePlan.env.ANTHROPIC_SMALL_FAST_MODEL, "provider/small");
assert.throws(() => buildProfileLaunchPlan(configDir, claudeProfile, "app"), /Claude App opening/);
});
+108
View File
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { restoreInactiveGlobalProfileConfigs } from "../../src/main/profile-service.ts";
test("profile service restores managed global Claude settings when only CCR-scoped Claude profiles are active", () => {
const previousHome = process.env.HOME;
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-profile-home-"));
process.env.HOME = home;
try {
const settingsFile = path.join(home, ".claude", "settings.json");
mkdirSync(path.dirname(settingsFile), { recursive: true });
const originalSettings = {
env: {
USER_VALUE: "kept"
},
theme: "dark"
};
writeFileSync(`${settingsFile}.ccr-backup-2026-01-01T00-00-00-000Z`, `${JSON.stringify(originalSettings, null, 2)}\n`);
writeFileSync(settingsFile, `${JSON.stringify({
apiKeyHelper: "/tmp/ccr-claude-code-api-key-claude-code",
env: {
ANTHROPIC_API_BASE_URL: "http://127.0.0.1:3456",
ANTHROPIC_BASE_URL: "http://127.0.0.1:3456",
ANTHROPIC_MODEL: "Fusion/GLM-5.2V",
CLAUDE_AGENT_API_BASE_URL: "http://127.0.0.1:3456"
}
}, null, 2)}\n`);
const statuses = restoreInactiveGlobalProfileConfigs([
{
agent: "claude-code",
enabled: true,
env: { CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1" },
id: "claude-code-2",
model: "Fusion/kimisearch",
name: "Claude Code",
scope: "ccr",
settingsFile: "~/.claude/settings.json",
smallFastModel: "",
surface: "auto"
}
]);
const restored = JSON.parse(readFileSync(settingsFile, "utf8"));
assert.equal(statuses.length, 1);
assert.equal(statuses[0].client, "claude-code");
assert.equal(statuses[0].ok, true);
assert.equal(restored.env.USER_VALUE, "kept");
assert.equal(restored.env.ANTHROPIC_MODEL, undefined);
assert.equal(restored.env.CCR_CLAUDE_CODE_MODEL, undefined);
assert.equal(restored.env.CODEXL_CLAUDE_CODE_MODEL, undefined);
} finally {
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
rmSync(home, { force: true, recursive: true });
}
});
test("profile service keeps managed global Claude settings when a global Claude profile is active", () => {
const previousHome = process.env.HOME;
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-profile-home-"));
process.env.HOME = home;
try {
const settingsFile = path.join(home, ".claude", "settings.json");
mkdirSync(path.dirname(settingsFile), { recursive: true });
writeFileSync(settingsFile, `${JSON.stringify({
apiKeyHelper: "/tmp/ccr-claude-code-api-key-claude-code",
env: {
ANTHROPIC_API_BASE_URL: "http://127.0.0.1:3456",
ANTHROPIC_BASE_URL: "http://127.0.0.1:3456",
ANTHROPIC_MODEL: "Fusion/GLM-5.2V",
CLAUDE_AGENT_API_BASE_URL: "http://127.0.0.1:3456"
}
}, null, 2)}\n`);
const statuses = restoreInactiveGlobalProfileConfigs([
{
agent: "claude-code",
enabled: true,
env: {},
id: "claude-code",
model: "Fusion/GLM-5.2V",
name: "Claude Code",
scope: "global",
settingsFile: "~/.claude/settings.json",
smallFastModel: "",
surface: "auto"
}
]);
const current = JSON.parse(readFileSync(settingsFile, "utf8"));
assert.equal(statuses.length, 0);
assert.equal(current.env.ANTHROPIC_MODEL, "Fusion/GLM-5.2V");
} finally {
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
rmSync(home, { force: true, recursive: true });
}
});
+33 -1
View File
@@ -35,7 +35,8 @@ function createRouterPlugin(options = {}) {
scope: "global"
}
]
}
},
virtualModelProfiles: options.virtualModelProfiles ?? []
});
}
@@ -58,6 +59,37 @@ test("built-in Claude Code route matches user-agent case-insensitively", async (
assert.equal(result.decision.reason, "builtin:claude-code");
});
test("built-in Claude Code route preserves explicit virtual gateway models", async () => {
const plugin = createRouterPlugin({
profileModel: "Provider/claude-sonnet",
virtualModelProfiles: [
{
displayName: "Kimisearch",
enabled: true,
id: "fusion-search",
key: "kimisearch",
match: { exactAliases: ["kimisearch"], prefixes: [], suffixes: [] },
materialization: { enabled: true, includeInGatewayModels: true }
}
]
});
const result = await plugin.routeRequest({
body: {
messages: [],
model: "Fusion/kimisearch"
},
headers: {
"user-agent": "claude-code/1.0"
},
method: "POST",
url: "/v1/messages"
});
assert.equal(result.body.model, "Fusion/kimisearch");
assert.equal(result.decision.model, "Fusion/kimisearch");
assert.equal(result.decision.reason, "inline-model");
});
test("built-in Codex route stays inactive when profile model is unset", async () => {
const plugin = createRouterPlugin({
agent: "codex"