Merge branch 'dev/3.1' into dev/docs

# Conflicts:
#	docs/src/content/docs/en/configuration/profiles.md
#	docs/src/content/docs/zh/configuration/profiles.md
This commit is contained in:
musistudio
2026-08-06 14:16:12 +08:00
80 changed files with 6257 additions and 591 deletions
+4 -4
View File
@@ -162,28 +162,28 @@ CCR supports OpenAI Chat / Responses, Anthropic Messages, Gemini Generate Conten
<table width="100%">
<tr>
<td align="center" width="330">
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.16/Claude-Code-Router_3.0.16.exe">
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.19/Claude-Code-Router_3.0.19.exe">
<img src="/docs/public/platform-icons/windows.png" width="44" height="44" alt="Windows logo" />
<br />
<strong>Windows</strong>
</a>
</td>
<td align="center" width="330">
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.16/Claude-Code-Router_3.0.16.AppImage">
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.19/Claude-Code-Router_3.0.19.AppImage">
<img src="/docs/public/platform-icons/linux.png" width="44" height="44" alt="Linux logo" />
<br />
<strong>Linux</strong>
</a>
</td>
<td align="center" width="330">
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.16/Claude-Code-Router_3.0.16-mac-Apple-Silicon-arm64.dmg">
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.19/Claude-Code-Router_3.0.19-mac-Apple-Silicon-arm64.dmg">
<img src="/docs/public/platform-icons/macos.png" width="44" height="44" alt="macOS logo" />
<br />
<strong>macOS (Apple Silicon)</strong>
</a>
</td>
<td align="center" width="330">
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.16/Claude-Code-Router_3.0.16-mac-Intel-x64.dmg">
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.19/Claude-Code-Router_3.0.19-mac-Intel-x64.dmg">
<img src="/docs/public/platform-icons/macos.png" width="44" height="44" alt="macOS logo" />
<br />
<strong>macOS (Intel)</strong>
+4 -4
View File
@@ -162,28 +162,28 @@ CCR 支持 OpenAI Chat / Responses、Anthropic Messages、Gemini Generate Conten
<table width="100%">
<tr>
<td align="center" width="330">
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.16/Claude-Code-Router_3.0.16.exe">
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.19/Claude-Code-Router_3.0.19.exe">
<img src="/docs/public/platform-icons/windows.png" width="44" height="44" alt="Windows 图标" />
<br />
<strong>Windows</strong>
</a>
</td>
<td align="center" width="330">
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.16/Claude-Code-Router_3.0.16.AppImage">
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.19/Claude-Code-Router_3.0.19.AppImage">
<img src="/docs/public/platform-icons/linux.png" width="44" height="44" alt="Linux 图标" />
<br />
<strong>Linux</strong>
</a>
</td>
<td align="center" width="330">
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.16/Claude-Code-Router_3.0.16-mac-Apple-Silicon-arm64.dmg">
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.19/Claude-Code-Router_3.0.19-mac-Apple-Silicon-arm64.dmg">
<img src="/docs/public/platform-icons/macos.png" width="44" height="44" alt="macOS 图标" />
<br />
<strong>macOS (Apple Silicon)</strong>
</a>
</td>
<td align="center" width="330">
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.16/Claude-Code-Router_3.0.16-mac-Intel-x64.dmg">
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.19/Claude-Code-Router_3.0.19-mac-Intel-x64.dmg">
<img src="/docs/public/platform-icons/macos.png" width="44" height="44" alt="macOS 图标" />
<br />
<strong>macOS (Intel)</strong>
+2 -1
View File
@@ -1,4 +1,4 @@
import { buildBrowserRenderer, buildMain, buildRenderer, buildStyles, buildTrayRenderer, buildWebClientBridge, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyBundledClaudeRuntimePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml, syncUiRendererToRuntimeDists } from "./esbuild.config.mjs";
import { buildBrowserRenderer, buildMain, buildRenderer, buildRequestLogBodyWorker, buildStyles, buildTrayRenderer, buildWebClientBridge, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyBundledClaudeRuntimePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml, syncUiRendererToRuntimeDists } from "./esbuild.config.mjs";
const mode = process.argv.includes("--dev") ? "development" : "production";
@@ -14,6 +14,7 @@ await Promise.all([
buildMain({ mode }),
buildBrowserRenderer({ mode }),
buildRenderer({ mode }),
buildRequestLogBodyWorker({ mode }),
buildTrayRenderer({ mode }),
buildWebClientBridge({ mode }),
buildStyles({ minify: mode === "production" })
+15 -1
View File
@@ -17,6 +17,7 @@ import {
copyModelCatalog,
copyRendererHtml,
copyTrayRendererHtml,
createRequestLogBodyWorkerBuildOptions,
createBrowserRendererBuildOptions,
createCliBuildOptions,
createMainBuildOptions,
@@ -51,6 +52,7 @@ let queuedStyleBuildReason = null;
const ready = {
browser: false,
cli: false,
logWorker: false,
main: false,
renderer: false,
tray: false,
@@ -66,6 +68,7 @@ const coreSharedSourceRoot = path.join(coreSourceRoot, "shared");
const styleWatchRoots = [rendererRoot, coreSharedSourceRoot].filter((watchRoot) => existsSync(watchRoot));
const activeReadyNames = new Set([
...(enabled.ui ? ["browser", "renderer", "tray", "webBridge"] : []),
...(enabled.ui ? ["logWorker"] : []),
...(enabled.cli ? ["cli"] : []),
...(enabled.electron ? ["main"] : [])
]);
@@ -295,7 +298,7 @@ function pollSourceWatchTargets() {
}
function markReady(name, reason = `${name} esbuild completed`) {
if (name === "browser" || name === "cli" || name === "main" || name === "renderer" || name === "tray" || name === "webBridge") {
if (name === "browser" || name === "cli" || name === "logWorker" || name === "main" || name === "renderer" || name === "tray" || name === "webBridge") {
ready[name] = true;
}
logDev(`build ready: ${reason}; ${readyState()}`);
@@ -509,6 +512,17 @@ if (enabled.ui) {
})
]
})
),
await esbuild.context(
createRequestLogBodyWorkerBuildOptions({
mode: "development",
plugins: [
watchPlugin("logWorker", (name) => {
syncUiRendererToRuntimeDists();
markReady(name);
})
]
})
)
);
}
+30
View File
@@ -66,7 +66,10 @@ 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");
export const requestLogBodyWorkerOutput = path.join(rendererAssetsDir, "log-body.worker.js");
export const requestLogBodyWorkerInput = path.join(rendererRoot, "pages", "home", "shared", "log-body.worker.ts");
export const electronUndiciProxyAgentInput = path.join(coreSourceRoot, "proxy", "undici-proxy-agent.ts");
export const localAgentAuthProviderHookInput = path.join(coreSourceRoot, "gateway", "core-runtime", "local-agent-auth-provider-hook.ts");
export const upstreamHeaderSanitizerInput = path.join(coreSourceRoot, "gateway", "core-runtime", "upstream-header-sanitizer.ts");
const lightweightMcpBundleNames = ["browser-web-search-proxy-mcp.js", "fusion-vision-mcp.js", "fusion-tool-fallback-mcp.js", "media-tools-proxy-mcp.js"];
const lightweightMcpBundleMaxBytes = 128 * 1024;
@@ -234,6 +237,7 @@ export function createMainBuildOptions({ mode = "production", plugins = [] } = {
path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts"),
path.join(coreSourceRoot, "observability", "request-log-worker.ts"),
path.join(coreSourceRoot, "routing", "route-script-worker.ts"),
localAgentAuthProviderHookInput,
upstreamHeaderSanitizerInput,
electronUndiciProxyAgentInput,
path.join(electronSourceRoot, "main", "preload.ts")
@@ -266,6 +270,7 @@ export function createCliBuildOptions({ mode = "production", plugins = [] } = {}
path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts"),
path.join(coreSourceRoot, "observability", "request-log-worker.ts"),
path.join(coreSourceRoot, "routing", "route-script-worker.ts"),
localAgentAuthProviderHookInput,
upstreamHeaderSanitizerInput
],
external: nodeExternals.filter((moduleName) => moduleName !== "electron"),
@@ -295,6 +300,7 @@ export function createCoreServerBuildOptions({ mode = "production", plugins = []
path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts"),
path.join(coreSourceRoot, "observability", "request-log-worker.ts"),
path.join(coreSourceRoot, "routing", "route-script-worker.ts"),
localAgentAuthProviderHookInput,
upstreamHeaderSanitizerInput
],
external: nodeExternals.filter((moduleName) => moduleName !== "electron"),
@@ -375,6 +381,26 @@ export function createWebClientBridgeBuildOptions({ mode = "production", plugins
};
}
export function createRequestLogBodyWorkerBuildOptions({ mode = "production", plugins = [] } = {}) {
return {
absWorkingDir: projectRoot,
bundle: true,
define: {
"process.env.NODE_ENV": JSON.stringify(mode)
},
entryPoints: [requestLogBodyWorkerInput],
format: "esm",
legalComments: "none",
logLevel: "info",
minify: mode === "production",
outfile: requestLogBodyWorkerOutput,
platform: "browser",
plugins: [rendererAliasPlugin(), packageAliasPlugin(), ...plugins],
sourcemap: mode !== "production",
target: "chrome120"
};
}
export function createBotGatewaySdkBuildOptions({ mode = "production", plugins = [] } = {}) {
return {
absWorkingDir: projectRoot,
@@ -460,6 +486,10 @@ export async function buildWebClientBridge(options = {}) {
await esbuild.build(createWebClientBridgeBuildOptions(options));
}
export async function buildRequestLogBodyWorker(options = {}) {
await esbuild.build(createRequestLogBodyWorkerBuildOptions(options));
}
export function copyCliRuntimeToElectronDist() {
ensureDist();
const cliRuntime = path.join(cliMainOutDir, "cli.js");
+1
View File
@@ -27,6 +27,7 @@ const testProjects = {
runtimeEntryPoints: {
"runtime/fusion-vision-mcp": path.join(packageRoots.core, "mcp", "fusion-vision-mcp.ts"),
"runtime/gateway-bootstrap": path.join(packageRoots.core, "gateway", "core-runtime", "gateway-bootstrap.ts"),
"runtime/local-agent-auth-provider-hook": path.join(packageRoots.core, "gateway", "core-runtime", "local-agent-auth-provider-hook.ts"),
"runtime/media-tools-proxy-mcp": path.join(packageRoots.core, "mcp", "media-tools-proxy-mcp.ts"),
"runtime/request-log-worker": path.join(packageRoots.core, "observability", "request-log-worker.ts"),
"runtime/route-script-worker": path.join(packageRoots.core, "routing", "route-script-worker.ts"),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

+22 -12
View File
@@ -1,18 +1,18 @@
{
"name": "claude-code-router-monorepo",
"version": "3.0.17",
"version": "3.0.19",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-code-router-monorepo",
"version": "3.0.17",
"version": "3.0.19",
"license": "MIT",
"workspaces": [
"packages/*"
],
"dependencies": {
"@the-next-ai/ai-gateway": "^1.0.14",
"@the-next-ai/ai-gateway": "^1.0.16",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"electron-updater": "^6.8.9",
@@ -2311,15 +2311,16 @@
}
},
"node_modules/@the-next-ai/ai-gateway": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/@the-next-ai/ai-gateway/-/ai-gateway-1.0.14.tgz",
"integrity": "sha512-ZVilhuxEoMxvMdPlVI55q6wm6JfWJyKLh9fS5uIAMZtc6Zzc0vf21coLYoP/hsqoNIQCchzkFgD5I2rHDG1QNA==",
"version": "1.0.16",
"resolved": "https://registry.npmjs.org/@the-next-ai/ai-gateway/-/ai-gateway-1.0.16.tgz",
"integrity": "sha512-9umpJ3gGROlXXGBHXHwOdhO9TEcQeG6AlnNmbccaUY5v64czQNFEod23mR31UfqLkv+TpHbKrcDtmuZ5//6S6w==",
"license": "MIT",
"dependencies": {
"diff": "^8.0.3",
"fastify": "^5.8.2",
"glob": "^13.0.6",
"openai": "^6.27.0",
"undici": "^6.28.0",
"ws": "^8.19.0"
},
"bin": {
@@ -2329,6 +2330,15 @@
"node": ">=20"
}
},
"node_modules/@the-next-ai/ai-gateway/node_modules/undici": {
"version": "6.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/@the-next-ai/bot-gateway-sdk": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@the-next-ai/bot-gateway-sdk/-/bot-gateway-sdk-0.1.0.tgz",
@@ -9543,10 +9553,10 @@
},
"packages/cli": {
"name": "@musistudio/claude-code-router",
"version": "3.0.17",
"version": "3.0.19",
"license": "MIT",
"dependencies": {
"@the-next-ai/ai-gateway": "^1.0.14",
"@the-next-ai/ai-gateway": "^1.0.16",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"node-forge": "^1.4.0",
@@ -9561,9 +9571,9 @@
},
"packages/core": {
"name": "@claude-code-router/core",
"version": "3.0.17",
"version": "3.0.19",
"dependencies": {
"@the-next-ai/ai-gateway": "^1.0.14",
"@the-next-ai/ai-gateway": "^1.0.16",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"node-forge": "^1.4.0",
@@ -9579,7 +9589,7 @@
},
"packages/electron": {
"name": "@claude-code-router/electron",
"version": "3.0.17",
"version": "3.0.19",
"dependencies": {
"better-sqlite3": "^12.11.1"
},
@@ -9589,7 +9599,7 @@
},
"packages/ui": {
"name": "@claude-code-router/ui",
"version": "3.0.17",
"version": "3.0.19",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "claude-code-router-monorepo",
"version": "3.0.17",
"version": "3.0.19",
"private": true,
"license": "MIT",
"description": "Local Claude Code Router gateway with CLI and web management UI.",
@@ -72,7 +72,7 @@
"rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3"
},
"dependencies": {
"@the-next-ai/ai-gateway": "^1.0.14",
"@the-next-ai/ai-gateway": "^1.0.16",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"electron-updater": "^6.8.9",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@musistudio/claude-code-router",
"version": "3.0.17",
"version": "3.0.19",
"license": "MIT",
"description": "Local Claude Code Router gateway with CLI and web management UI.",
"repository": {
@@ -41,7 +41,7 @@
"test:integration": "node ../../build/test.mjs cli --scope integration && node ../../build/run-tests.mjs cli"
},
"dependencies": {
"@the-next-ai/ai-gateway": "^1.0.14",
"@the-next-ai/ai-gateway": "^1.0.16",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"node-forge": "^1.4.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@claude-code-router/core",
"version": "3.0.17",
"version": "3.0.19",
"private": true,
"description": "Claude Code Router core gateway, routing, provider, and storage services.",
"main": "dist/main/server.js",
@@ -16,7 +16,7 @@
"test:integration": "node ../../build/test.mjs core --scope integration && node ../../build/run-tests.mjs core"
},
"dependencies": {
"@the-next-ai/ai-gateway": "^1.0.14",
"@the-next-ai/ai-gateway": "^1.0.16",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"node-forge": "^1.4.0",
@@ -24,6 +24,13 @@ type BotGatewaySdkModule = {
createBotGatewayClient: (options?: unknown) => unknown;
};
type BotGatewayCommand = {
args?: string[];
command: string;
cwd?: string;
electronRunAsNode?: boolean;
};
type QrSession = {
botConfigId: string;
client: BotGatewayClientWithRequest;
@@ -172,22 +179,25 @@ export function cancelBotGatewayQrLogin(
async function createQrClient(bot: BotGatewayRuntimeConfig, stateDir: string): Promise<BotGatewayClientWithRequest> {
const sdk = await loadBotGatewaySdk();
const command = resolveBotGatewayCommand(sdk, bot);
const { electronRunAsNode, ...commandOptions } = command ?? {};
const client = sdk.createBotGatewayClient({
transport: "stdio",
env: {
...process.env,
...(electronRunAsNode ? { ELECTRON_RUN_AS_NODE: "1" } : {}),
BOT_GATEWAY_STATE_DIR: stateDir,
CODEXL_HOME: CONFIGDIR
},
...command
...commandOptions
}) as BotGatewayClientWithRequest;
if (!client || typeof client.request !== "function" || typeof client.health !== "function") {
throw new Error("Bot Gateway SDK client does not expose request().");
}
attachBotGatewayStdioErrorHandler(client);
return client;
}
function resolveBotGatewayCommand(sdk: BotGatewaySdkModule, bot: BotGatewayRuntimeConfig): { args?: string[]; command: string; cwd?: string } | undefined {
function resolveBotGatewayCommand(sdk: BotGatewaySdkModule, bot: BotGatewayRuntimeConfig): BotGatewayCommand | undefined {
if (bot.command) {
return {
args: bot.args,
@@ -199,21 +209,19 @@ function resolveBotGatewayCommand(sdk: BotGatewaySdkModule, bot: BotGatewayRunti
return undefined;
}
const bundledPath = sdk.bundledStdioPath();
const runnerPath = materializeBotGatewayStdioRunnerPath(bundledPath);
return {
args: [sanitizedBotGatewayStdioRunnerPath(bundledPath)],
args: [runnerPath],
command: process.execPath,
cwd: path.dirname(bundledPath)
cwd: path.dirname(runnerPath),
electronRunAsNode: Boolean(process.versions.electron)
};
}
function sanitizedBotGatewayStdioRunnerPath(sourcePath: string): string {
export function materializeBotGatewayStdioRunnerPath(sourcePath: string, configDir = CONFIGDIR): string {
const source = readFileSync(sourcePath, "utf8");
const normalized = normalizeDuplicateShebangs(source);
if (normalized === source) {
return sourcePath;
}
const targetDir = path.join(CONFIGDIR, "bot-gateway", "runners");
const targetDir = path.join(configDir, "bot-gateway", "runners");
const targetPath = path.join(targetDir, "bot-gateway-stdio.mjs");
mkdirSync(targetDir, { recursive: true });
if (!existsSync(targetPath) || readFileSync(targetPath, "utf8") !== normalized) {
@@ -234,6 +242,44 @@ function normalizeDuplicateShebangs(source: string): string {
return [lines[0], ...lines.slice(index)].join("\n");
}
function attachBotGatewayStdioErrorHandler(client: BotGatewayClientWithRequest): void {
const stdioClient = client as BotGatewayClientWithRequest & {
child?: {
listenerCount?: (eventName: string) => number;
once: (eventName: string, listener: (error: Error) => void) => unknown;
};
pending?: Map<unknown, { reject?: (error: Error) => void }>;
};
const attach = () => {
const child = stdioClient.child;
if (!child || typeof child.once !== "function") {
return;
}
const listenerCount = typeof child.listenerCount === "function" ? child.listenerCount("error") : 0;
if (listenerCount > 0) {
return;
}
child.once("error", (error: Error) => {
const pending = stdioClient.pending instanceof Map ? stdioClient.pending : undefined;
if (pending) {
for (const request of pending.values()) {
request.reject?.(error);
}
pending.clear();
}
});
};
const originalRequest = client.request.bind(client);
client.request = <T = unknown>(method: string, params?: unknown): Promise<T> => {
try {
return originalRequest<T>(method, params);
} finally {
attach();
}
};
attach();
}
async function loadBotGatewaySdk(): Promise<BotGatewaySdkModule> {
if (!sdkPromise) {
sdkPromise = importBotGatewaySdk();
@@ -1,6 +1,7 @@
import type { AppConfig } from "@ccr/core/contracts/app";
import { availableGatewayModelIds, normalizeProfileScopeValue } from "@ccr/core/contracts/app";
import { modelRegistryForConfig } from "@ccr/core/routing/model-registry";
import { resolveUsageModelAttribution } from "@ccr/core/usage/model-attribution";
export const CLAUDE_APP_ONE_MILLION_CONTEXT_SUFFIX = "[1m]";
const CLAUDE_APP_ENCODED_ROUTE_PREFIX = "anthropic/claude-ccr-h";
@@ -165,14 +166,21 @@ function claudeAppGatewaySupportsOneMillionContext(
}
const providerOverride = claudeAppGatewayProviderSupportsOneMillionContext(baseModel, config);
return providerOverride ?? Boolean(options.supportsOneMillionContext?.(baseModel));
if (providerOverride !== undefined) {
return providerOverride;
}
const physicalSelector = claudeAppGatewayPhysicalModelSelector(baseModel, config);
return Boolean(
options.supportsOneMillionContext?.(physicalSelector ?? baseModel) ||
(physicalSelector && physicalSelector !== baseModel && options.supportsOneMillionContext?.(baseModel))
);
}
function claudeAppGatewayProviderSupportsOneMillionContext(
model: string,
config: Pick<AppConfig, "Providers" | "virtualModelProfiles">
): boolean | undefined {
const resolved = modelRegistryForConfig(config).resolveProviderModel(model);
const resolved = claudeAppGatewayResolvedProviderModel(model, config);
if (!resolved) {
return undefined;
}
@@ -188,6 +196,34 @@ function claudeAppGatewayProviderSupportsOneMillionContext(
return Math.floor((contextWindow * effectivePercent) / 100) >= 1_000_000;
}
function claudeAppGatewayPhysicalModelSelector(
model: string,
config: Pick<AppConfig, "Providers" | "virtualModelProfiles">
): string | undefined {
const resolved = claudeAppGatewayResolvedProviderModel(model, config);
return resolved ? `${resolved.provider.name}/${resolved.model}` : undefined;
}
function claudeAppGatewayResolvedProviderModel(
model: string,
config: Pick<AppConfig, "Providers" | "virtualModelProfiles">
) {
const registry = modelRegistryForConfig(config);
const direct = registry.resolveProviderModel(model);
if (direct) {
return direct;
}
const attribution = resolveUsageModelAttribution(config, model);
if (!attribution.provider || !attribution.model) {
return undefined;
}
const resolved = registry.resolve(`${attribution.provider}/${attribution.model}`);
return resolved?.kind === "provider"
? { model: resolved.model, provider: resolved.provider }
: undefined;
}
function positiveInteger(value: number | undefined): number | undefined {
return value !== undefined && Number.isFinite(value) && value > 0
? Math.trunc(value)
@@ -5,6 +5,7 @@ import path from "node:path";
import type { AppConfig, ProfileConfig } from "@ccr/core/contracts/app";
import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env";
import { prepareClaudeAppCdpUserDataDir, reserveClaudeAppCdpPort, scheduleClaudeAppDesignCdp } from "@ccr/core/agents/claude-app/cdp";
import { prepareClaudeAppVmStorage } from "@ccr/core/agents/claude-app/vm-storage";
import { claudeCodeModelEnv as claudeCodeProfileModelEnv, claudeCodeUtcTimezoneEnvOverride, isClaudeCodeManagedModelEnvKey } from "@ccr/core/agents/claude-code/environment";
import { resolveClaudeCodeSettingsFile } from "@ccr/core/profiles/launch-core";
import { normalizeWindowsDesktopAppCandidate, windowsDesktopAppCandidates } from "@ccr/core/platform/windows-app-discovery";
@@ -52,6 +53,10 @@ export async function launchClaudeAppProfile(configDir: string, profile: Profile
const settingsDir = path.dirname(settingsFile);
const userDataDir = resolveClaudeAppProfileUserDataDir(configDir, profile);
mkdirSync(userDataDir, { recursive: true });
const vmStorage = prepareClaudeAppVmStorage(configDir, userDataDir);
if (vmStorage.action === "skipped" && vmStorage.reason === "clone-failed") {
console.warn(`[profile] Failed to clone Claude App VM seed for ${profile.name || profile.id}. Claude App may rebuild its VM in ${vmStorage.targetBundleDir}.`);
}
prepareClaudeAppCdpUserDataDir(userDataDir);
const shouldOpenDesign = shouldOpenClaudeAppDesign(config);
const cdpPort = await reserveClaudeAppCdpPort(console, shouldOpenDesign);
@@ -0,0 +1,408 @@
import { constants, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, rmSync, statSync, symlinkSync, chmodSync, renameSync } from "node:fs";
import { spawnSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { resolveRuntimeAppPath } from "@ccr/core/runtime/app-paths";
const claudeAppVmBundlesDir = "vm_bundles";
const claudeAppVmBundleName = "claudevm.bundle";
const claudeAppVmSeedEnv = "CCR_CLAUDE_APP_VM_SEED_DIR";
const claudeAppVmSeedDisabledEnv = "CCR_CLAUDE_APP_VM_SEED_DISABLED";
const maxSmallFileCopyBytes = 64 * 1024 * 1024;
const lockRetryIntervalMs = 100;
const lockTimeoutMs = 15_000;
const staleLockMs = 10 * 60_000;
export type ClaudeAppVmStoragePrepareResult =
| {
action: "prepared";
seedBundleDir: string;
sourceBundleDir: string;
targetBundleDir: string;
}
| {
action: "skipped";
reason: string;
targetBundleDir: string;
};
export function prepareClaudeAppVmStorage(configDir: string, userDataDir: string): ClaudeAppVmStoragePrepareResult {
const targetBundleDir = resolveClaudeAppVmBundleDir(userDataDir);
if (isDisabledEnv(process.env[claudeAppVmSeedDisabledEnv])) {
return { action: "skipped", reason: "disabled", targetBundleDir };
}
return withVmStorageLock(configDir, targetBundleDir, () => prepareClaudeAppVmStorageLocked(configDir, userDataDir));
}
function prepareClaudeAppVmStorageLocked(configDir: string, userDataDir: string): ClaudeAppVmStoragePrepareResult {
const targetBundleDir = resolveClaudeAppVmBundleDir(userDataDir);
if (isUsableClaudeAppVmBundle(targetBundleDir)) {
return { action: "skipped", reason: "target-present", targetBundleDir };
}
const sourceBundleDir = findClaudeAppVmSeedBundle(configDir, userDataDir);
if (!sourceBundleDir) {
return { action: "skipped", reason: "no-seed", targetBundleDir };
}
const seedBundleDir = ensureSharedClaudeAppVmSeed(configDir, sourceBundleDir, targetBundleDir);
if (!seedBundleDir || samePath(seedBundleDir, targetBundleDir)) {
return { action: "skipped", reason: "seed-unavailable", targetBundleDir };
}
if (!prepareTargetBundlePath(targetBundleDir)) {
return { action: "skipped", reason: "target-not-replaceable", targetBundleDir };
}
try {
cloneDirectory(seedBundleDir, targetBundleDir);
return {
action: "prepared",
seedBundleDir,
sourceBundleDir,
targetBundleDir
};
} catch {
rmSync(targetBundleDir, { force: true, recursive: true });
return { action: "skipped", reason: "clone-failed", targetBundleDir };
}
}
function withVmStorageLock(
configDir: string,
targetBundleDir: string,
run: () => ClaudeAppVmStoragePrepareResult
): ClaudeAppVmStoragePrepareResult {
const lockDir = path.join(configDir, "app-cache", "claude-app", ".vm-storage.lock");
if (!acquireDirectoryLock(lockDir)) {
return { action: "skipped", reason: "lock-timeout", targetBundleDir };
}
try {
return run();
} finally {
rmSync(lockDir, { force: true, recursive: true });
}
}
function acquireDirectoryLock(lockDir: string): boolean {
const deadline = Date.now() + lockTimeoutMs;
mkdirSync(path.dirname(lockDir), { mode: 0o700, recursive: true });
while (Date.now() < deadline) {
try {
mkdirSync(lockDir, { mode: 0o700 });
return true;
} catch {
removeStaleLock(lockDir);
sleepSync(lockRetryIntervalMs);
}
}
return false;
}
function removeStaleLock(lockDir: string): void {
try {
const stat = statSync(lockDir);
if (Date.now() - stat.mtimeMs > staleLockMs) {
rmSync(lockDir, { force: true, recursive: true });
}
} catch {
// Another process may have released the lock between attempts.
}
}
function sleepSync(ms: number): void {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
export function resolveClaudeAppVmBundleDir(userDataDir: string): string {
return path.join(userDataDir, claudeAppVmBundlesDir, claudeAppVmBundleName);
}
export function resolveSharedClaudeAppVmSeedBundleDir(configDir: string): string {
return path.join(configDir, "app-cache", "claude-app", "vm-seeds", claudeAppVmBundleName);
}
export function resolveClaudeAppDefaultUserDataDirs(): string[] {
if (process.platform === "darwin") {
const applicationSupport = path.join(resolveRuntimeAppPath("home"), "Library", "Application Support");
return [
path.join(applicationSupport, "Claude"),
path.join(applicationSupport, "Claude Desktop"),
path.join(applicationSupport, "Claude-3p")
];
}
if (process.platform === "win32") {
const roaming = resolveRuntimeAppPath("appData");
const local = process.env.LOCALAPPDATA || path.join(roaming, "..", "Local");
return [
path.join(roaming, "Claude"),
path.join(roaming, "Claude Desktop"),
path.join(local, "Claude"),
path.join(local, "Claude Desktop"),
path.join(local, "Claude-3p")
];
}
const configHome = resolveRuntimeAppPath("appData");
return [
path.join(configHome, "Claude"),
path.join(configHome, "claude"),
path.join(configHome, "Claude Desktop"),
path.join(configHome, "claude-desktop"),
path.join(configHome, "Claude-3p")
];
}
function findClaudeAppVmSeedBundle(configDir: string, userDataDir: string): string | undefined {
const targetBundleDir = resolveClaudeAppVmBundleDir(userDataDir);
return uniqueStrings([
...configuredSeedBundleCandidates(),
resolveSharedClaudeAppVmSeedBundleDir(configDir),
...resolveClaudeAppDefaultUserDataDirs().map(resolveClaudeAppVmBundleDir),
...existingCcrProfileVmBundleCandidates(configDir)
]).find((candidate) =>
!samePath(candidate, targetBundleDir) &&
isUsableClaudeAppVmBundle(candidate)
);
}
function configuredSeedBundleCandidates(): string[] {
const configured = process.env[claudeAppVmSeedEnv]?.trim();
if (!configured) {
return [];
}
return configured
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean)
.flatMap((entry) => {
const resolved = resolveUserPath(entry);
return path.basename(resolved) === claudeAppVmBundleName
? [resolved]
: [resolved, resolveClaudeAppVmBundleDir(resolved)];
});
}
function existingCcrProfileVmBundleCandidates(configDir: string): string[] {
const profilesDir = path.join(configDir, "profiles");
if (!isDirectory(profilesDir)) {
return [];
}
const results: string[] = [];
const pending: Array<{ depth: number; dir: string }> = [{ depth: 0, dir: profilesDir }];
while (pending.length > 0) {
const current = pending.shift();
if (!current || current.depth > 8) {
continue;
}
for (const entry of readDirEntries(current.dir)) {
const file = path.join(current.dir, entry);
if (entry === claudeAppVmBundleName && isDirectory(file) && file.includes(`${path.sep}${claudeAppVmBundlesDir}${path.sep}`)) {
results.push(file);
continue;
}
if (isDirectory(file)) {
pending.push({ depth: current.depth + 1, dir: file });
}
}
}
return results;
}
function ensureSharedClaudeAppVmSeed(
configDir: string,
sourceBundleDir: string,
targetBundleDir: string
): string | undefined {
const seedBundleDir = resolveSharedClaudeAppVmSeedBundleDir(configDir);
if (isUsableClaudeAppVmBundle(seedBundleDir)) {
return seedBundleDir;
}
if (samePath(sourceBundleDir, seedBundleDir)) {
return isUsableClaudeAppVmBundle(sourceBundleDir) ? sourceBundleDir : undefined;
}
if (!prepareTargetBundlePath(seedBundleDir)) {
return sourceBundleDir;
}
try {
cloneDirectory(sourceBundleDir, seedBundleDir);
return seedBundleDir;
} catch {
rmSync(seedBundleDir, { force: true, recursive: true });
return samePath(sourceBundleDir, targetBundleDir) ? undefined : sourceBundleDir;
}
}
function prepareTargetBundlePath(bundleDir: string): boolean {
if (!pathEntryExists(bundleDir)) {
mkdirSync(path.dirname(bundleDir), { mode: 0o700, recursive: true });
return true;
}
if (!isReplaceableIncompleteBundle(bundleDir)) {
return false;
}
rmSync(bundleDir, { force: true, recursive: true });
mkdirSync(path.dirname(bundleDir), { mode: 0o700, recursive: true });
return true;
}
function isUsableClaudeAppVmBundle(bundleDir: string): boolean {
return isDirectory(bundleDir) && (
existsSync(path.join(bundleDir, "rootfs.img")) ||
existsSync(path.join(bundleDir, "rootfs.img.zst"))
);
}
function isReplaceableIncompleteBundle(bundleDir: string): boolean {
if (!isDirectory(bundleDir)) {
return isSymlink(bundleDir);
}
const entries = readDirEntries(bundleDir);
if (entries.length === 0) {
return true;
}
return entries.every((entry) =>
entry === ".cowork-adopted" ||
entry === ".DS_Store" ||
entry.startsWith(".wvm-tmp-")
);
}
function cloneDirectory(sourceDir: string, targetDir: string): void {
const parentDir = path.dirname(targetDir);
const tempDir = path.join(parentDir, `.${path.basename(targetDir)}.ccr-clone-${process.pid}-${Date.now()}`);
rmSync(tempDir, { force: true, recursive: true });
try {
copyDirectoryContents(sourceDir, tempDir);
rmSync(targetDir, { force: true, recursive: true });
renameSync(tempDir, targetDir);
} catch (error) {
rmSync(tempDir, { force: true, recursive: true });
throw error;
}
}
function copyDirectoryContents(sourceDir: string, targetDir: string): void {
const sourceStat = statSync(sourceDir);
mkdirSync(targetDir, { mode: sourceStat.mode & 0o777, recursive: true });
chmodBestEffort(targetDir, sourceStat.mode & 0o777);
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
const source = path.join(sourceDir, entry.name);
const target = path.join(targetDir, entry.name);
const sourceEntryStat = lstatSync(source);
if (sourceEntryStat.isSymbolicLink()) {
symlinkSync(readlinkSync(source), target);
continue;
}
if (sourceEntryStat.isDirectory()) {
copyDirectoryContents(source, target);
continue;
}
if (sourceEntryStat.isFile()) {
copyFileWithClone(source, target, sourceEntryStat.size);
chmodBestEffort(target, sourceEntryStat.mode & 0o777);
}
}
}
function copyFileWithClone(source: string, target: string, size: number): void {
if (process.platform === "darwin" && cloneFileWithMacCp(source, target)) {
return;
}
try {
copyFileSync(source, target, constants.COPYFILE_FICLONE_FORCE);
return;
} catch {
if (size > maxSmallFileCopyBytes) {
throw new Error(`Copy-on-write clone is not available for ${source}.`);
}
}
copyFileSync(source, target);
}
function cloneFileWithMacCp(source: string, target: string): boolean {
const result = spawnSync("/bin/cp", ["-c", source, target], {
stdio: "ignore"
});
return result.status === 0;
}
function pathEntryExists(file: string): boolean {
try {
lstatSync(file);
return true;
} catch {
return false;
}
}
function isDirectory(file: string): boolean {
try {
return statSync(file).isDirectory();
} catch {
return false;
}
}
function isSymlink(file: string): boolean {
try {
return lstatSync(file).isSymbolicLink();
} catch {
return false;
}
}
function readDirEntries(dir: string): string[] {
try {
return readdirSync(dir);
} catch {
return [];
}
}
function samePath(left: string, right: string): boolean {
return normalizeComparablePath(left) === normalizeComparablePath(right);
}
function normalizeComparablePath(value: string): string {
const normalized = path.resolve(value);
return process.platform === "win32" ? normalized.replace(/\\/g, "/").toLowerCase() : normalized;
}
function uniqueStrings(values: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const normalized = normalizeComparablePath(value);
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
result.push(value);
}
return result;
}
function resolveUserPath(value: string): string {
if (value === "~") {
return os.homedir();
}
if (value.startsWith(`~${path.sep}`) || value.startsWith("~/")) {
return path.join(os.homedir(), value.slice(2));
}
return path.resolve(value);
}
function chmodBestEffort(file: string, mode: number): void {
try {
chmodSync(file, mode);
} catch {
// File permissions are best-effort across platforms.
}
}
function isDisabledEnv(value: string | undefined): boolean {
const normalized = value?.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
}
@@ -5498,6 +5498,7 @@ class BotGatewayBridge {
});
const clientOptions = botGatewaySdkClientOptions(this.config, env, sdk);
this.client = sdk.createBotGatewayClient(clientOptions);
attachBotGatewayStdioErrorHandler(this.client);
await withTimeout(this.client.health(), this.config.startupTimeoutMs, "Bot Gateway health check timed out.");
this.updateDiagnostics({ state: "connected", connectedAt: new Date().toISOString(), lastError: "" });
await this.ensureIntegration();
@@ -5686,10 +5687,13 @@ function botGatewaySdkImportSpecifier(value) {
function botGatewaySdkClientOptions(config, env, sdk) {
const command = resolveBotGatewayCommand(config) || resolveBundledBotGatewayCommand(sdk);
const commandOptions = Object.assign({}, command || {});
const electronRunAsNode = Boolean(commandOptions.electronRunAsNode);
delete commandOptions.electronRunAsNode;
return {
transport: "stdio",
...(command || {}),
env
...commandOptions,
env: electronRunAsNode ? { ...env, ELECTRON_RUN_AS_NODE: "1" } : env
};
}
@@ -5709,20 +5713,18 @@ function resolveBundledBotGatewayCommand(sdk) {
return undefined;
}
const bundledPath = sdk.bundledStdioPath();
const runnerPath = materializeBotGatewayStdioRunnerPath(bundledPath);
return {
command: process.execPath,
args: [sanitizedBotGatewayStdioRunnerPath(bundledPath)],
cwd: path.dirname(bundledPath)
args: [runnerPath],
cwd: path.dirname(runnerPath),
electronRunAsNode: Boolean(process.versions && process.versions.electron)
};
}
function sanitizedBotGatewayStdioRunnerPath(sourcePath) {
function materializeBotGatewayStdioRunnerPath(sourcePath) {
const source = fs.readFileSync(sourcePath, "utf8");
const normalized = normalizeDuplicateShebangs(source);
if (normalized === source) {
return sourcePath;
}
const targetDir = path.join(CONFIG_DIR, "bot-gateway", "runners");
const targetPath = path.join(targetDir, "bot-gateway-stdio.mjs");
fs.mkdirSync(targetDir, { recursive: true });
@@ -5744,6 +5746,35 @@ function normalizeDuplicateShebangs(source) {
return [lines[0], ...lines.slice(index)].join("\n");
}
function attachBotGatewayStdioErrorHandler(client) {
const attach = () => {
const child = client && client.child;
if (!child || typeof child.once !== "function") return;
const listenerCount = typeof child.listenerCount === "function" ? child.listenerCount("error") : 0;
if (listenerCount > 0) return;
child.once("error", (error) => {
const pending = client && client.pending instanceof Map ? client.pending : null;
if (!pending) return;
for (const request of pending.values()) {
if (request && typeof request.reject === "function") request.reject(error);
}
pending.clear();
});
};
if (!client || typeof client.request !== "function") {
return;
}
const originalRequest = client.request.bind(client);
client.request = (method, params) => {
try {
return originalRequest(method, params);
} finally {
attach();
}
};
attach();
}
function botGatewayClientRequest(client, method, params, timeoutMs) {
if (!client || typeof client.request !== "function") {
return Promise.reject(new Error("Bot Gateway SDK client does not expose request()."));
@@ -137,7 +137,7 @@ function codexModelCatalogItem(
default_reasoning_level: profile.defaultReasoningLevel,
default_reasoning_effort: profile.defaultReasoningLevel,
default_reasoning_summary: profile.defaultReasoningSummary,
description: `CCR gateway model ${model}`,
description: profile.description ?? `CCR gateway model ${model}`,
displayName: model,
display_name: model,
effective_context_window_percent: effectiveContextWindowPercent,
@@ -179,6 +179,7 @@ type CodexCapabilityProfile = {
applyPatchToolType: string | null;
catalogEntry?: ModelCatalogEntry;
contextWindow?: number;
description?: string;
defaultReasoningLevel: string | null;
defaultReasoningSummary: string;
effectiveContextWindowPercent?: number;
@@ -250,6 +251,9 @@ function codexModelCapabilityProfile(
applyPatchToolType,
catalogEntry,
contextWindow: providerModelMetadata?.contextWindow,
description: provider
? providerModelDescriptionFor(provider, providerModel)
: undefined,
defaultReasoningLevel: resolveDefaultReasoningLevel(
providerModelMetadata?.defaultReasoningLevel !== undefined
? providerModelMetadata.defaultReasoningLevel
@@ -281,6 +285,17 @@ function providerModelMetadataFor(provider: GatewayProviderConfig, model: string
return match?.[1];
}
function providerModelDescriptionFor(provider: GatewayProviderConfig, model: string): string | undefined {
const descriptions = provider.modelDescriptions ?? {};
const direct = descriptions[model]?.trim();
if (direct) {
return direct;
}
const normalized = model.trim().toLowerCase();
const match = Object.entries(descriptions).find(([candidate]) => candidate.trim().toLowerCase() === normalized);
return match?.[1]?.trim() || undefined;
}
function codexProviderModelMetadataFor(provider: GatewayProviderConfig, model: string): ProviderModelMetadata | undefined {
const metadata = providerModelMetadataFor(provider, model) ?? localCodexModelMetadataFor(provider, model);
if (!isLocalCodexProvider(provider)) {
@@ -1,4 +1,5 @@
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import os from "node:os";
import path from "node:path";
import type {
@@ -9,19 +10,35 @@ import type {
} from "@ccr/core/contracts/app";
import {
bearerAuthPlugin,
findOauthTokenSet,
isRecord,
missingCandidate,
providerInternalNamePlaceholder,
providerPayload,
readJsonRecord,
readOauthTokenSetFields,
uniqueProviderName,
uniqueStrings,
type OAuthTokenSet
} from "@ccr/core/agents/local-providers/shared";
const claudeDefaultModels = ["claude-sonnet-5"];
const claudeCodeKeychainService = "Claude Code-credentials";
const claudeCodeKeychainServiceBase = "Claude Code-credentials";
const claudeCodeKeychainAccountPattern = /^[a-zA-Z0-9._-]+$/;
const claudeCodeKeychainServicePattern = /^Claude Code(?:-[a-z]+-oauth)?-credentials(?:-[0-9a-f]{8})?$/;
// `security` exit code for errSecItemNotFound.
const keychainItemNotFoundStatus = 44;
type ClaudeCodeKeychainCandidate = { account?: string; service: string };
export type ClaudeCodeLoginScan = {
/** Reads that failed, with the `security` stderr that explains why. */
errors: string[];
/** Locations that parsed successfully. */
inspected: string[];
oauth?: OAuthTokenSet;
/** Locations that parsed but carry no OAuth token. */
tokenless: string[];
};
const percentLimitMapping = (id: string, label: string, path: string, window: string) => ({
id,
@@ -65,7 +82,8 @@ const claudeCodeAccountMapping: ProviderAccountMappingConfig = {
};
export function claudeCodeCandidate(): LocalAgentProviderCandidate {
const oauth = readClaudeCodeOauth();
const scan = scanClaudeCodeLogin();
const oauth = scan.oauth;
if (oauth?.accessToken) {
return {
detail: "Claude Code login detected. Click Import to add it as a gateway provider.",
@@ -92,9 +110,36 @@ export function claudeCodeCandidate(): LocalAgentProviderCandidate {
status: "locked"
};
}
const detail = claudeCodeScanDiagnostic(scan);
if (detail) {
return {
detail,
id: "claude-code-api",
importable: false,
kind: "claude-code",
models: claudeDefaultModels,
name: "Claude Code API",
protocol: "anthropic_messages",
sourceFile: scan.inspected[0],
status: "locked"
};
}
return missingCandidate("claude-code", "claude-code-api", "Claude Code API", "anthropic_messages", claudeDefaultModels);
}
// Distinguishes "found login state but no token" and "could not read the store"
// from "no login at all", so the Add Provider list can say why an import is
// unavailable instead of silently omitting the candidate.
function claudeCodeScanDiagnostic(scan: ClaudeCodeLoginScan): string | undefined {
if (scan.tokenless.length > 0) {
return `Claude Code login state was found but contains no OAuth token (${scan.tokenless.join("; ")}). Run \`claude /login\`, then retry.`;
}
if (scan.errors.length > 0) {
return `Claude Code login state could not be read: ${scan.errors.join("; ")}`;
}
return undefined;
}
export function importClaudeCodeProvider(candidate: LocalAgentProviderCandidate, providerNames: string[]): LocalAgentProviderImportResult {
const oauth = readClaudeCodeOauth();
const token = oauth?.accessToken;
@@ -139,9 +184,15 @@ function claudeCodeProviderAccountConfig(): ProviderAccountConfig {
}
export function readClaudeCodeOauth(): OAuthTokenSet | undefined {
const keychainOauth = readClaudeCodeKeychainOauth();
return scanClaudeCodeLogin().oauth;
}
export function scanClaudeCodeLogin(): ClaudeCodeLoginScan {
const scan: ClaudeCodeLoginScan = { errors: [], inspected: [], tokenless: [] };
const keychainOauth = scanClaudeCodeKeychain(scan);
if (keychainOauth) {
return keychainOauth;
scan.oauth = keychainOauth;
return scan;
}
for (const sourceFile of claudeCredentialFiles()) {
@@ -149,58 +200,238 @@ export function readClaudeCodeOauth(): OAuthTokenSet | undefined {
if (!record) {
continue;
}
const credential = findOauthTokenSet(record);
return {
accessToken: credential?.accessToken,
refreshToken: credential?.refreshToken,
scan.inspected.push(sourceFile);
// Root-level fields only, same reasoning as the keychain path below: Claude
// Code writes this file with the identical record shape, mcpOAuth included.
const credential = readOauthTokenSetFields(record.claudeAiOauth) ?? readOauthTokenSetFields(record);
if (!credential?.accessToken && !credential?.refreshToken) {
scan.tokenless.push(`${sourceFile} (keys: ${Object.keys(record).join(", ")})`);
continue;
}
scan.oauth = {
accessToken: credential.accessToken,
refreshToken: credential.refreshToken,
sourceFile
};
return scan;
}
return undefined;
return scan;
}
function claudeCredentialFiles(): string[] {
return uniqueStrings([
path.join(claudeCodeStorageDir(), ".credentials.json"),
path.join(os.homedir(), ".claude", ".credentials.json"),
path.join(os.homedir(), ".claude", "credentials.json"),
path.join(os.homedir(), ".config", "claude", "credentials.json")
]);
}
// Newer macOS builds of the Claude Code CLI store credentials in the
// Keychain instead of ~/.claude/.credentials.json. Reading it triggers the
// standard macOS keychain access prompt (Allow / Always Allow); the user
// declining or the item not existing both surface as a non-zero exit here.
function readClaudeCodeKeychainOauth(): OAuthTokenSet | undefined {
const keychainRecord = readClaudeCodeKeychainRecord();
if (!keychainRecord) {
return undefined;
}
const credential = findOauthTokenSet(keychainRecord);
if (!credential) {
return undefined;
}
return {
accessToken: credential.accessToken,
refreshToken: credential.refreshToken,
sourceFile: `keychain:${claudeCodeKeychainService}`
};
// Claude Code's plaintext fallback lives in its secure-storage config dir,
// which CLAUDE_SECURESTORAGE_CONFIG_DIR / CLAUDE_CONFIG_DIR can relocate.
function claudeCodeStorageDir(): string {
const secureStorageDir = process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR;
const dir = secureStorageDir !== undefined
? secureStorageDir || path.join(os.homedir(), ".claude")
: process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
return dir.normalize("NFC");
}
function readClaudeCodeKeychainRecord(): Record<string, unknown> | undefined {
// Claude Code >= 2.1 derives the keychain service name from its config dir:
// `Claude Code${oauthSuffix}-credentials${configSuffix}`
// `configSuffix` is empty for a default config dir and
// `-${sha256(NFC(configDir)).slice(0, 8)}` once CLAUDE_CONFIG_DIR or
// CLAUDE_SECURESTORAGE_CONFIG_DIR is set. Verified against 2.1.220.
function claudeCodeExpectedKeychainServices(): string[] {
const secureStorageDir = process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR;
const usesDefaultDir = secureStorageDir !== undefined ? !secureStorageDir : !process.env.CLAUDE_CONFIG_DIR;
const configSuffix = usesDefaultDir
? ""
: `-${createHash("sha256").update(claudeCodeStorageDir()).digest("hex").slice(0, 8)}`;
const oauthSuffix = process.env.CLAUDE_CODE_CUSTOM_OAUTH_URL ? "-custom-oauth" : "";
return uniqueStrings([`Claude Code${oauthSuffix}-credentials${configSuffix}`, claudeCodeKeychainServiceBase]);
}
// Claude Code writes the item under the current `$USER`; a login from an older
// build (or one that could not resolve a username) sits under a different
// account on the *same* service name, so the account has to be explicit.
function claudeCodeKeychainAccount(): string {
let user: string | undefined;
try {
user = process.env.USER || os.userInfo().username;
} catch {
user = undefined;
}
return user && claudeCodeKeychainAccountPattern.test(user) ? user : "claude-code-user";
}
// Newer macOS builds of the Claude Code CLI store credentials in the Keychain
// instead of ~/.claude/.credentials.json. Reading one triggers the standard
// macOS keychain access prompt (Allow / Always Allow); the user declining or
// the item not existing both surface as a non-zero exit here.
function scanClaudeCodeKeychain(scan: ClaudeCodeLoginScan): OAuthTokenSet | undefined {
if (process.platform !== "darwin") {
return undefined;
}
const expectedServices = claudeCodeExpectedKeychainServices();
const account = claudeCodeKeychainAccount();
// Ordered by cost: the enumeration tier shells out to `security dump-keychain`,
// so it is only built once the expected item fails to produce a token.
const tiers: Array<() => ClaudeCodeKeychainCandidate[]> = [
() => expectedServices.map(service => ({ account, service })),
// An item written under a different account, or under a config dir this
// process cannot reconstruct, only turns up by enumeration.
discoverClaudeCodeKeychainItems,
// Pre-2.1 lookup: no `-a`, so the Keychain picks an arbitrary account when
// several items share the service name.
() => expectedServices.map(service => ({ service }))
];
const attempted = new Set<string>();
const servicesRead = new Set<string>();
let legacyRootMatch: OAuthTokenSet | undefined;
for (const buildTier of tiers) {
for (const candidate of buildTier()) {
const key = `${candidate.service}\u0000${candidate.account ?? ""}`;
// An accountless read of a service already read by account returns one of
// those same items, so it would only duplicate the diagnostics.
if (attempted.has(key) || (candidate.account === undefined && servicesRead.has(candidate.service))) {
continue;
}
attempted.add(key);
const label = candidate.account === undefined ? candidate.service : `${candidate.service} (${candidate.account})`;
const record = readClaudeCodeKeychainRecord(candidate, scan, label);
if (!record) {
continue;
}
servicesRead.add(candidate.service);
scan.inspected.push(`keychain:${label}`);
// Only `claudeAiOauth` is an Anthropic API credential. Both reads are
// root-level: a recursive search would descend into `mcpOAuth`, whose
// per-plugin records also carry `accessToken`, and that token imports
// cleanly as a provider and then 401s on every request. Restricting to
// root-level fields excludes *any* nested container, so a future sibling
// key cannot reintroduce the hole.
const claudeAiOauth = readOauthTokenSetFields(record.claudeAiOauth);
if (claudeAiOauth) {
return {
accessToken: claudeAiOauth.accessToken,
refreshToken: claudeAiOauth.refreshToken,
sourceFile: `keychain:${label}`
};
}
// Pre-`claudeAiOauth` layout, where the tokens sat at the record root.
const legacyRoot = readOauthTokenSetFields(record);
if (legacyRoot) {
legacyRootMatch ??= {
accessToken: legacyRoot.accessToken,
refreshToken: legacyRoot.refreshToken,
sourceFile: `keychain:${label}`
};
continue;
}
scan.tokenless.push(`keychain:${label} (keys: ${Object.keys(record).join(", ")})`);
}
}
// Every tier is exhausted before falling back to a root-level match, so an
// explicit `claudeAiOauth` on any item always wins.
return legacyRootMatch;
}
// `security dump-keychain` without `-d` prints item *metadata* only: it never
// decrypts a password and never prompts. Finds login items this environment
// cannot name, newest-modified first.
function discoverClaudeCodeKeychainItems(): ClaudeCodeKeychainCandidate[] {
let dump: string;
try {
const output = execFileSync(
"security",
["find-generic-password", "-s", claudeCodeKeychainService, "-w"],
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
);
const parsed = JSON.parse(output.trim()) as unknown;
return isRecord(parsed) ? parsed : undefined;
dump = execFileSync("security", ["dump-keychain"], {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
stdio: ["ignore", "pipe", "ignore"]
});
} catch {
return [];
}
const found: Array<ClaudeCodeKeychainCandidate & { modified: string }> = [];
let account: string | undefined;
let modified = "";
let service: string | undefined;
const flush = () => {
if (service && claudeCodeKeychainServicePattern.test(service)) {
found.push({ account, modified, service });
}
account = undefined;
modified = "";
service = undefined;
};
for (const line of dump.split("\n")) {
if (line.startsWith("keychain:")) {
flush();
continue;
}
const serviceMatch = /^\s*"svce"<blob>="(.*)"$/.exec(line);
if (serviceMatch) {
service = serviceMatch[1];
continue;
}
const accountMatch = /^\s*"acct"<blob>="(.*)"$/.exec(line);
if (accountMatch) {
account = accountMatch[1];
continue;
}
const modifiedMatch = /^\s*"mdat"<timedate>=.*"(\d{14})Z/.exec(line);
if (modifiedMatch) {
modified = modifiedMatch[1];
}
}
flush();
return found
.sort((left, right) => right.modified.localeCompare(left.modified))
.map(({ account: itemAccount, service: itemService }) => ({ account: itemAccount, service: itemService }));
}
function readClaudeCodeKeychainRecord(
candidate: ClaudeCodeKeychainCandidate,
scan: ClaudeCodeLoginScan,
label: string
): Record<string, unknown> | undefined {
const args = ["find-generic-password", "-s", candidate.service, "-w"];
if (candidate.account !== undefined) {
args.splice(1, 0, "-a", candidate.account);
}
try {
const output = execFileSync("security", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
const parsed = JSON.parse(output.trim()) as unknown;
if (isRecord(parsed)) {
return parsed;
}
scan.errors.push(`keychain:${label}: item is not a JSON object`);
return undefined;
} catch (error) {
const message = keychainErrorMessage(error);
// errSecItemNotFound is the ordinary "no such login" answer, not a fault:
// recording it would turn a logged-out machine into a `locked` candidate.
if (message !== undefined) {
scan.errors.push(`keychain:${label}: ${message}`);
}
return undefined;
}
}
// Returns undefined when the item simply does not exist.
function keychainErrorMessage(error: unknown): string | undefined {
if (error instanceof SyntaxError) {
return `item is not valid JSON (${error.message})`;
}
const failure = error as { status?: number; stderr?: Buffer | string } | undefined;
if (failure?.status === keychainItemNotFoundStatus) {
return undefined;
}
const stderr = typeof failure?.stderr === "string" ? failure.stderr : failure?.stderr?.toString("utf8");
const message = stderr?.trim().replace(/\s*\n\s*/g, "; ");
if (message) {
return /could not be found/i.test(message) ? undefined : message;
}
return failure?.status === undefined ? String(error) : `security exited ${failure.status}`;
}
@@ -130,8 +130,11 @@ export function cloneProviderAccountConfig(account: ProviderAccountConfig | unde
return account ? JSON.parse(JSON.stringify(account)) as ProviderAccountConfig : undefined;
}
export function findOauthTokenSet(value: unknown, depth = 0): { accessToken?: string; refreshToken?: string } | undefined {
if (!isRecord(value) || depth > 5) {
// Reads the token fields on `value` itself, never descending into children.
// Callers that know their record shape use this to avoid matching an unrelated
// nested credential -- see the mcpOAuth note in claude-code.ts.
export function readOauthTokenSetFields(value: unknown): { accessToken?: string; refreshToken?: string } | undefined {
if (!isRecord(value)) {
return undefined;
}
const accessToken =
@@ -142,8 +145,16 @@ export function findOauthTokenSet(value: unknown, depth = 0): { accessToken?: st
readString(value.refreshToken) ||
readString(value.refresh_token) ||
readString(value.anthropicRefreshToken);
if (accessToken || refreshToken) {
return { accessToken, refreshToken };
return accessToken || refreshToken ? { accessToken, refreshToken } : undefined;
}
export function findOauthTokenSet(value: unknown, depth = 0): { accessToken?: string; refreshToken?: string } | undefined {
if (!isRecord(value) || depth > 5) {
return undefined;
}
const direct = readOauthTokenSetFields(value);
if (direct) {
return direct;
}
for (const child of Object.values(value)) {
const found = findOauthTokenSet(child, depth + 1);
+21 -1
View File
@@ -527,6 +527,10 @@ export function normalizeProviderPresetCapabilitiesForTest(
return normalizeProviderPresetCapabilities(provider);
}
export function parseProvidersForTest(value: unknown): GatewayProviderConfig[] | undefined {
return parseProviders(value);
}
function hasUnsupportedNvidiaCapabilities(value: unknown): boolean {
if (!Array.isArray(value)) {
return false;
@@ -1378,7 +1382,8 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined {
baseUrl: readString(item.baseUrl),
baseurl: readString(item.baseurl),
billing: item.billing,
capabilities: parseProviderCapabilities(item.capabilities),
capabilities: parseProviderCapabilities(item.capabilities)
?? parseProviderProtocolCapability(item),
credentials: parseProviderCredentials(item.credentials ?? item.keys ?? item.apiKeys),
extraBody: item.extraBody,
extraHeaders: item.extraHeaders,
@@ -1687,6 +1692,21 @@ function parseProviderCapabilities(value: unknown): GatewayProviderCapability[]
return capabilities.length > 0 ? capabilities : undefined;
}
// Local-agent login imports (e.g. Codex API) declare their protocol at the top
// level of the provider payload instead of inside a capabilities array. When no
// explicit capabilities are configured, translate that protocol into a single
// capability so the gateway picks the correct upstream adapter. Without this,
// an openai_responses provider silently falls back to the chat-completions
// adapter and every request 404s against Responses-only backends.
function parseProviderProtocolCapability(item: Record<string, unknown>): GatewayProviderCapability[] | undefined {
const type = parseProviderCapabilityProtocol(readString(item.protocol));
const baseUrl = readString(item.baseUrl) || readString(item.baseurl) || readString(item.api_base_url);
if (!type || !baseUrl) {
return undefined;
}
return [{ baseUrl, type }];
}
function parseProviderCapabilityProtocol(value: string | undefined): GatewayProviderCapabilityProtocol | undefined {
if (!value) {
return undefined;
+19 -2
View File
@@ -237,7 +237,7 @@ export type ProviderCredentialConfig = {
};
export type ProviderAccountAuthMode = "provider-api-key" | "provider-api-key-raw" | "none";
export type ProviderAccountConnectorSource = "standard" | "http-json" | "plugin" | "local-estimate" | "merged" | "unsupported";
export type ProviderAccountConnectorSource = "standard" | "http-json" | "webcontent-json" | "plugin" | "local-estimate" | "merged" | "unsupported";
export type ProviderAccountStatus = "ok" | "warning" | "critical" | "error" | "unsupported";
export type ProviderAccountMeterKind = "balance" | "subscription" | "quota" | "time_window" | "tokens" | "requests";
export type ProviderAccountMeterUnit = "USD" | "CNY" | "hours" | "minutes" | "tokens" | "requests" | string;
@@ -253,6 +253,7 @@ export type ProviderAccountConfig = {
export type ProviderAccountConnectorConfig =
| ProviderAccountStandardConnectorConfig
| ProviderAccountHttpJsonConnectorConfig
| ProviderAccountWebContentJsonConnectorConfig
| ProviderAccountPluginConnectorConfig
| ProviderAccountLocalEstimateConnectorConfig;
@@ -280,6 +281,22 @@ export type ProviderAccountHttpJsonConnectorConfig = ProviderAccountConnectorBas
type: "http-json";
};
export type ProviderAccountWebContentJsonConnectorConfig = ProviderAccountConnectorBaseConfig & {
body?: unknown;
browser?: {
loginUrl?: string;
partition?: "built-in-browser";
requestOrigin?: string;
timeoutMs?: number;
};
endpoint: string;
headers?: Record<string, string>;
mapping: ProviderAccountMappingConfig;
method?: "GET" | "POST";
parser?: ProviderAccountHttpJsonParser;
type: "webcontent-json";
};
export type ProviderAccountPluginConnectorConfig = ProviderAccountConnectorBaseConfig & {
connectorId: string;
options?: unknown;
@@ -456,7 +473,7 @@ export type ProviderCatalogModelsResult = {
export type ProviderAccountTestRequest = {
apiKey?: string;
baseUrl: string;
connector: ProviderAccountHttpJsonConnectorConfig;
connector: ProviderAccountHttpJsonConnectorConfig | ProviderAccountWebContentJsonConnectorConfig;
providerName?: string;
};
@@ -1,3 +1,4 @@
import { timingSafeEqual } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { ApiKeyConfig, AppConfig } from "@ccr/core/contracts/app";
import { loadPersistedApiKeys } from "@ccr/core/config/config-repository";
@@ -24,10 +25,10 @@ export async function authorize(
}
const token = readAuthToken(request.headers) || readRemoteControlQueryAuthToken(request);
let apiKey = token ? apiKeys.find((item) => item.key === token) : undefined;
let apiKey = token ? findApiKeyByToken(apiKeys, token) : undefined;
if (!apiKey && token) {
apiKeys = await configuredApiKeys(config, { refresh: true });
apiKey = apiKeys.find((item) => item.key === token);
apiKey = findApiKeyByToken(apiKeys, token);
}
if (apiKey) {
if (isApiKeyExpired(apiKey)) {
@@ -121,6 +122,16 @@ async function loadPersistedApiKeysCached(options: { refresh?: boolean } = {}):
}
}
function findApiKeyByToken(apiKeys: ApiKeyConfig[], token: string): ApiKeyConfig | undefined {
return apiKeys.find((item) => constantTimeEqual(item.key, token));
}
function constantTimeEqual(left: string, right: string): boolean {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
}
function isApiKeyExpired(apiKey: ApiKeyConfig): boolean {
if (!apiKey.expiresAt) return false;
const expiresAt = Date.parse(apiKey.expiresAt);
@@ -13,14 +13,16 @@ import { mediaToolsMcpServer } from "@ccr/core/mcp/grok-media-config";
import { resolveGatewayPublicModelId } from "@ccr/core/gateway/features/model-discovery";
import { activeProviderCredentials, inferProtocol, normalizedProviderCapabilities, normalizeProviderProtocol, providerCapabilityForClientProtocol, providerCapabilityInternalName, providerCapabilityNameMatches, providerCredentialInternalName, providerProtocolForClientProtocol, sortProviderCredentialsForConfig, toCoreGatewayProviders } from "@ccr/core/providers/runtime-topology";
import { buildRawTraceConfig } from "@ccr/core/observability/raw-trace-sync";
import { endpoint, resolveUndiciProxyAgentModule, resolveUpstreamHeaderSanitizerEntry, writeGatewayProxyPreloadFile } from "@ccr/core/gateway/core-runtime/supervisor";
import { endpoint, resolveLocalAgentAuthProviderHookEntry, resolveUndiciProxyAgentModule, resolveUpstreamHeaderSanitizerEntry, writeGatewayProxyPreloadFile } from "@ccr/core/gateway/core-runtime/supervisor";
import { billingUsageSyncHeader, billingUsageSyncPath, claudeCodeOauthBetaHeader, claudeCodeOauthRequiredBeta, coreGatewayAuthHeader, coreGatewayAuthTokenEnv } from "@ccr/core/gateway/internal/shared";
import type { BrowserWebSearchMcpIntegration, CoreGatewayProvider } from "@ccr/core/gateway/internal/shared";
import { uniqueStrings } from "@ccr/core/gateway/internal/collections";
import { isLocalClaudeCodeOauthProviderPlugin, mergeAnthropicBetaValues } from "@ccr/core/providers/oauth-plugin";
import { isLocalAgentOauthProviderPlugin } from "@ccr/core/gateway/core-runtime/local-agent-auth-provider-hook";
import { resolveConfiguredProviderModelSelector, resolveUniqueConfiguredProviderModelSelector } from "@ccr/core/routing/model-resolution";
const upstreamHeaderSanitizerPluginKey = "ccr-upstream-header-sanitizer";
const localAgentAuthProviderHookPluginKey = "ccr-local-agent-auth-provider-hooks";
export const unlimitedVirtualModelToolCalls = Number.MAX_SAFE_INTEGER;
export const unlimitedVirtualModelToolTurns = Number.MAX_SAFE_INTEGER;
@@ -36,7 +38,8 @@ export async function compileCoreGatewayConfig(
const pluginCoreGatewayConfig = pluginService.getCoreGatewayConfig();
const configuredGatewayPlugins = Array.isArray(pluginCoreGatewayConfig.plugins)
? pluginCoreGatewayConfig.plugins.filter((plugin) =>
!isRecord(plugin) || stringValue(plugin.key) !== upstreamHeaderSanitizerPluginKey
!isRecord(plugin) ||
![localAgentAuthProviderHookPluginKey, upstreamHeaderSanitizerPluginKey].includes(stringValue(plugin.key) ?? "")
)
: [];
const pluginBillingConfig = isRecord(pluginCoreGatewayConfig.billing) ? pluginCoreGatewayConfig.billing : {};
@@ -82,6 +85,7 @@ export async function compileCoreGatewayConfig(
.filter((provider): provider is CoreGatewayProvider => Boolean(provider)),
...builtinToolArtifacts.providers
];
const localAgentAuthProviderHookPlugin = localAgentAuthProviderHookPluginConfig(providerPluginsWithCapabilityAliases);
const pluginAgentConfig = isRecord(pluginCoreGatewayConfig.agent) ? pluginCoreGatewayConfig.agent : {};
const pluginMcpServers = Array.isArray(pluginAgentConfig.mcpServers) ? pluginAgentConfig.mcpServers : [];
const externalMcpServers = [
@@ -138,6 +142,7 @@ export async function compileCoreGatewayConfig(
port: config.gateway.corePort,
plugins: [
...configuredGatewayPlugins,
...(localAgentAuthProviderHookPlugin ? [localAgentAuthProviderHookPlugin] : []),
{
enabled: true,
key: upstreamHeaderSanitizerPluginKey,
@@ -156,6 +161,17 @@ export async function compileCoreGatewayConfig(
};
}
function localAgentAuthProviderHookPluginConfig(providerPlugins: unknown[]): Record<string, unknown> | undefined {
if (!providerPlugins.some(isLocalAgentOauthProviderPlugin)) {
return undefined;
}
return {
enabled: true,
key: localAgentAuthProviderHookPluginKey,
modulePath: resolveLocalAgentAuthProviderHookEntry()
};
}
function withProviderCapabilityPluginAliases(
providerPlugins: unknown[],
providers: GatewayProviderConfig[]
@@ -448,9 +464,9 @@ function withCodexOauthRuntimeDefaults(providerPlugins: unknown[]): unknown[] {
const codexOauth = plugin.codexOauth;
const nextCodexOauth = {
...codexOauth,
...(!hasOwn(codexOauth, "accountId") && !hasOwn(codexOauth, "account_id") && codexAuth?.accountId
? { accountId: codexAuth.accountId }
: {})
...(codexAuth?.accessToken ? { accessToken: codexAuth.accessToken } : {}),
...(codexAuth?.refreshToken ? { refreshToken: codexAuth.refreshToken } : {}),
...(codexAuth?.accountId ? { accountId: codexAuth.accountId } : {})
};
const nextPlugin: Record<string, unknown> = {
...plugin,
@@ -518,6 +534,7 @@ async function withGrokOauthRuntimeDefaults(providerPlugins: unknown[]): Promise
const currentHeaders = isRecord(currentAuth.headers) ? currentAuth.headers : {};
const currentRequest = isRecord(plugin.request) ? plugin.request : {};
const currentRequestHeaders = isRecord(currentRequest.headers) ? currentRequest.headers : {};
const currentBodyRemove = Array.isArray(currentRequest.bodyRemove) ? currentRequest.bodyRemove : [];
return {
...plugin,
auth: {
@@ -529,6 +546,12 @@ async function withGrokOauthRuntimeDefaults(providerPlugins: unknown[]): Promise
},
request: {
...currentRequest,
bodyRemove: uniqueStrings([
...currentBodyRemove
.map((value) => stringValue(value))
.filter((value): value is string => Boolean(value)),
"external_web_access"
]),
headers: {
...currentRequestHeaders,
"x-grok-client-identifier": "xai-grok-cli",
@@ -787,8 +810,3 @@ function addProviderNameVariants(names: Set<string>, providerName: string | unde
names.add(providerName.slice(0, capabilitySeparatorIndex));
}
}
function hasOwn(value: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(value, key);
}
@@ -0,0 +1,438 @@
import { readClaudeCodeOauth, readGrokAuth, readKimiAuth, resolveGrokAuth, resolveKimiAuth } from "@ccr/core/agents/local-providers/service";
import { grokAccessTokenExpired, grokClientVersion } from "@ccr/core/agents/local-providers/grok";
import { kimiAccessTokenExpired, kimiIdentityHeaders } from "@ccr/core/agents/local-providers/kimi";
import { transformCodexApplyPatchBridgeRequestBody } from "@ccr/core/gateway/features/codex-patch-bridge";
import { claudeCodeOauthBetaHeader, claudeCodeOauthRequiredBeta } from "@ccr/core/gateway/internal/shared";
import { isRecord, stringValue } from "@ccr/core/gateway/internal/value";
import { mergeAnthropicBetaValues } from "@ccr/core/providers/oauth-plugin";
const configProviderPluginKeyPrefix = "config:";
const localAgentProviderPluginKeyPrefix = "ccr-local-agent-";
type HeaderRecord = Record<string, string>;
type UpstreamRequest = {
body?: unknown;
bodyEncoding?: "bytes" | "form" | "json" | "none" | "text";
headers?: HeaderRecord;
method?: string;
url: string;
};
type ProviderPluginInput = {
model?: string;
request?: {
headers?: Record<string, string | string[] | undefined>;
};
upstreamRequest: UpstreamRequest;
};
type ProviderHookResult = {
ok: true;
value: UpstreamRequest;
} | {
error: string;
ok: false;
};
type ProviderHook = {
authenticate?: (input: ProviderPluginInput) => Promise<ProviderHookResult> | ProviderHookResult;
key: string;
provider?: string;
providerName?: string;
transformRequest?: (input: ProviderPluginInput) => Promise<ProviderHookResult> | ProviderHookResult;
};
type LocalAgentOauthKind = "claude-code" | "grok" | "kimi";
export function createGatewayPlugin(input: { config?: Record<string, unknown> } = {}) {
return {
providerHooks: localAgentOauthProviderHooks(input.config)
};
}
export function localAgentOauthProviderHooks(config: Record<string, unknown> | undefined): ProviderHook[] {
const providerPlugins = Array.isArray(config?.providerPlugins) ? config.providerPlugins : [];
return providerPlugins
.map(localAgentOauthProviderHook)
.filter((hook): hook is ProviderHook => Boolean(hook));
}
export function isLocalAgentOauthProviderPlugin(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
return localAgentOauthKind(value) !== undefined;
}
function localAgentOauthProviderHook(plugin: unknown): ProviderHook | undefined {
if (!isRecord(plugin)) {
return undefined;
}
const kind = localAgentOauthKind(plugin);
const key = stringValue(plugin.key);
if (!kind || !key) {
return undefined;
}
const hook: ProviderHook = {
key: `${configProviderPluginKeyPrefix}${key}`,
provider: stringValue(plugin.provider),
providerName: stringValue(plugin.providerName)
};
if (kind === "grok") {
hook.authenticate = (input) => authenticateWithBearer(input, () => resolveLiveGrokAccessToken(plugin), plugin, "Grok CLI access token was not found.");
hook.transformRequest = (input) => transformGrokRequest(input, plugin);
return hook;
}
if (kind === "kimi") {
hook.authenticate = (input) => authenticateWithBearer(input, () => resolveLiveKimiAccessToken(plugin), plugin, "Kimi CLI access token was not found.");
hook.transformRequest = (input) => transformWithHeaders(input, kimiIdentityHeaders());
return hook;
}
hook.authenticate = (input) => authenticateClaudeCode(input, plugin);
return hook;
}
function localAgentOauthKind(plugin: Record<string, unknown>): LocalAgentOauthKind | undefined {
const key = stringValue(plugin.key)?.toLowerCase();
if (!key?.startsWith(localAgentProviderPluginKeyPrefix)) {
return undefined;
}
if (key.includes("grok-cli-oauth")) {
return "grok";
}
if (key.includes("kimi-cli-oauth")) {
return "kimi";
}
if (key.includes("claude-code-oauth")) {
return "claude-code";
}
return undefined;
}
async function authenticateWithBearer(
input: ProviderPluginInput,
tokenResolver: () => Promise<string | undefined> | string | undefined,
plugin: Record<string, unknown>,
missingTokenError: string
): Promise<ProviderHookResult> {
const token = await tokenResolver() || originalBearerToken(plugin);
if (!token) {
return { error: missingTokenError, ok: false };
}
return {
ok: true,
value: {
...input.upstreamRequest,
headers: withBearerAuth(input.upstreamRequest.headers, token, originalRemoveHeaders(plugin))
}
};
}
async function authenticateClaudeCode(
input: ProviderPluginInput,
plugin: Record<string, unknown>
): Promise<ProviderHookResult> {
const token = readClaudeCodeOauth()?.accessToken || originalBearerToken(plugin);
if (!token) {
return { error: "Claude Code access token was not found.", ok: false };
}
const headers = withBearerAuth(input.upstreamRequest.headers, token, originalRemoveHeaders(plugin));
headers[claudeCodeOauthBetaHeader] = mergeAnthropicBetaValues(
requestHeader(input.request?.headers, claudeCodeOauthBetaHeader),
originalAnthropicBetaDefault(plugin),
claudeCodeOauthRequiredBeta
);
return {
ok: true,
value: {
...input.upstreamRequest,
headers
}
};
}
async function resolveLiveGrokAccessToken(plugin: Record<string, unknown>): Promise<string | undefined> {
const auth = await resolveGrokAuth().catch(() => readGrokAuth());
if (auth?.accessToken && !grokAccessTokenExpired(auth)) {
return auth.accessToken;
}
return originalBearerToken(plugin);
}
async function resolveLiveKimiAccessToken(plugin: Record<string, unknown>): Promise<string | undefined> {
const reference = kimiOauthReference(plugin);
const auth = await resolveKimiAuth(reference).catch(() => readKimiAuth(reference));
if (auth?.accessToken && !kimiAccessTokenExpired(auth)) {
return auth.accessToken;
}
return originalBearerToken(plugin);
}
function transformWithHeaders(input: ProviderPluginInput, headers: HeaderRecord): ProviderHookResult {
return {
ok: true,
value: {
...input.upstreamRequest,
headers: {
...(input.upstreamRequest.headers ?? {}),
...headers
}
}
};
}
function transformGrokRequest(input: ProviderPluginInput, plugin: Record<string, unknown>): ProviderHookResult {
const headers = {
...(input.upstreamRequest.headers ?? {}),
"x-grok-client-identifier": "xai-grok-cli",
"x-grok-client-version": grokClientVersion(),
"x-grok-model-override": input.model || originalRequestHeader(plugin, "x-grok-model-override") || ""
};
const body = transformGrokResponsesBody(input.upstreamRequest.body);
return {
ok: true,
value: {
...input.upstreamRequest,
body: body.changed ? body.value : input.upstreamRequest.body,
headers
}
};
}
function transformGrokResponsesBody(value: unknown): { value: unknown; changed: boolean } {
if (!isRecord(value)) {
return { value, changed: false };
}
const patched = transformCodexApplyPatchBridgeRequestBody(value);
const sanitizedOptions = sanitizeGrokUnsupportedResponsesOptions(patched.body);
const sanitized = sanitizeGrokResponsesTools(sanitizedOptions.value);
return {
value: sanitized.value,
changed: patched.changed || sanitizedOptions.changed || sanitized.changed
};
}
function sanitizeGrokUnsupportedResponsesOptions(body: Record<string, unknown>): {
value: Record<string, unknown>;
changed: boolean;
} {
let next: Record<string, unknown> | undefined;
for (const key of grokUnsupportedResponsesOptions) {
if (Object.hasOwn(body, key)) {
next ??= { ...body };
delete next[key];
}
}
return next ? { value: next, changed: true } : { value: body, changed: false };
}
function sanitizeGrokResponsesTools(body: Record<string, unknown>): { value: Record<string, unknown>; changed: boolean } {
if (!Array.isArray(body.tools)) {
return { value: body, changed: false };
}
let changed = false;
const removedToolNames = new Set<string>();
const tools = body.tools.filter((tool) => {
const supported = isGrokSupportedResponsesTool(tool);
if (!supported) {
changed = true;
const name = responseToolName(tool);
if (name) {
removedToolNames.add(name);
}
}
return supported;
});
if (!changed) {
return { value: body, changed: false };
}
const next = { ...body };
if (tools.length > 0) {
next.tools = tools;
} else {
delete next.tools;
delete next.parallel_tool_calls;
}
if (tools.length === 0 || grokToolChoiceNamesRemovedTool(next.tool_choice, removedToolNames)) {
delete next.tool_choice;
}
return { value: next, changed: true };
}
function isGrokSupportedResponsesTool(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
return grokSupportedResponsesToolTypes.has(stringValue(value.type) ?? "");
}
function responseToolName(value: unknown): string | undefined {
if (!isRecord(value)) {
return undefined;
}
return stringValue(value.name) || stringValue(value.server_label);
}
function grokToolChoiceNamesRemovedTool(value: unknown, removedToolNames: Set<string>): boolean {
if (removedToolNames.size === 0) {
return false;
}
if (!isRecord(value)) {
return false;
}
const name = stringValue(value.name) || stringValue(value.server_label);
return name ? removedToolNames.has(name) : false;
}
function withBearerAuth(
headers: HeaderRecord | undefined,
token: string,
removeHeaders: string[]
): HeaderRecord {
const next = { ...(headers ?? {}) };
for (const name of removeHeaders) {
deleteHeader(next, name);
}
setHeader(next, "authorization", `Bearer ${token}`);
return next;
}
function originalRemoveHeaders(plugin: Record<string, unknown>): string[] {
const auth = isRecord(plugin.auth) ? plugin.auth : undefined;
const removeHeaders = Array.isArray(auth?.removeHeaders) ? auth.removeHeaders : [];
return uniqueStrings([
...removeHeaders
.map((value) => stringValue(value))
.filter((value): value is string => Boolean(value)),
"x-api-key"
]);
}
function originalBearerToken(plugin: Record<string, unknown>): string | undefined {
const authorization = originalAuthHeader(plugin, "authorization");
const prefix = "Bearer ";
return authorization?.startsWith(prefix) ? authorization.slice(prefix.length).trim() || undefined : undefined;
}
function originalAuthHeader(plugin: Record<string, unknown>, name: string): string | undefined {
const auth = isRecord(plugin.auth) ? plugin.auth : undefined;
const headers = isRecord(auth?.headers) ? auth.headers : undefined;
return recordHeader(headers, name);
}
function originalRequestHeader(plugin: Record<string, unknown>, name: string): string | undefined {
const request = isRecord(plugin.request) ? plugin.request : undefined;
const headers = isRecord(request?.headers) ? request.headers : undefined;
return recordHeader(headers, name);
}
function originalAnthropicBetaDefault(plugin: Record<string, unknown>): string | undefined {
const value = originalAuthHeaderValue(plugin, claudeCodeOauthBetaHeader);
if (typeof value === "string") {
return value;
}
if (isRecord(value)) {
return stringValue(value.default);
}
return undefined;
}
function originalAuthHeaderValue(plugin: Record<string, unknown>, name: string): unknown {
const auth = isRecord(plugin.auth) ? plugin.auth : undefined;
const headers = isRecord(auth?.headers) ? auth.headers : undefined;
if (!headers) {
return undefined;
}
const normalizedName = name.toLowerCase();
return Object.entries(headers).find(([key]) => key.trim().toLowerCase() === normalizedName)?.[1];
}
function recordHeader(headers: Record<string, unknown> | undefined, name: string): string | undefined {
const value = recordHeaderValue(headers, name);
return typeof value === "string" ? value : undefined;
}
function recordHeaderValue(headers: Record<string, unknown> | undefined, name: string): unknown {
if (!headers) {
return undefined;
}
const normalizedName = name.toLowerCase();
return Object.entries(headers).find(([key]) => key.trim().toLowerCase() === normalizedName)?.[1];
}
function requestHeader(
headers: Record<string, string | string[] | undefined> | undefined,
name: string
): string | undefined {
if (!headers) {
return undefined;
}
const normalizedName = name.toLowerCase();
const value = Object.entries(headers).find(([key]) => key.trim().toLowerCase() === normalizedName)?.[1];
if (Array.isArray(value)) {
return value.join(",");
}
return value;
}
function setHeader(headers: HeaderRecord, name: string, value: string): void {
deleteHeader(headers, name);
headers[name] = value;
}
function deleteHeader(headers: HeaderRecord, name: string): void {
const normalizedName = name.toLowerCase();
for (const key of Object.keys(headers)) {
if (key.trim().toLowerCase() === normalizedName) {
delete headers[key];
}
}
}
function kimiOauthReference(plugin: Record<string, unknown>): { key?: string; oauthHost?: string } | undefined {
const oauth = isRecord(plugin.kimiOauth) ? plugin.kimiOauth : undefined;
if (!oauth) {
return undefined;
}
const key = stringValue(oauth.key);
const oauthHost = stringValue(oauth.oauthHost) || stringValue(oauth.oauth_host);
return key || oauthHost ? { key, oauthHost } : undefined;
}
const grokSupportedResponsesToolTypes = new Set([
"function",
"x_search",
"image_generation",
"collections_search",
"file_search",
"code_execution",
"code_interpreter",
"mcp",
"shell"
]);
const grokUnsupportedResponsesOptions = new Set([
"external_web_access"
]);
function uniqueStrings(values: string[]): string[] {
const seen = new Set<string>();
const unique: string[] = [];
for (const value of values) {
const key = value.trim().toLowerCase();
if (!key || seen.has(key)) {
continue;
}
seen.add(key);
unique.push(value);
}
return unique;
}
@@ -5,7 +5,7 @@ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { randomBytes } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { networkInterfaces } from "node:os";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { delimiter as pathDelimiter, join as pathJoin, resolve as pathResolve } from "node:path";
import { CONFIGDIR } from "@ccr/core/config/constants";
import {
@@ -46,11 +46,11 @@ export function spawnGatewayProcess(
coreAuthToken: string
): SpawnedGatewayProcess {
const gatewayEntry = resolveGatewayEntry();
const proxyPreloadFile = upstreamProxyUrl ? writeGatewayProxyPreloadFile() : undefined;
const fetchPreloadFile = writeGatewayFetchPreloadFile();
const nodeRuntime = resolveGatewayNodeRuntime();
const env = createGatewayProcessEnv(config, upstreamProxyUrl, runtimeId, coreAuthToken, nodeRuntime.electronRunAsNode);
const gatewayBootstrapEntry = resolveGatewayBootstrapEntry();
const args = proxyPreloadFile ? ["--require", proxyPreloadFile, gatewayBootstrapEntry] : [gatewayBootstrapEntry];
const args = ["--require", fetchPreloadFile, gatewayBootstrapEntry];
const child = spawn(nodeRuntime.command, args, {
cwd: CONFIGDIR,
env,
@@ -184,6 +184,20 @@ export function resolveUpstreamHeaderSanitizerEntry(): string {
].find((candidate) => existsSync(candidate)) ?? pathJoin(__dirname, "upstream-header-sanitizer.js");
}
export function resolveLocalAgentAuthProviderHookEntry(): string {
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
return [
pathJoin(__dirname, "local-agent-auth-provider-hook.js"),
pathJoin(process.cwd(), ".test-dist", "core", "runtime", "local-agent-auth-provider-hook.js"),
...(resourcesPath
? [
pathJoin(resourcesPath, "app.asar", "dist", "main", "local-agent-auth-provider-hook.js"),
pathJoin(resourcesPath, "app", "dist", "main", "local-agent-auth-provider-hook.js")
]
: [])
].find((candidate) => existsSync(candidate)) ?? pathJoin(__dirname, "local-agent-auth-provider-hook.js");
}
function resolveGatewayEntry(): string {
const override = process.env[gatewayEntryOverrideEnv]?.trim();
if (override) {
@@ -264,6 +278,8 @@ function createGatewayProcessEnv(
AUTH_STATIC_API_KEY_ENV: coreGatewayAuthTokenEnv,
AUTH_STATIC_API_KEY_HEADER: coreGatewayAuthHeader,
CCR_GATEWAY_RUNTIME_ID: runtimeId,
CCR_UNDICI_MODULE: resolveUndiciProxyAgentModule(),
CCR_UPSTREAM_TIMEOUT_MS: String(gatewayUpstreamTimeoutMs(config)),
[coreGatewayAuthTokenEnv]: coreAuthToken,
HOST: config.gateway.coreHost,
PORT: String(config.gateway.corePort)
@@ -302,10 +318,14 @@ function createGatewayProcessEnv(
env.https_proxy = upstreamProxyUrl;
env.all_proxy = upstreamProxyUrl;
env.CCR_UPSTREAM_PROXY_URL = upstreamProxyUrl;
env.CCR_UNDICI_MODULE = resolveUndiciProxyAgentModule();
return env;
}
function gatewayUpstreamTimeoutMs(config: AppConfig): number {
const value = Number(config.API_TIMEOUT_MS);
return Number.isFinite(value) && value >= 0 ? Math.trunc(value) : 0;
}
function resolveGatewayNodeRuntime(): GatewayNodeRuntime {
const candidates = uniqueGatewayNodeRuntimeCandidates([
...configuredGatewayNodeRuntimeCandidates(),
@@ -419,58 +439,77 @@ function appendGatewayChildOutput(child: ChildProcess, message: string): string
return details ? `${message}\n${details}` : message;
}
export function writeGatewayProxyPreloadFile(): string {
export function writeGatewayFetchPreloadFile(): string {
const file = pathJoin(CONFIGDIR, "gateway-proxy-preload.cjs");
writeFileSync(
file,
[
"\"use strict\";",
"const up = process.env.CCR_UPSTREAM_PROXY_URL;",
"const um = process.env.CCR_UNDICI_MODULE;",
"if (up && um) {",
" const { ProxyAgent } = require(um);",
" const agent = new ProxyAgent(up);",
" const realFetch = globalThis.fetch.bind(globalThis);",
" const raw = (process.env.NO_PROXY || process.env.no_proxy || '').toLowerCase();",
" const byp = raw.split(',').map((s) => s.trim()).filter(Boolean);",
" const norm = (h) => h.replace(/^\\[/, '').replace(/\\]$/, '').replace(/\\.$/, '');",
" const isLP = (h) => h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '0:0:0:0:0:0:0:1' || h === '0.0.0.0' || h.startsWith('127.');",
" const shouldBypass = (input) => {",
" let h;",
" try {",
" const u = typeof input === 'string' ? new URL(input) : input instanceof URL ? input : new URL(input && input.url ? input.url : String(input));",
" h = norm(u.hostname);",
" } catch { return true; }",
" if (!h) return false;",
" if (isLP(h)) return true;",
" return byp.some((p) => {",
" if (p === '*') return true;",
" const s = p.split(':');",
" const ph = norm(s[0]);",
" if (s.length === 2 && s[1]) {",
" if (h !== ph) return false;",
" try { return new URL(input).port === s[1]; } catch { return false; }",
" }",
" if (ph.startsWith('*.')) return h.endsWith(ph.slice(1));",
" if (ph.startsWith('.')) return h.endsWith(ph) || h === ph.slice(1);",
" return h === ph;",
" });",
" };",
" const patched = function(input, init) {",
" if (init && init.dispatcher) return realFetch(input, init);",
" if (shouldBypass(input)) return realFetch(input, init);",
" return realFetch(input, Object.assign({}, init, { dispatcher: agent }));",
" };",
" if (Object.getOwnPropertyDescriptor(globalThis, 'fetch')?.writable) {",
" globalThis.fetch = patched;",
" }",
"}"
].join("\n"),
"utf8"
);
mkdirSync(CONFIGDIR, { recursive: true });
writeFileSync(file, gatewayFetchPreloadScript(), "utf8");
return file;
}
export function writeGatewayProxyPreloadFile(): string {
return writeGatewayFetchPreloadFile();
}
function gatewayFetchPreloadScript(): string {
return [
"\"use strict\";",
"const up = process.env.CCR_UPSTREAM_PROXY_URL;",
"const um = process.env.CCR_UNDICI_MODULE;",
"const rawTimeout = process.env.CCR_UPSTREAM_TIMEOUT_MS;",
"const parsedTimeout = rawTimeout === undefined || rawTimeout === '' ? NaN : Number(rawTimeout);",
"const hasTimeout = Number.isFinite(parsedTimeout) && parsedTimeout >= 0;",
"if ((up || hasTimeout) && um) {",
" const { Agent, ProxyAgent } = require(um);",
" const timeoutOptions = hasTimeout ? { headersTimeout: Math.trunc(parsedTimeout), bodyTimeout: Math.trunc(parsedTimeout) } : {};",
" const directAgent = hasTimeout ? new Agent(timeoutOptions) : undefined;",
" const proxyAgent = up ? new ProxyAgent(Object.assign({ uri: up }, timeoutOptions)) : undefined;",
" const realFetch = globalThis.fetch.bind(globalThis);",
" const raw = (process.env.NO_PROXY || process.env.no_proxy || '').toLowerCase();",
" const byp = raw.split(',').map((s) => s.trim()).filter(Boolean);",
" const norm = (h) => h.replace(/^\\[/, '').replace(/\\]$/, '').replace(/\\.$/, '');",
" const isLP = (h) => h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '0:0:0:0:0:0:0:1' || h === '0.0.0.0' || h.startsWith('127.');",
" const requestUrl = (input) => {",
" try {",
" return typeof input === 'string' ? new URL(input) : input instanceof URL ? input : new URL(input && input.url ? input.url : String(input));",
" } catch { return undefined; }",
" };",
" const shouldBypass = (input) => {",
" const u = requestUrl(input);",
" if (!u) return true;",
" const h = norm(u.hostname);",
" if (!h) return false;",
" if (isLP(h)) return true;",
" return byp.some((p) => {",
" if (p === '*') return true;",
" const s = p.split(':');",
" const ph = norm(s[0]);",
" if (s.length === 2 && s[1]) return h === ph && u.port === s[1];",
" if (ph.startsWith('*.')) return h.endsWith(ph.slice(1));",
" if (ph.startsWith('.')) return h.endsWith(ph) || h === ph.slice(1);",
" return h === ph;",
" });",
" };",
" const dispatcherFor = (input) => {",
" if (!proxyAgent) return directAgent;",
" return shouldBypass(input) ? directAgent : proxyAgent;",
" };",
" const patched = function(input, init) {",
" if (init && init.dispatcher) return realFetch(input, init);",
" const dispatcher = dispatcherFor(input);",
" if (!dispatcher) return realFetch(input, init);",
" return realFetch(input, Object.assign({}, init, { dispatcher }));",
" };",
" if (Object.getOwnPropertyDescriptor(globalThis, 'fetch')?.writable) {",
" globalThis.fetch = patched;",
" }",
"}"
].join("\n");
}
export function gatewayFetchPreloadScriptForTest(): string {
return gatewayFetchPreloadScript();
}
function mergeNoProxy(current: string | undefined, values: string[]): string {
const merged = new Set<string>();
for (const value of [...(current || "").split(","), ...values]) {
@@ -0,0 +1,128 @@
import { Readable, Transform } from "node:stream";
import { StringDecoder } from "node:string_decoder";
import type { GatewayProviderProtocol } from "@ccr/core/contracts/app";
import { isRecord, stringValue } from "@ccr/core/gateway/internal/value";
export function shouldRewriteAnthropicMessageStartModel(input: {
contentType: string | undefined;
model: string | undefined;
protocol: GatewayProviderProtocol | undefined;
}): boolean {
return input.protocol === "anthropic_messages" &&
Boolean(input.model?.trim()) &&
Boolean(input.contentType?.toLowerCase().includes("text/event-stream"));
}
export function rewriteAnthropicMessageStartModelStream(
input: Readable,
model: string
): Readable {
const replacementModel = model.trim();
if (!replacementModel) {
return input;
}
const decoder = new StringDecoder("utf8");
let pending = "";
return input.pipe(new Transform({
transform(chunk, _encoding, callback) {
pending += decoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
pending = drainAnthropicSseBlocks(this, pending, replacementModel, false);
callback();
},
flush(callback) {
pending += decoder.end();
drainAnthropicSseBlocks(this, pending, replacementModel, true);
pending = "";
callback();
}
}));
}
function drainAnthropicSseBlocks(
stream: Transform,
text: string,
replacementModel: string,
flush: boolean
): string {
let cursor = 0;
for (const match of text.matchAll(/\r?\n\r?\n/g)) {
const index = match.index ?? 0;
const delimiter = match[0];
const block = text.slice(cursor, index);
cursor = index + delimiter.length;
stream.push(`${rewriteAnthropicSseBlockMessageStartModel(block, replacementModel)}${delimiter}`);
}
const trailing = text.slice(cursor);
if (!flush) {
return trailing;
}
if (trailing) {
stream.push(rewriteAnthropicSseBlockMessageStartModel(trailing, replacementModel));
}
return "";
}
export function rewriteAnthropicSseBlockMessageStartModelForTest(
block: string,
model: string
): string {
return rewriteAnthropicSseBlockMessageStartModel(block, model);
}
function rewriteAnthropicSseBlockMessageStartModel(block: string, replacementModel: string): string {
if (!block.trim()) {
return block;
}
const parsed = parseSseJsonData(block);
if (!isRecord(parsed) || stringValue(parsed.type) !== "message_start" || !isRecord(parsed.message)) {
return block;
}
if (stringValue(parsed.message.model) === replacementModel) {
return block;
}
return replaceSseDataLines(block, JSON.stringify({
...parsed,
message: {
...parsed.message,
model: replacementModel
}
}));
}
function parseSseJsonData(block: string): unknown {
const data = block
.split(/\r?\n/g)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).replace(/^ /, ""))
.join("\n");
if (!data || data === "[DONE]") {
return undefined;
}
try {
return JSON.parse(data) as unknown;
} catch {
return undefined;
}
}
function replaceSseDataLines(block: string, data: string): string {
const newline = block.includes("\r\n") ? "\r\n" : "\n";
const lines = block.split(/\r?\n/g);
const output: string[] = [];
let replaced = false;
for (const line of lines) {
if (!line.startsWith("data:")) {
output.push(line);
continue;
}
if (!replaced) {
output.push(`data: ${data}`);
replaced = true;
}
}
return output.join(newline);
}
@@ -0,0 +1,389 @@
import type { IncomingHttpHeaders } from "node:http";
import { Readable, Transform } from "node:stream";
import type { AppConfig } from "@ccr/core/contracts/app";
import { normalizeRouteSelector } from "@ccr/core/routing/model-registry";
import { isRecord, rawStringValue, stringValue } from "@ccr/core/gateway/internal/value";
import { readHeader } from "@ccr/core/gateway/http/io";
import { parseJsonObjectSafe, serializeJsonBody } from "@ccr/core/gateway/http/body";
import { requestProtocolForPath } from "@ccr/core/routing/protocol-endpoints";
import { resolveUsageModelAttribution } from "@ccr/core/usage/model-attribution";
const multiAgentNamespaceName = "multi_agent_v1";
const multiAgentFunctionPrefix = `${multiAgentNamespaceName}_`;
const multiAgentToolNames = new Set(["close_agent", "resume_agent", "send_input", "spawn_agent", "wait_agent"]);
export function prepareCodexMultiAgentBridgeRequest(input: {
body?: Buffer;
config: AppConfig;
headers: IncomingHttpHeaders;
method: string;
path: string;
routedModel?: string;
}): { body: Buffer; diagnostic: string } | undefined {
if (!codexMultiAgentBridgeEnabled(input.headers, input.method, input.path)) {
return undefined;
}
const parsedBody = parseJsonObjectSafe(input.body);
if (!parsedBody) {
return undefined;
}
const model = input.routedModel || stringValue(parsedBody.model);
if (!codexMultiAgentBridgeModelEligible(model, input.config)) {
return undefined;
}
const transformed = transformCodexMultiAgentBridgeRequestBody(parsedBody);
if (!transformed.changed) {
return undefined;
}
return {
body: serializeJsonBody(transformed.body),
diagnostic: `${model ?? "unknown"}:${transformed.changedParts.join(",")}`
};
}
export function transformCodexMultiAgentBridgeRequestBody(body: Record<string, unknown>): {
body: Record<string, unknown>;
changed: boolean;
changedParts: string[];
} {
const next = { ...body };
const changedParts: string[] = [];
const tools = transformCodexMultiAgentBridgeTools(body.tools);
if (tools.changed) {
next.tools = tools.value;
changedParts.push("tools");
const toolChoice = transformCodexMultiAgentBridgeToolChoice(body.tool_choice);
if (toolChoice.changed) {
if (toolChoice.value === undefined) {
delete next.tool_choice;
} else {
next.tool_choice = toolChoice.value;
}
changedParts.push("tool_choice");
}
const input = transformCodexMultiAgentBridgeInput(body.input);
if (input.changed) {
next.input = input.value;
changedParts.push("input");
}
}
return {
body: next,
changed: changedParts.length > 0,
changedParts
};
}
function transformCodexMultiAgentBridgeTools(value: unknown): { value: unknown; changed: boolean } {
if (!Array.isArray(value)) {
return { value, changed: false };
}
let changed = false;
const tools = value.flatMap((tool) => {
if (!isRecord(tool) || tool.type !== "namespace" || tool.name !== multiAgentNamespaceName) {
return [tool];
}
const namespaceTools = Array.isArray(tool.tools) ? tool.tools : [];
const flattened = namespaceTools
.filter((item) => isRecord(item) && item.type === "function" && multiAgentToolNames.has(stringValue(item.name) ?? ""))
.map((item) => codexMultiAgentFunctionTool(item as Record<string, unknown>));
if (flattened.length === 0) {
return [tool];
}
changed = true;
return flattened;
});
return { value: tools, changed };
}
function codexMultiAgentFunctionTool(tool: Record<string, unknown>): Record<string, unknown> {
const name = stringValue(tool.name) ?? "";
const description = rawStringValue(tool.description) ?? "";
return {
...tool,
name: codexMultiAgentFunctionName(name),
description: description
? `Namespaced ${multiAgentNamespaceName}.${name} tool.\n\n${description}`
: `Namespaced ${multiAgentNamespaceName}.${name} tool.`
};
}
function transformCodexMultiAgentBridgeToolChoice(value: unknown): { value: unknown; changed: boolean } {
const name = toolChoiceName(value);
if (!name) {
return { value, changed: false };
}
if (normalizeMultiAgentToolName(name) === undefined && name !== multiAgentNamespaceName) {
return { value, changed: false };
}
if (name === multiAgentNamespaceName) {
return { value: undefined, changed: true };
}
const mappedName = normalizeMultiAgentToolName(name);
if (!mappedName || mappedName === name) {
return { value, changed: false };
}
if (isRecord(value) && isRecord(value.function)) {
return {
value: {
...value,
function: {
...value.function,
name: mappedName
}
},
changed: true
};
}
if (isRecord(value)) {
return {
value: {
...value,
name: mappedName,
type: value.type === "tool" ? "function" : value.type
},
changed: true
};
}
return { value, changed: false };
}
function toolChoiceName(value: unknown): string | undefined {
if (!isRecord(value)) {
return undefined;
}
return stringValue(value.name) ?? (isRecord(value.function) ? stringValue(value.function.name) : undefined);
}
function transformCodexMultiAgentBridgeInput(value: unknown): { value: unknown; changed: boolean } {
if (!Array.isArray(value)) {
return { value, changed: false };
}
let changed = false;
const items = value.map((item) => {
const transformed = transformCodexMultiAgentBridgeRequestItem(item);
changed ||= transformed.changed;
return transformed.value;
});
return { value: items, changed };
}
function transformCodexMultiAgentBridgeRequestItem(value: unknown): { value: unknown; changed: boolean } {
if (!isRecord(value) || value.type !== "function_call") {
return { value, changed: false };
}
const mappedName = normalizeMultiAgentToolName(stringValue(value.name) ?? "");
if (!mappedName || mappedName === value.name) {
return { value, changed: false };
}
const { namespace: _namespace, ...rest } = value;
return {
value: {
...rest,
name: mappedName
},
changed: true
};
}
function codexMultiAgentBridgeEnabled(headers: IncomingHttpHeaders, method: string, path: string): boolean {
return (method || "GET").toUpperCase() === "POST" &&
requestProtocolForPath(path) === "openai_responses" &&
isCodexUserAgent(headers);
}
function isCodexUserAgent(headers: IncomingHttpHeaders): boolean {
return readHeader(headers["user-agent"])?.toLowerCase().includes("codex") ?? false;
}
function codexMultiAgentBridgeModelEligible(model: string | undefined, config: AppConfig): boolean {
const modelName = modelNameForMultiAgentBridge(model);
if (!modelName || modelName.toLowerCase().includes("gpt")) {
return false;
}
const baseModelName = modelNameForMultiAgentBridge(resolveUsageModelAttribution(config, model).model);
return !baseModelName.toLowerCase().includes("gpt");
}
function modelNameForMultiAgentBridge(model: string | undefined): string {
const normalized = normalizeRouteSelector(model) ?? "";
const slashIndex = normalized.lastIndexOf("/");
return slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized;
}
function codexMultiAgentFunctionName(name: string): string {
return `${multiAgentFunctionPrefix}${name}`;
}
function normalizeMultiAgentToolName(name: string): string | undefined {
const normalized = name.trim();
if (!normalized) {
return undefined;
}
if (normalized.startsWith(multiAgentFunctionPrefix)) {
const inner = normalized.slice(multiAgentFunctionPrefix.length);
return multiAgentToolNames.has(inner) ? codexMultiAgentFunctionName(inner) : undefined;
}
const dottedPrefix = `${multiAgentNamespaceName}.`;
if (normalized.startsWith(dottedPrefix)) {
const inner = normalized.slice(dottedPrefix.length);
return multiAgentToolNames.has(inner) ? codexMultiAgentFunctionName(inner) : undefined;
}
return multiAgentToolNames.has(normalized) ? codexMultiAgentFunctionName(normalized) : undefined;
}
function nativeMultiAgentToolName(name: string): string | undefined {
const normalized = name.trim();
if (!normalized.startsWith(multiAgentFunctionPrefix)) {
return undefined;
}
const inner = normalized.slice(multiAgentFunctionPrefix.length);
return multiAgentToolNames.has(inner) ? inner : undefined;
}
export function codexMultiAgentBridgeResponseStream(input: Readable, headers: Headers): Readable {
const contentType = headers.get("content-type")?.toLowerCase() ?? "";
if (contentType.includes("text/event-stream")) {
return input.pipe(new Transform({
transform(chunk, _encoding, callback) {
transformSseChunk(this, chunk);
callback();
},
flush(callback) {
flushSseTransform(this);
callback();
}
}));
}
if (contentType.includes("application/json")) {
const chunks: Buffer[] = [];
return input.pipe(new Transform({
transform(chunk, _encoding, callback) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
callback();
},
flush(callback) {
const raw = Buffer.concat(chunks).toString("utf8");
try {
const parsed = JSON.parse(raw);
const transformed = transformCodexMultiAgentBridgeResponseValue(parsed);
this.push(Buffer.from(`${JSON.stringify(transformed.value)}\n`, "utf8"));
} catch {
this.push(Buffer.from(raw, "utf8"));
}
callback();
}
}));
}
return input;
}
export function transformCodexMultiAgentBridgeResponseValue(value: unknown): { value: unknown; changed: boolean } {
if (!isRecord(value)) {
return { value, changed: false };
}
let changed = false;
const next = { ...value };
if (isRecord(value.item)) {
const item = transformMultiAgentFunctionCall(value.item);
if (item.changed) {
next.item = item.value;
changed = true;
}
}
if (Array.isArray(value.output)) {
const output = transformCodexMultiAgentBridgeResponseItems(value.output);
if (output.changed) {
next.output = output.value;
changed = true;
}
}
if (isRecord(value.response) && Array.isArray(value.response.output)) {
const output = transformCodexMultiAgentBridgeResponseItems(value.response.output);
if (output.changed) {
next.response = {
...value.response,
output: output.value
};
changed = true;
}
}
const item = transformMultiAgentFunctionCall(next);
if (item.changed) {
return item;
}
return { value: next, changed };
}
function transformCodexMultiAgentBridgeResponseItems(items: unknown[]): { value: unknown[]; changed: boolean } {
let changed = false;
const value = items.map((item) => {
const transformed = isRecord(item)
? transformMultiAgentFunctionCall(item)
: { value: item, changed: false };
changed ||= transformed.changed;
return transformed.value;
});
return { value, changed };
}
function transformMultiAgentFunctionCall(item: Record<string, unknown>): { value: unknown; changed: boolean } {
if (item.type !== "function_call") {
return { value: item, changed: false };
}
const nativeName = nativeMultiAgentToolName(stringValue(item.name) ?? "");
if (!nativeName) {
return { value: item, changed: false };
}
return {
value: {
...item,
name: nativeName,
namespace: multiAgentNamespaceName
},
changed: true
};
}
function transformSseChunk(stream: Transform, chunk: Buffer | string): void {
const state = stream as Transform & { __ccrCodexMultiAgentBridgeSsePending?: string };
state.__ccrCodexMultiAgentBridgeSsePending = (state.__ccrCodexMultiAgentBridgeSsePending ?? "") + chunk.toString();
while (state.__ccrCodexMultiAgentBridgeSsePending) {
const match = /\r?\n\r?\n/.exec(state.__ccrCodexMultiAgentBridgeSsePending);
if (!match || match.index === undefined) {
break;
}
const block = state.__ccrCodexMultiAgentBridgeSsePending.slice(0, match.index);
const delimiter = match[0];
state.__ccrCodexMultiAgentBridgeSsePending = state.__ccrCodexMultiAgentBridgeSsePending.slice(match.index + delimiter.length);
stream.push(transformCodexMultiAgentBridgeSseEvent(block) + delimiter);
}
}
function flushSseTransform(stream: Transform): void {
const state = stream as Transform & { __ccrCodexMultiAgentBridgeSsePending?: string };
if (state.__ccrCodexMultiAgentBridgeSsePending) {
stream.push(transformCodexMultiAgentBridgeSseEvent(state.__ccrCodexMultiAgentBridgeSsePending));
state.__ccrCodexMultiAgentBridgeSsePending = "";
}
}
export function transformCodexMultiAgentBridgeSseEvent(block: string): string {
const lines = block.split(/\r?\n/);
const dataIndex = lines.findIndex((line) => line.startsWith("data: "));
if (dataIndex < 0) {
return block;
}
const raw = lines[dataIndex].slice("data: ".length);
try {
const parsed = JSON.parse(raw);
const transformed = transformCodexMultiAgentBridgeResponseValue(parsed);
if (!transformed.changed) {
return block;
}
lines[dataIndex] = `data: ${JSON.stringify(transformed.value)}`;
return lines.join("\n");
} catch {
return block;
}
}
@@ -2,6 +2,7 @@ import type { AppConfig } from "@ccr/core/contracts/app";
import { isRecord, rawStringValue, stringValue } from "@ccr/core/gateway/internal/value";
import type { AnthropicWebSearchProtocolContext, BrowserWebSearchProtocolRecord, ClaudeCodeWebSearchContinuationContext, HostedWebSearchProtocolContext } from "@ccr/core/gateway/internal/shared";
import { parseJsonObjectSafe, serializeJsonBody } from "@ccr/core/gateway/http/body";
import { resolveGatewayPublicModelId } from "@ccr/core/gateway/features/model-discovery";
import { uniqueStrings } from "@ccr/core/gateway/internal/collections";
import { requestProtocolForPath } from "@ccr/core/routing/protocol-endpoints";
import { claudeCodeWebSearchToolResultTexts, extractAnthropicWebSearchQueryHint, extractClaudeCodeWebSearchToolResultQuery, extractHostedWebSearchQueryHint, fusionWebSearchToolNameForRequest, hasHostedWebSearchDeclaration, isAnthropicHostedWebSearchTool, isOpenAiHostedWebSearchTool, openAiToolChoiceNamesWebSearch, readHostedWebSearchMaxUses } from "@ccr/core/gateway/features/hosted-web-search/discovery";
@@ -24,7 +25,10 @@ export function createHostedWebSearchProtocolContext(input: {
if (!body || !hasHostedWebSearchDeclaration(body, protocol)) {
return undefined;
}
const toolName = fusionWebSearchToolNameForRequest(input.config, stringValue(body.model) || input.routedModel);
const toolName = fusionWebSearchToolNameForRequest(
input.config,
hostedWebSearchModelForRequest(input.config, stringValue(body.model) || input.routedModel)
);
if (!toolName) {
return undefined;
}
@@ -53,7 +57,10 @@ export function createClaudeCodeWebSearchContinuationContext(input: {
if (!body || claudeCodeWebSearchToolResultTexts(body).length === 0) {
return undefined;
}
const toolName = fusionWebSearchToolNameForRequest(input.config, stringValue(body.model) || input.routedModel);
const toolName = fusionWebSearchToolNameForRequest(
input.config,
hostedWebSearchModelForRequest(input.config, stringValue(body.model) || input.routedModel)
);
if (!toolName) {
return undefined;
}
@@ -64,6 +71,11 @@ export function createClaudeCodeWebSearchContinuationContext(input: {
};
}
function hostedWebSearchModelForRequest(config: AppConfig, model: string | undefined): string | undefined {
return resolveGatewayPublicModelId(model, config) ?? model;
}
export function prepareHostedWebSearchProtocolRequestBody(
body: Buffer | undefined,
records: BrowserWebSearchProtocolRecord[],
@@ -15,6 +15,7 @@ import type { ClaudeCodeDiscoverableModel } from "@ccr/core/gateway/internal/sha
import { parseJsonObjectSafe, serializeJsonBodyWithModel } from "@ccr/core/gateway/http/body";
import { uniqueStrings } from "@ccr/core/gateway/internal/collections";
import { contextArchiveConfigForApiKey, contextArchiveMcpEnabled } from "@ccr/core/gateway/context-archive";
import { resolveUsageModelAttribution } from "@ccr/core/usage/model-attribution";
export function shouldServeGatewayModelsResponse(method: string, path: string): boolean {
@@ -128,8 +129,9 @@ function createClaudeAppGatewayModelsResponse(
const routes = buildClaudeAppGatewayModelRoutes(config, claudeAppGatewayModelRouteOptions);
const data = routes.map((route) => {
const catalogId = stripClaudeCodeOneMillionContextSuffix(route.targetModel);
const catalogEntry = findModelCatalogEntry(catalogId);
const modelMetadata = providerModelMetadataForSelector(config, catalogId);
const modelDiscovery = providerModelDiscoveryForSelector(config, catalogId);
const catalogEntry = modelDiscovery.catalogEntry;
const modelMetadata = modelDiscovery.metadata;
const maxInputTokens = claudeGatewayModelContextWindow(catalogEntry, route.oneMillionContext, modelMetadata);
const maxOutputTokens = modelCatalogMaxOutputTokens(catalogEntry);
const exposeOneMillionContextVariant = options.claudeCode && route.oneMillionContext;
@@ -165,8 +167,9 @@ function createClaudeCodeModelsResponse(config: AppConfig, contextArchiveCompact
const data = models.map((model) => {
const claudeId = claudeCodeDiscoveryModelId(model.id);
const catalogId = stripClaudeCodeOneMillionContextSuffix(model.id);
const catalogEntry = findModelCatalogEntry(catalogId);
const modelMetadata = providerModelMetadataForSelector(config, catalogId);
const modelDiscovery = providerModelDiscoveryForSelector(config, catalogId);
const catalogEntry = modelDiscovery.catalogEntry;
const modelMetadata = modelDiscovery.metadata;
const maxInputTokens = claudeGatewayModelContextWindow(catalogEntry, model.oneMillionContext, modelMetadata);
const maxOutputTokens = modelCatalogMaxOutputTokens(catalogEntry);
return {
@@ -226,11 +229,27 @@ function effectiveProviderContextWindow(metadata: ProviderModelMetadata | undefi
}
function providerModelMetadataForSelector(config: AppConfig, selector: string): ProviderModelMetadata | undefined {
const resolved = modelRegistryForConfig(config).resolveProviderModel(selector);
function providerModelDiscoveryForSelector(
config: AppConfig,
selector: string
): { catalogEntry?: ModelCatalogEntry; metadata?: ProviderModelMetadata } {
const resolved = providerModelResolutionForSelector(config, selector);
if (!resolved) {
return undefined;
return {
catalogEntry: findModelCatalogEntry(selector)
};
}
const physicalSelector = `${resolved.provider.name}/${resolved.model}`;
return {
catalogEntry: findModelCatalogEntry(physicalSelector) ?? findModelCatalogEntry(selector),
metadata: providerModelMetadataForResolvedModel(resolved)
};
}
function providerModelMetadataForResolvedModel(
resolved: NonNullable<ReturnType<typeof providerModelResolutionForSelector>>
): ProviderModelMetadata | undefined {
const metadata = resolved.provider.modelMetadata ?? {};
const direct = metadata[resolved.model];
if (direct) {
@@ -241,6 +260,34 @@ function providerModelMetadataForSelector(config: AppConfig, selector: string):
}
function providerModelResolutionForSelector(config: AppConfig, selector: string) {
const registry = modelRegistryForConfig(config);
const direct = registry.resolveProviderModel(selector);
if (direct) {
return direct;
}
const attribution = resolveUsageModelAttribution(config, selector);
if (!attribution.provider || !attribution.model) {
return undefined;
}
const resolved = registry.resolve(`${attribution.provider}/${attribution.model}`);
return resolved?.kind === "provider"
? { model: resolved.model, provider: resolved.provider }
: undefined;
}
function gatewayModelSupportsOneMillionContext(config: AppConfig, selector: string): boolean {
const discovery = providerModelDiscoveryForSelector(config, selector);
const metadataContextWindow = effectiveProviderContextWindow(discovery.metadata);
return Boolean(
(metadataContextWindow && metadataContextWindow >= 1_000_000) ||
discovery.catalogEntry?.limits?.supports1MContext
);
}
function percentage(value: number | undefined): number | undefined {
return value !== undefined && Number.isFinite(value) && value > 0 && value <= 100
? value
@@ -336,7 +383,7 @@ function buildClaudeCodeDiscoverableModels(config: AppConfig): ClaudeCodeDiscove
for (const id of buildClaudeCodeDiscoverableModelIds(config)) {
pushModel(id, hasClaudeCodeOneMillionContextSuffix(id));
const baseId = stripClaudeCodeOneMillionContextSuffix(id);
if (!hasClaudeCodeOneMillionContextSuffix(id) && findModelCatalogEntry(baseId)?.limits?.supports1MContext) {
if (!hasClaudeCodeOneMillionContextSuffix(id) && gatewayModelSupportsOneMillionContext(config, baseId)) {
pushModel(claudeCodeOneMillionContextModelId(baseId), true);
}
}
+20 -2
View File
@@ -1,3 +1,21 @@
export function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
export const DEFAULT_RETRY_AFTER_MS = 1000;
export function delay(ms: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) {
return Promise.resolve();
}
return new Promise((resolve) => {
let timer: ReturnType<typeof setTimeout> | undefined;
const complete = () => {
if (timer !== undefined) {
clearTimeout(timer);
}
signal?.removeEventListener("abort", complete);
resolve();
};
signal?.addEventListener("abort", complete, { once: true });
timer = setTimeout(complete, ms);
});
}
+51 -44
View File
@@ -30,6 +30,8 @@ import { adaptRouteRequestBody, restoreRouteRequestBody } from "@ccr/core/routin
import { reserveApiKeyLimits } from "@ccr/core/gateway/auth/api-key-authorizer";
import { recordProviderCredentialOutcome } from "@ccr/core/providers/credential-pool";
import { codexApplyPatchBridgeResponseStream, prepareCodexApplyPatchBridgeRequest } from "@ccr/core/gateway/features/codex-patch-bridge";
import { codexMultiAgentBridgeResponseStream, prepareCodexMultiAgentBridgeRequest } from "@ccr/core/gateway/features/codex-multi-agent-bridge";
import { rewriteAnthropicMessageStartModelStream, shouldRewriteAnthropicMessageStartModel } from "@ccr/core/gateway/features/anthropic-response-model";
import { prepareCursorOpenAICompatChatBody } from "@ccr/core/gateway/features/cursor-compat";
import { filteredResponseHeaders, formatError, formatUpstreamErrorForLog, forwardHeaders, inferGatewayClient, readRequestBody, sendJson, shouldCaptureGatewayUsage, shouldSendBody, stripLocalGatewayAuthHeaders } from "@ccr/core/gateway/http/io";
import { serializeJsonBody, takeJsonObject } from "@ccr/core/gateway/http/body";
@@ -41,7 +43,7 @@ import { coreGatewayUsageAttributionConfig } from "@ccr/core/gateway/core-runtim
import { providerModelPricingForUsage } from "@ccr/core/models/pricing-service";
import { clientClosedRequestStatusCode, clientDisconnectMessage, resolveStreamRequestLogOutcome, UpstreamRequestError } from "@ccr/core/gateway/internal/shared";
import type { BrowserWebSearchMcpIntegration, BrowserWebSearchProtocolRecord, UpstreamFetchResult } from "@ccr/core/gateway/internal/shared";
import { applyProviderCapabilityRouting, cancelResponseBody, destroyResponseStreams, fetchUpstreamWithFallback, mergeFallbackResponseHeaders, rewriteCapabilityResponseHeaders, uniqueStreams, upstreamResponseHeaders } from "@ccr/core/gateway/upstream/executor";
import { cancelResponseBody, destroyResponseStreams, fetchUpstreamWithFallback, mergeFallbackResponseHeaders, rewriteCapabilityResponseHeaders, uniqueStreams, upstreamResponseHeaders } from "@ccr/core/gateway/upstream/executor";
import { requestProtocolForPath, shouldApplyGatewayRouting } from "@ccr/core/routing/protocol-endpoints";
import { createClaudeCodeWebSearchContinuationContext, createHostedWebSearchProtocolContext, hostedWebSearchProtocolResponseStream, hostedWebSearchUnavailableMessage, prepareClaudeCodeWebSearchContinuationRequestBody, prepareHostedWebSearchProtocolRequestBody, selectClaudeCodeWebSearchContinuationRecords, selectHostedWebSearchProtocolRecords } from "@ccr/core/gateway/features/hosted-web-search/index";
@@ -155,6 +157,7 @@ export class GatewayRequestPipeline {
let routeFallback = this.config.Router.fallback;
let routedModel: string | undefined;
let codexApplyPatchBridgeActive = false;
let codexMultiAgentBridgeActive = false;
const claudeModelRewriteStartedAt = Date.now();
const claudeModelRewrite = prepareClaudeCodeDiscoveredModelRequest(this.config, request.headers, method, path, bodyToForward);
if (claudeModelRewrite) {
@@ -380,42 +383,33 @@ export class GatewayRequestPipeline {
});
}
const capabilityRoutingStartedAt = Date.now();
const capabilityBodyBefore = bodyToForward;
const capabilityFallbackBefore = routeFallback;
const capabilityModelBefore = routedModel;
const capabilityProviderHeadersBefore = {
gateway: headers["x-gateway-target-provider"],
list: headers["x-target-providers"],
target: headers["x-target-provider"]
};
const providerCapabilityRouting = applyProviderCapabilityRouting({
const codexMultiAgentBridgeStartedAt = Date.now();
const codexMultiAgentBridgeRequest = prepareCodexMultiAgentBridgeRequest({
body: bodyToForward,
config: this.config,
fallback: routeFallback,
headers,
headers: request.headers,
method,
path,
routedModel
});
bodyToForward = providerCapabilityRouting.body;
routeFallback = providerCapabilityRouting.fallback;
routedModel = providerCapabilityRouting.routedModel;
routeTrace?.capture({
changes: [
...(capabilityBodyBefore === bodyToForward ? [] : [{ operation: "replace" as const, path: "/body/model", scope: "body" as const }]),
reportedRouteChange("routing", "/routing/model", capabilityModelBefore, routedModel),
...(capabilityFallbackBefore === routeFallback ? [] : [{ after: routeFallback, before: capabilityFallbackBefore, operation: "replace" as const, path: "/routing/fallback", scope: "routing" as const }]),
reportedRouteChange("headers", "/headers/x-target-provider", capabilityProviderHeadersBefore.target, headers["x-target-provider"]),
reportedRouteChange("headers", "/headers/x-target-providers", capabilityProviderHeadersBefore.list, headers["x-target-providers"]),
reportedRouteChange("headers", "/headers/x-gateway-target-provider", capabilityProviderHeadersBefore.gateway, headers["x-gateway-target-provider"])
].filter(isReportedRouteChange),
durationMs: Date.now() - capabilityRoutingStartedAt,
kind: "mutation",
name: "provider.capability-routing",
phase: "capability",
startedAtMs: capabilityRoutingStartedAt,
target: routedModel ? { model: routedModel } : undefined
});
if (codexMultiAgentBridgeRequest) {
bodyToForward = codexMultiAgentBridgeRequest.body;
codexMultiAgentBridgeActive = true;
headers["x-ccr-codex-multi-agent-bridge"] = sanitizeHeaderValue(codexMultiAgentBridgeRequest.diagnostic);
headers["content-type"] = "application/json";
routeTrace?.capture({
changes: [
{ operation: "replace", path: "/body", scope: "body" },
{ after: headers["x-ccr-codex-multi-agent-bridge"], operation: "add", path: "/headers/x-ccr-codex-multi-agent-bridge", scope: "headers" },
{ after: headers["content-type"], operation: "replace", path: "/headers/content-type", scope: "headers" }
],
durationMs: Date.now() - codexMultiAgentBridgeStartedAt,
kind: "mutation",
name: "compatibility.codex-multi-agent",
phase: "compatibility",
startedAtMs: codexMultiAgentBridgeStartedAt
});
}
const hostedWebSearchProtocolContext = createHostedWebSearchProtocolContext({
body: bodyToForward,
@@ -670,6 +664,7 @@ export class GatewayRequestPipeline {
throw error;
}
const clientVisibleResponseModel = requestedModel ?? routedModel;
bodyToForward = upstreamResult.attempt.body ?? bodyToForward;
routedModel = upstreamResult.attempt.model ?? routedModel;
if (contextArchiveToolContinuation && upstreamResult.response.ok) {
@@ -719,9 +714,8 @@ export class GatewayRequestPipeline {
const appendContextArchiveFooter = Boolean(contextArchiveRecord && upstreamResponse.ok);
const transformCodexCompactResponse = Boolean(!contextArchiveRecord && codexCompactCompatResponseMode && upstreamResponse.ok);
const contextArchiveSourceContentType = responseHeaders.get("content-type") ?? undefined;
if (codexApplyPatchBridgeActive || appendContextArchiveFooter || transformCodexCompactResponse) {
responseHeaders.delete("content-length");
}
const responseProtocol = requestProtocolForPath(upstreamPath) ?? requestProtocol;
const archiveResponseProtocol = responseProtocol ?? "anthropic_messages";
if ((appendContextArchiveFooter || transformCodexCompactResponse) && contextArchiveResponseContentType) {
responseHeaders.set("content-type", contextArchiveResponseContentType);
}
@@ -739,6 +733,14 @@ export class GatewayRequestPipeline {
) {
responseHeaders.delete("content-length");
}
const rewriteAnthropicResponseModel = upstreamResponse.ok && shouldRewriteAnthropicMessageStartModel({
contentType: responseHeaders.get("content-type") ?? undefined,
model: clientVisibleResponseModel,
protocol: responseProtocol
});
if (codexApplyPatchBridgeActive || codexMultiAgentBridgeActive || appendContextArchiveFooter || transformCodexCompactResponse || rewriteAnthropicResponseModel) {
responseHeaders.delete("content-length");
}
recordProviderCredentialOutcome(this.config, method, upstreamResult.attempt, upstreamResponse.status, responseHeaders);
if (clientDisconnected || response.destroyed) {
await cancelResponseBody(upstreamResponse);
@@ -771,15 +773,17 @@ export class GatewayRequestPipeline {
const patchedResponseBody = codexApplyPatchBridgeActive
? codexApplyPatchBridgeResponseStream(upstreamBody, responseHeaders)
: upstreamBody;
const multiAgentResponseBody = codexMultiAgentBridgeActive
? codexMultiAgentBridgeResponseStream(patchedResponseBody, responseHeaders)
: patchedResponseBody;
const hostedWebSearchResponseBody = hostedWebSearchProtocolContext
? hostedWebSearchProtocolResponseStream(
patchedResponseBody,
multiAgentResponseBody,
responseHeaders,
hostedWebSearchProtocolContext,
this.browserWebSearchMcpIntegration
)
: patchedResponseBody;
const archiveResponseProtocol = requestProtocolForPath(upstreamPath) ?? requestProtocol ?? "anthropic_messages";
: multiAgentResponseBody;
const responseBody = appendContextArchiveFooter && contextArchiveRecord
? contextArchiveHandoffResponseStream(
hostedWebSearchResponseBody,
@@ -796,7 +800,10 @@ export class GatewayRequestPipeline {
codexCompactCompatResponseMode
)
: hostedWebSearchResponseBody;
const responseStreams = uniqueStreams([upstreamBody, patchedResponseBody, hostedWebSearchResponseBody, responseBody]);
const clientResponseBody = rewriteAnthropicResponseModel && clientVisibleResponseModel
? rewriteAnthropicMessageStartModelStream(responseBody, clientVisibleResponseModel)
: responseBody;
const responseStreams = uniqueStreams([upstreamBody, patchedResponseBody, multiAgentResponseBody, hostedWebSearchResponseBody, responseBody, clientResponseBody]);
const sampler = createBodySampler();
const sseErrorDetector = createSseErrorDetector(responseHeaders.get("content-type") ?? undefined);
let streamDetectedError: string | undefined;
@@ -826,7 +833,7 @@ export class GatewayRequestPipeline {
onClientDisconnect = () => {
streamDetectedError ??= sseErrorDetector.finish();
writeStreamLog();
responseBody.unpipe(response);
clientResponseBody.unpipe(response);
destroyResponseStreams(responseStreams);
};
onResponseFinish = () => {
@@ -852,11 +859,11 @@ export class GatewayRequestPipeline {
for (const stream of responseStreams) {
stream.on("error", onResponseStreamError);
}
responseBody.on("data", (chunk) => {
clientResponseBody.on("data", (chunk) => {
sampler.append(chunk);
streamDetectedError ??= sseErrorDetector.append(chunk);
});
responseBody.once("end", () => {
clientResponseBody.once("end", () => {
upstreamStreamEnded = true;
streamDetectedError ??= sseErrorDetector.finish();
if (responseCompleted || response.writableEnded) {
@@ -864,7 +871,7 @@ export class GatewayRequestPipeline {
}
});
if (shouldCaptureUsage) {
responseBody.once("end", () => {
clientResponseBody.once("end", () => {
recordUsage({
bodyText: sampler.read(),
client,
@@ -884,7 +891,7 @@ export class GatewayRequestPipeline {
onClientDisconnect();
return;
}
responseBody.pipe(response);
clientResponseBody.pipe(response);
}
async replayContextArchive(input: ContextArchiveReplayInput): Promise<ContextArchiveReplayResult> {
+1
View File
@@ -6,6 +6,7 @@
*/
export { gatewayService } from "@ccr/core/gateway/application/gateway-service";
export { prepareCodexApplyPatchBridgeRequest, transformCodexApplyPatchBridgeRequestBody, transformCodexApplyPatchBridgeResponseValue, transformCodexApplyPatchBridgeSseEvent } from "@ccr/core/gateway/features/codex-patch-bridge";
export { prepareCodexMultiAgentBridgeRequest, transformCodexMultiAgentBridgeRequestBody, transformCodexMultiAgentBridgeResponseValue, transformCodexMultiAgentBridgeSseEvent } from "@ccr/core/gateway/features/codex-multi-agent-bridge";
export { appendContextArchiveToolOutputsForTest, contextArchiveFunctionCallsForTest, parseContextArchiveToolResponseBodyForTest, prepareCodexCompactCompatRequest, prepareContextArchiveToolContinuationRequestForTest } from "@ccr/core/gateway/features/context-archive-continuation";
export { normalizeClaudeCodeOauthProviderPlugins, normalizeCoreGatewayVirtualModelProfiles } from "@ccr/core/gateway/core-runtime/config-compiler";
export { fusionBuiltinToolArtifactsForTest, fusionFallbackToolDefinitions, fusionToolNamesBackedByMcpServers } from "@ccr/core/mcp/fusion-config";
+200 -27
View File
@@ -47,9 +47,10 @@ export function applyProviderCapabilityRouting(input: {
rewriteProviderListHeader(input.headers, "x-target-providers", input.config, protocol);
rewriteProviderHeader(input.headers, "x-gateway-target-provider", input.config, protocol);
const routedModel = rewriteModelSelectorForProtocol(input.routedModel, input.config, protocol);
const targetProviderName = firstTargetProviderHeader(input.headers);
const routedModel = rewriteModelSelectorForProtocol(input.routedModel, input.config, protocol, targetProviderName);
const fallback = rewriteFallbackForProtocol(input.fallback, input.config, protocol);
const body = rewriteBodyModelForProtocol(input.body, input.config, protocol);
const body = rewriteBodyModelForProtocol(input.body, input.config, protocol, targetProviderName);
clearTargetProviderHeadersForModelSelector(input.headers, input.config, body, routedModel);
return {
@@ -163,13 +164,18 @@ function rewriteFallbackForProtocol(fallback: RouterFallbackConfig, config: AppC
}
function rewriteBodyModelForProtocol(body: Buffer | undefined, config: AppConfig, protocol: GatewayProviderProtocol): Buffer | undefined {
function rewriteBodyModelForProtocol(
body: Buffer | undefined,
config: AppConfig,
protocol: GatewayProviderProtocol,
targetProviderName?: string
): Buffer | undefined {
const parsedBody = parseJsonObjectSafe(body);
if (!parsedBody) {
return body;
}
const model = stringValue(parsedBody.model);
const rewrittenModel = rewriteModelSelectorForProtocol(model, config, protocol);
const rewrittenModel = rewriteModelSelectorForProtocol(model, config, protocol, targetProviderName);
if (!rewrittenModel || rewrittenModel === model) {
return body;
}
@@ -198,23 +204,43 @@ function clearTargetProviderHeadersForModelSelector(
function rewriteModelSelectorForProtocol(
model: string | undefined,
config: AppConfig,
protocol: GatewayProviderProtocol
protocol: GatewayProviderProtocol,
targetProviderName?: string
): string | undefined {
const normalized = normalizeRouteSelector(model);
if (!normalized) {
return model;
}
const publicModel = resolveGatewayPublicModelId(normalized, config) ?? normalized;
const selector =
resolveConfiguredProviderModelSelector(publicModel, config) ??
resolveUniqueConfiguredProviderModelSelector(publicModel, config);
const capability = selector ? providerCapabilityForClientProtocol(selector.provider, protocol) : undefined;
return selector && capability
? `${providerCapabilityInternalName(selector.provider, capability.type)}/${selector.model}`
const resolved = modelRegistryForConfig(config).resolve(
publicModel,
targetProviderName ? { providerName: targetProviderName } : {}
);
const selector = resolved?.kind === "provider"
? { model: resolved.model, provider: resolved.provider }
: undefined;
const providerName = selector ? providerSelectorNameForProtocol(selector.provider, protocol, Boolean(targetProviderName)) : undefined;
return selector && providerName
? `${providerName}/${selector.model}`
: publicModel;
}
function providerSelectorNameForProtocol(
provider: GatewayProviderConfig,
protocol: GatewayProviderProtocol,
allowRuntimeProvider: boolean
): string | undefined {
const capability = providerCapabilityForClientProtocol(provider, protocol);
if (capability) {
return providerCapabilityInternalName(provider, capability.type);
}
return allowRuntimeProvider && providerProtocolForClientProtocol(provider, protocol)
? providerRuntimeId(provider)
: undefined;
}
export function rewriteCapabilityResponseHeaders(headers: Headers, config: AppConfig): Headers {
const providerName = headers.get("x-gateway-target-provider-name")?.trim();
if (!providerName) {
@@ -265,16 +291,50 @@ export async function fetchUpstreamWithFallback(input: {
upstreamUrl: string;
}): Promise<UpstreamFetchResult> {
const fallbackMode = input.fallback.mode;
const planningHeaders = { ...input.headers };
const planningRouting = applyProviderCapabilityRouting({
body: input.body,
config: input.config,
fallback: input.fallback,
headers: planningHeaders,
path: input.path,
routedModel: input.routedModel
});
const attempts = buildUpstreamAttempts(
input.config,
input.fallback,
planningRouting.fallback,
input.method,
input.path,
input.body,
input.routedModel
planningRouting.body,
planningRouting.routedModel
);
const failedAttempts: UpstreamFailedAttempt[] = [];
const attemptRoutingCache = new Map<string | undefined, {
body?: Buffer;
headers: Record<string, string>;
routedModel?: string;
sourceBody?: Buffer;
sourceRoutedModel?: string;
}>();
const primaryAttempt = attempts[0];
const parsedInputBody = parseJsonObjectSafe(input.body);
const planningBodyCanSeedPrimary = requestProtocolForPath(input.path) === "gemini_generate_content" ||
!parsedInputBody ||
!primaryAttempt?.model ||
stringValue(parsedInputBody.model) !== undefined;
if (primaryAttempt && planningBodyCanSeedPrimary) {
attemptRoutingCache.set(primaryAttempt.model, {
body: planningRouting.body,
headers: planningHeaders,
routedModel: primaryAttempt.model,
sourceBody: input.body,
sourceRoutedModel: input.routedModel
});
}
input.trace?.capture({
changes: [
routeTraceChange("routing", "/routing/fallback", input.fallback, planningRouting.fallback)
].filter(isRouteTraceChange),
decision: { reason: `fallback:${fallbackMode}`, source: "execution-plan" },
kind: "decision",
name: "fallback.execution-plan",
@@ -290,17 +350,69 @@ export async function fetchUpstreamWithFallback(input: {
}
const attemptNumber = index + 1;
const plannedAttempt = attempts[index];
const capabilityRoutingStartedAt = Date.now();
let cachedAttemptRouting = attemptRoutingCache.get(plannedAttempt.model);
if (!cachedAttemptRouting) {
const routedHeaders = { ...input.headers };
const sourceBody = buildAttemptBody(input.body, input.path, plannedAttempt.model);
const routing = applyProviderCapabilityRouting({
body: sourceBody,
config: input.config,
fallback: input.fallback,
headers: routedHeaders,
path: input.path,
routedModel: plannedAttempt.model
});
cachedAttemptRouting = {
body: routing.body,
headers: routedHeaders,
routedModel: routing.routedModel,
sourceBody,
sourceRoutedModel: plannedAttempt.model
};
attemptRoutingCache.set(plannedAttempt.model, cachedAttemptRouting);
}
const attemptHeaders = { ...cachedAttemptRouting.headers };
const attemptSourceBody = cachedAttemptRouting.sourceBody;
const capabilityProviderHeadersBefore = {
gateway: input.headers["x-gateway-target-provider"],
list: input.headers["x-target-providers"],
target: input.headers["x-target-provider"]
};
input.trace?.capture({
attempt: attemptNumber,
changes: [
...(attemptSourceBody === cachedAttemptRouting.body
? []
: [{ operation: "replace" as const, path: "/body/model", scope: "body" as const }]),
routeTraceChange("routing", "/routing/model", cachedAttemptRouting.sourceRoutedModel, cachedAttemptRouting.routedModel),
routeTraceChange("headers", "/headers/x-target-provider", capabilityProviderHeadersBefore.target, attemptHeaders["x-target-provider"]),
routeTraceChange("headers", "/headers/x-target-providers", capabilityProviderHeadersBefore.list, attemptHeaders["x-target-providers"]),
routeTraceChange("headers", "/headers/x-gateway-target-provider", capabilityProviderHeadersBefore.gateway, attemptHeaders["x-gateway-target-provider"])
].filter(isRouteTraceChange),
durationMs: Date.now() - capabilityRoutingStartedAt,
kind: "mutation",
name: "provider.capability-routing",
phase: "capability",
startedAtMs: capabilityRoutingStartedAt,
target: cachedAttemptRouting.routedModel ? { model: cachedAttemptRouting.routedModel } : undefined
});
const attemptPreparationStartedAt = Date.now();
const attempt = prepareUpstreamCredentialAttempt({
attempt: attempts[index],
attempt: {
...plannedAttempt,
body: cachedAttemptRouting.body,
model: cachedAttemptRouting.routedModel ?? plannedAttempt.model
},
config: input.config,
headers: input.headers,
headers: attemptHeaders,
method: input.method,
path: input.path
});
const hasNextAttempt = index < attempts.length - 1;
const attemptUrl = rewriteRouteModelInUrl(input.upstreamUrl, attempt.model);
const attemptHeaders = {
const upstreamHeaders = {
...withCoreGatewayAuthHeader(
omitLocalObservabilityHeaders(attempt.headers ?? input.headers),
input.coreAuthToken
@@ -346,13 +458,13 @@ export async function fetchUpstreamWithFallback(input: {
});
releaseJsonObject(attempt.body);
releaseJsonObject(attempts[index].body);
releaseJsonObject(attemptSourceBody);
releaseJsonObject(input.body);
try {
const response = await fetchWithSystemProxy(attemptUrl, {
body: shouldSendBody(input.method) ? attempt.body?.toString("utf8") : undefined,
headers: attemptHeaders,
headers: upstreamHeaders,
method: input.method,
signal: input.signal
});
@@ -387,7 +499,7 @@ export async function fetchUpstreamWithFallback(input: {
recordProviderCredentialOutcome(input.config, input.method, attempt, response.status, response.headers);
await drainResponseBody(response);
if (delayMs > 0) {
await delay(delayMs);
await delay(delayMs, input.signal);
}
continue;
}
@@ -450,7 +562,7 @@ export async function fetchUpstreamWithFallback(input: {
}
if (hasNextAttempt) {
if (delayMs > 0) {
await delay(delayMs);
await delay(delayMs, input.signal);
}
continue;
}
@@ -744,6 +856,22 @@ function resolveProviderCredentialRoutingTarget(
const parsedBody = parseJsonObjectSafe(body);
const bodyModel = stringValue(parsedBody?.model);
const targetProviderName = firstTargetProviderHeader(headers);
const headerProvider = targetProviderName ? findProviderByPublicOrInternalName(config, targetProviderName) : undefined;
const headerProviderProtocol = headerProvider ? providerProtocolForClientProtocol(headerProvider, protocol) : undefined;
const exactHeaderProviderModel = headerProvider ? resolveExactModelForProvider(bodyModel, headerProvider) : undefined;
if (headerProvider && headerProviderProtocol && exactHeaderProviderModel) {
return {
body: parsedBody && exactHeaderProviderModel !== bodyModel
? serializeJsonBodyWithModel(parsedBody, exactHeaderProviderModel)
: body,
model: exactHeaderProviderModel,
provider: headerProvider,
protocol: headerProviderProtocol,
source: "header"
};
}
const modelSelector = resolveConfiguredProviderModelSelector(bodyModel, config) ??
resolveUniqueConfiguredProviderModelSelector(bodyModel, config);
if (modelSelector) {
@@ -760,16 +888,15 @@ function resolveProviderCredentialRoutingTarget(
}
}
const targetProviderName = firstTargetProviderHeader(headers);
if (!targetProviderName) {
return undefined;
}
const provider = findProviderByPublicOrInternalName(config, targetProviderName);
const provider = headerProvider ?? findProviderByPublicOrInternalName(config, targetProviderName);
if (!provider) {
return undefined;
}
const providerProtocol = providerProtocolForClientProtocol(provider, protocol);
const providerProtocol = headerProviderProtocol ?? providerProtocolForClientProtocol(provider, protocol);
if (!providerProtocol) {
return undefined;
}
@@ -787,6 +914,15 @@ function resolveProviderCredentialRoutingTarget(
}
function resolveExactModelForProvider(
value: string | undefined,
provider: GatewayProviderConfig
): string | undefined {
const normalized = normalizeRouteSelector(value);
return normalized && providerHasModel(provider, normalized) ? normalized : undefined;
}
function resolveModelForProvider(
value: string | undefined,
provider: GatewayProviderConfig
@@ -904,9 +1040,6 @@ function buildUpstreamAttempts(
primaryModel: routedModel
});
return plan.attempts.map((attempt) => ({
body: parsedBody && !modelInPath && fallback.mode === "model-chain" && attempt.model
? serializeJsonBodyWithModel(parsedBody, attempt.model)
: body,
index: attempt.index,
model: attempt.model,
target: attempt.target
@@ -914,6 +1047,46 @@ function buildUpstreamAttempts(
}
function buildAttemptBody(
body: Buffer | undefined,
path: string,
model: string | undefined
): Buffer | undefined {
if (!body || !model || requestProtocolForPath(path) === "gemini_generate_content") {
return body;
}
const parsedBody = parseJsonObjectSafe(body);
if (!parsedBody || stringValue(parsedBody.model) === model) {
return body;
}
return serializeJsonBodyWithModel(parsedBody, model);
}
function routeTraceChange(
scope: RequestRouteTraceChange["scope"],
path: string,
before: unknown,
after: unknown
): RequestRouteTraceChange | undefined {
if (before === after) {
return undefined;
}
return {
...(before === undefined ? {} : { before }),
...(after === undefined ? {} : { after }),
operation: before === undefined ? "add" : after === undefined ? "remove" : "replace",
path,
scope
};
}
function isRouteTraceChange(value: RequestRouteTraceChange | undefined): value is RequestRouteTraceChange {
return Boolean(value);
}
async function drainResponseBody(response: Response): Promise<void> {
try {
await response.arrayBuffer();
+20 -5
View File
@@ -917,8 +917,8 @@ function buildCodexConfigToml(
managedContextArchiveMcpEnd
]);
content = removeCodexProviderTable(content, values.providerId);
content = removeCodexMcpServerTable(content, TOOL_HUB_MCP_SERVER_NAME);
content = removeCodexMcpServerTable(content, CONTEXT_ARCHIVE_MCP_SERVER_NAME);
content = removeCodexMcpServerTable(content, TOOL_HUB_MCP_SERVER_NAME, { includeChildTables: !values.toolHubMcp });
content = removeCodexMcpServerTable(content, CONTEXT_ARCHIVE_MCP_SERVER_NAME, { includeChildTables: !values.contextArchiveMcp });
if (values.configFormat === "separate_profile_files") {
content = removeCodexProfileTable(content, values.providerId);
}
@@ -1034,7 +1034,9 @@ function buildSeparateCodexProfileToml(
): string {
const firstTableIndex = firstTomlTableIndex(source);
const rootSource = firstTableIndex === -1 ? source : source.slice(0, firstTableIndex);
const restSource = firstTableIndex === -1 ? "" : source.slice(firstTableIndex);
let restSource = firstTableIndex === -1 ? "" : source.slice(firstTableIndex);
restSource = removeCodexMcpServerTable(restSource, TOOL_HUB_MCP_SERVER_NAME, { includeChildTables: true });
restSource = removeCodexMcpServerTable(restSource, CONTEXT_ARCHIVE_MCP_SERVER_NAME, { includeChildTables: true });
const modelAssignment = managedModelAssignment(rootSource, values.model);
const showAllSessionsAssignment = rootTomlAssignment(rootSource, "show_all_sessions")
?? (values.showAllSessions ? "show_all_sessions = true" : undefined);
@@ -2323,7 +2325,11 @@ function removeCodexProfileTable(source: string, providerId: string): string {
return removeTomlTable(source, "profiles", providerId);
}
function removeCodexMcpServerTable(source: string, serverName: string): string {
function removeCodexMcpServerTable(
source: string,
serverName: string,
options: { includeChildTables?: boolean } = {}
): string {
const lines = source.split(/(?<=\n)/);
const headers = new Set([
`[mcp_servers.${serverName}]`,
@@ -2338,7 +2344,8 @@ function removeCodexMcpServerTable(source: string, serverName: string): string {
const kept: string[] = [];
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
if (!headers.has(line.trim())) {
const trimmed = line.trim();
if (!headers.has(trimmed) && !isCodexMcpServerChildTable(trimmed, serverName, Boolean(options.includeChildTables))) {
kept.push(line);
continue;
}
@@ -2352,6 +2359,14 @@ function removeCodexMcpServerTable(source: string, serverName: string): string {
return kept.join("");
}
function isCodexMcpServerChildTable(trimmedLine: string, serverName: string, enabled: boolean): boolean {
if (!enabled) {
return false;
}
return trimmedLine.startsWith(`[mcp_servers.${serverName}.`) ||
trimmedLine.startsWith(`[mcp_servers.${tomlQuotedKey(serverName)}.`);
}
function removeTomlTable(source: string, section: string, name: string): string {
const lines = source.split(/(?<=\n)/);
const headers = new Set([
+204 -32
View File
@@ -49,6 +49,7 @@ import type {
ProviderAccountTestRequest,
ProviderAccountTestResult,
ProviderAccountStandardConnectorConfig,
ProviderAccountWebContentJsonConnectorConfig,
ProviderCredentialConfig,
ProviderAccountStatus
} from "@ccr/core/contracts/app";
@@ -93,6 +94,25 @@ type CodexOauthRefreshResult = {
scope?: string;
};
export type ProviderAccountWebContentFetchRequest = {
body?: unknown;
endpoint: string;
headers?: Record<string, string>;
loginUrl?: string;
method: "GET" | "POST";
provider: GatewayProviderConfig;
requestOrigin: string;
timeoutMs?: number;
};
export type ProviderAccountWebContentFetchResponse = {
payload: unknown;
};
export type ProviderAccountWebContentFetchHandler = (
request: ProviderAccountWebContentFetchRequest
) => Promise<ProviderAccountWebContentFetchResponse>;
const defaultRefreshIntervalMs = 5 * 60 * 1000;
const minRefreshIntervalMs = 30 * 1000;
const maxErrorRefreshIntervalMs = 60 * 1000;
@@ -109,6 +129,11 @@ const cache = new Map<string, CacheEntry>();
const codexOauthCache = new Map<string, CodexOauthRefreshResult>();
const inFlightRefreshes = new Map<string, Promise<ProviderAccountSnapshot | undefined>>();
let cacheGeneration = 0;
let providerAccountWebContentFetchHandler: ProviderAccountWebContentFetchHandler | undefined;
export function setProviderAccountWebContentFetchHandler(handler: ProviderAccountWebContentFetchHandler | undefined): void {
providerAccountWebContentFetchHandler = handler;
}
export async function getProviderAccountSnapshots(
providerName?: string,
@@ -168,15 +193,13 @@ export async function testProviderAccountConnector(request: ProviderAccountTestR
models: [],
name: request.providerName?.trim() || "Provider"
};
const connector: ProviderAccountHttpJsonConnectorConfig = {
...request.connector,
auth: request.connector.auth ?? "provider-api-key",
method: request.connector.method ?? "GET",
type: "http-json"
};
const payload = await fetchJson(connector.endpoint, provider, connector.auth, connector.headers, connector.method, connector.body);
const connector = normalizeProviderAccountTestConnector(request.connector);
const payload = connector.type === "webcontent-json"
? await fetchWebContentJson(provider, connector)
: await fetchJson(connector.endpoint, provider, connector.auth, connector.headers, connector.method, connector.body);
const source = connector.type;
if (connector.parser === "grok-subscription") {
const meters = grokSubscriptionMeters(payload);
const meters = grokSubscriptionMeters(payload, source);
return {
meters,
message: grokSubscriptionMessage(payload),
@@ -186,7 +209,7 @@ export async function testProviderAccountConnector(request: ProviderAccountTestR
};
}
if (connector.parser === "kimi-code-usages") {
const meters = kimiCodeUsageMeters(payload);
const meters = kimiCodeUsageMeters(payload, source);
return {
meters,
message: meters.length === 0 ? "No usage data available." : undefined,
@@ -196,7 +219,7 @@ export async function testProviderAccountConnector(request: ProviderAccountTestR
};
}
if (connector.parser === "new-api-key-usage") {
const meters = newApiKeyUsageMeters(payload);
const meters = newApiKeyUsageMeters(payload, source);
return {
meters,
message: meters.length === 0 ? newApiKeyUsageFallbackMessage(payload) : readMappedString(connector.mapping.message, payload),
@@ -206,7 +229,7 @@ export async function testProviderAccountConnector(request: ProviderAccountTestR
};
}
if (connector.parser === "new-api-user-self") {
const meters = newApiUserSelfMeters(payload);
const meters = newApiUserSelfMeters(payload, source);
return {
meters,
message: meters.length === 0 ? readMappedString(connector.mapping.message, payload) ?? "No user balance data available." : readMappedString(connector.mapping.message, payload),
@@ -216,7 +239,7 @@ export async function testProviderAccountConnector(request: ProviderAccountTestR
};
}
const meters = mappedMetersFromPayload(connector, payload);
const meters = mappedMetersFromPayload(connector, payload, source);
return {
meters,
@@ -227,6 +250,24 @@ export async function testProviderAccountConnector(request: ProviderAccountTestR
};
}
function normalizeProviderAccountTestConnector(
connector: ProviderAccountHttpJsonConnectorConfig | ProviderAccountWebContentJsonConnectorConfig
): ProviderAccountHttpJsonConnectorConfig | ProviderAccountWebContentJsonConnectorConfig {
if (connector.type === "webcontent-json") {
return {
...connector,
method: connector.method ?? "GET",
type: "webcontent-json"
};
}
return {
...connector,
auth: connector.auth ?? "provider-api-key",
method: connector.method ?? "GET",
type: "http-json"
};
}
export function newApiKeyUsageMetersForTest(payload: unknown): ProviderAccountMeter[] {
return newApiKeyUsageMeters(payload);
}
@@ -613,6 +654,9 @@ async function resolveConnector(
if (connector.type === "http-json") {
return await resolveHttpJsonConnector(config, provider, connector);
}
if (connector.type === "webcontent-json") {
return await resolveWebContentJsonConnector(provider, connector);
}
if (connector.type === "plugin") {
return await resolvePluginConnector(config, provider, connector, now);
}
@@ -719,6 +763,58 @@ async function resolveHttpJsonConnector(
};
}
async function resolveWebContentJsonConnector(
provider: GatewayProviderConfig,
connector: ProviderAccountWebContentJsonConnectorConfig
): Promise<ConnectorResult> {
const payload = await fetchWebContentJson(provider, connector);
const source: ProviderAccountConnectorSource = "webcontent-json";
if (connector.parser === "grok-subscription") {
return {
errors: [],
message: grokSubscriptionMessage(payload),
meters: grokSubscriptionMeters(payload, source),
source,
status: grokSubscriptionStatus(payload)
};
}
if (connector.parser === "kimi-code-usages") {
const meters = kimiCodeUsageMeters(payload, source);
return {
errors: [],
message: meters.length === 0 ? "No usage data available." : undefined,
meters,
source
};
}
if (connector.parser === "new-api-key-usage") {
const meters = newApiKeyUsageMeters(payload, source);
return {
errors: [],
message: meters.length === 0 ? newApiKeyUsageFallbackMessage(payload) : readMappedString(connector.mapping.message, payload),
meters,
source
};
}
if (connector.parser === "new-api-user-self") {
const meters = newApiUserSelfMeters(payload, source);
return {
errors: [],
message: meters.length === 0 ? readMappedString(connector.mapping.message, payload) ?? "No user balance data available." : readMappedString(connector.mapping.message, payload),
meters,
source
};
}
return {
errors: [],
meters: mappedMetersFromPayload(connector, payload, source),
message: readMappedString(connector.mapping.message, payload),
source,
status: normalizeStatus(readMappedString(connector.mapping.status, payload))
};
}
async function resolvePluginConnector(
config: AppConfig,
provider: GatewayProviderConfig,
@@ -880,7 +976,7 @@ function normalizeRemoteSnapshot(
};
}
function grokSubscriptionMeters(payload: unknown): ProviderAccountMeter[] {
function grokSubscriptionMeters(payload: unknown, source: ProviderAccountConnectorSource = "http-json"): ProviderAccountMeter[] {
const allowAccess = grokSubscriptionBoolean(payload, [
"allow_access",
"allowAccess",
@@ -897,7 +993,7 @@ function grokSubscriptionMeters(payload: unknown): ProviderAccountMeter[] {
label: "Subscription access",
limit: 100,
remaining: allowAccess ? 100 : 0,
source: "http-json",
source,
unit: "%",
used: allowAccess ? 0 : 100,
window: "subscription"
@@ -984,12 +1080,12 @@ function grokSubscriptionRecords(payload: unknown): Record<string, unknown>[] {
return records;
}
function newApiKeyUsageMeters(payload: unknown): ProviderAccountMeter[] {
const meter = newApiKeyUsageMeter(payload);
function newApiKeyUsageMeters(payload: unknown, source: ProviderAccountConnectorSource = "http-json"): ProviderAccountMeter[] {
const meter = newApiKeyUsageMeter(payload, source);
return meter ? [meter] : [];
}
function newApiKeyUsageMeter(payload: unknown): ProviderAccountMeter | undefined {
function newApiKeyUsageMeter(payload: unknown, source: ProviderAccountConnectorSource): ProviderAccountMeter | undefined {
const data = newApiKeyUsageData(payload);
if (!data) {
return undefined;
@@ -1009,7 +1105,7 @@ function newApiKeyUsageMeter(payload: unknown): ProviderAccountMeter | undefined
label: "API key quota",
limit,
remaining,
source: "http-json",
source,
unit: "quota",
used
};
@@ -1023,12 +1119,12 @@ function newApiKeyUsageFallbackMessage(payload: unknown): string {
return readMappedString("$.message", payload) ?? "No API key quota data available.";
}
function newApiUserSelfMeters(payload: unknown): ProviderAccountMeter[] {
const meter = newApiUserSelfMeter(payload);
function newApiUserSelfMeters(payload: unknown, source: ProviderAccountConnectorSource = "http-json"): ProviderAccountMeter[] {
const meter = newApiUserSelfMeter(payload, source);
return meter ? [meter] : [];
}
function newApiUserSelfMeter(payload: unknown): ProviderAccountMeter | undefined {
function newApiUserSelfMeter(payload: unknown, source: ProviderAccountConnectorSource): ProviderAccountMeter | undefined {
const data = newApiUserSelfData(payload);
if (!data) {
return undefined;
@@ -1046,7 +1142,7 @@ function newApiUserSelfMeter(payload: unknown): ProviderAccountMeter | undefined
label: "User balance",
limit: remaining !== undefined && used !== undefined ? remaining + used : undefined,
remaining,
source: "http-json",
source,
unit: "quota",
used
};
@@ -1068,13 +1164,13 @@ function newApiUserSelfData(payload: unknown): Record<string, unknown> | undefin
return isRecord(data) ? data : payload;
}
function kimiCodeUsageMeters(payload: unknown): ProviderAccountMeter[] {
function kimiCodeUsageMeters(payload: unknown, source: ProviderAccountConnectorSource = "http-json"): ProviderAccountMeter[] {
if (!isRecord(payload)) {
return [];
}
const meters: ProviderAccountMeter[] = [];
const usage = isRecord(payload.usage) ? kimiCodeUsageMeter(payload.usage, "weekly_quota", "Weekly quota") : undefined;
const usage = isRecord(payload.usage) ? kimiCodeUsageMeter(payload.usage, "weekly_quota", "Weekly quota", source) : undefined;
if (usage) {
meters.push(usage);
}
@@ -1088,7 +1184,7 @@ function kimiCodeUsageMeters(payload: unknown): ProviderAccountMeter[] {
const detail = isRecord(item.detail) ? item.detail : item;
const window = isRecord(item.window) ? item.window : {};
const label = kimiCodeUsageLimitLabel(item, detail, window, index);
const meter = kimiCodeUsageMeter(detail, uniqueKimiCodeUsageMeterId(kimiCodeUsageMeterId(item, detail, label, index), seenIds), label, item);
const meter = kimiCodeUsageMeter(detail, uniqueKimiCodeUsageMeterId(kimiCodeUsageMeterId(item, detail, label, index), seenIds), label, source, item);
if (meter) {
seenIds.add(meter.id);
meters.push(meter);
@@ -1103,6 +1199,7 @@ function kimiCodeUsageMeter(
data: Record<string, unknown>,
id: string,
defaultLabel: string,
source: ProviderAccountConnectorSource,
fallbackData?: Record<string, unknown>
): ProviderAccountMeter | undefined {
const limit = normalizeNumber(data.limit);
@@ -1134,7 +1231,7 @@ function kimiCodeUsageMeter(
limit: 100,
remaining: remainingPercent,
resetAt,
source: "http-json",
source,
unit: "%",
used: remainingPercent === undefined ? undefined : 100 - remainingPercent
};
@@ -1147,7 +1244,7 @@ function kimiCodeUsageMeter(
limit,
remaining,
resetAt,
source: "http-json",
source,
unit: "quota",
used
};
@@ -1257,7 +1354,11 @@ function normalizeRemoteErrors(value: unknown, source: ProviderAccountConnectorS
return errors.length > 0 ? errors : undefined;
}
function mappedMeterFromPayload(config: ProviderAccountMappedMeterConfig, payload: unknown): ProviderAccountMeter | undefined {
function mappedMeterFromPayload(
config: ProviderAccountMappedMeterConfig,
payload: unknown,
source: ProviderAccountConnectorSource = "http-json"
): ProviderAccountMeter | undefined {
const id = config.id.trim();
const label = config.label.trim();
if (!id || !label) {
@@ -1280,12 +1381,16 @@ function mappedMeterFromPayload(config: ProviderAccountMappedMeterConfig, payloa
unit,
used,
window: config.window
}, "http-json");
}, source);
}
function mappedMetersFromPayload(connector: ProviderAccountHttpJsonConnectorConfig, payload: unknown): ProviderAccountMeter[] {
function mappedMetersFromPayload(
connector: ProviderAccountHttpJsonConnectorConfig | ProviderAccountWebContentJsonConnectorConfig,
payload: unknown,
source: ProviderAccountConnectorSource = "http-json"
): ProviderAccountMeter[] {
const meters = connector.mapping.meters
.map((meter) => mappedMeterFromPayload(meter, payload))
.map((meter) => mappedMeterFromPayload(meter, payload, source))
.filter((meter): meter is ProviderAccountMeter => Boolean(meter));
return attachCodexRateLimitResetCreditDetails(meters, payload);
}
@@ -1802,6 +1907,53 @@ function readBearerToken(value: string | undefined): string | undefined {
return match?.[1]?.trim() || undefined;
}
async function fetchWebContentJson(
provider: GatewayProviderConfig,
connector: ProviderAccountWebContentJsonConnectorConfig
): Promise<unknown> {
if (!providerAccountWebContentFetchHandler) {
throw new Error("Browser session account requests are only available in CCR Desktop. Use HTTP JSON in CLI or Docker.");
}
const endpoint = absoluteAccountEndpoint(provider, connector.endpoint);
const endpointUrl = parseHttpUrl(endpoint, "Browser session account endpoint");
const browser = connector.browser ?? {};
if (browser.partition && browser.partition !== "built-in-browser") {
throw new Error("Browser session account requests currently support only the built-in-browser partition.");
}
const requestOrigin = normalizeWebContentRequestOrigin(browser.requestOrigin, endpointUrl);
if (requestOrigin !== endpointUrl.origin) {
throw new Error("Browser session account request origin must match the endpoint origin.");
}
const loginUrl = browser.loginUrl?.trim();
if (loginUrl) {
parseHttpUrl(loginUrl, "Browser session account login URL");
}
const response = await providerAccountWebContentFetchHandler({
body: connector.method === "POST" ? connector.body : undefined,
endpoint: endpointUrl.toString(),
headers: connector.headers,
loginUrl,
method: connector.method ?? "GET",
provider: providerWithoutApiKey(provider),
requestOrigin,
timeoutMs: browser.timeoutMs
});
return response.payload;
}
function providerWithoutApiKey(provider: GatewayProviderConfig): GatewayProviderConfig {
return {
...provider,
api_key: "",
apiKey: undefined,
apikey: undefined
};
}
async function fetchJson(
endpoint: string,
provider: GatewayProviderConfig,
@@ -1925,6 +2077,26 @@ function absoluteAccountEndpoint(provider: GatewayProviderConfig, endpoint: stri
return url.toString();
}
function normalizeWebContentRequestOrigin(requestOrigin: string | undefined, endpointUrl: URL): string {
if (!requestOrigin?.trim()) {
return endpointUrl.origin;
}
return parseHttpUrl(requestOrigin, "Browser session account request origin").origin;
}
function parseHttpUrl(value: string, label: string): URL {
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error(`${label} must be an absolute HTTP or HTTPS URL.`);
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error(`${label} must use HTTP or HTTPS.`);
}
return url;
}
function providerBaseUrl(provider: GatewayProviderConfig): string {
return provider.api_base_url || provider.baseUrl || provider.baseurl || "";
}
@@ -2056,7 +2228,7 @@ function connectorError(source: ProviderAccountConnectorSource, message: string,
}
function connectorSource(connector: ProviderAccountConnectorConfig): ProviderAccountConnectorSource {
return connector.type === "standard" || connector.type === "http-json" || connector.type === "plugin" || connector.type === "local-estimate"
return connector.type === "standard" || connector.type === "http-json" || connector.type === "webcontent-json" || connector.type === "plugin" || connector.type === "local-estimate"
? connector.type
: "unsupported";
}
+32 -15
View File
@@ -164,7 +164,7 @@ export async function probeGatewayProviderCandidates(
try {
const probe = await probeGatewayProvider({
apiKey: mode === "connectivity" || mode === "models" ? request.apiKey : undefined,
apiKey: request.apiKey,
baseUrl: candidate.baseUrl,
forceRefresh: request.forceRefresh,
mode,
@@ -337,7 +337,7 @@ function providerProbeCandidateName(candidate: GatewayProviderProbeCandidate | u
async function resolveGatewayProviderProbe(request: GatewayProviderProbeRequest): Promise<GatewayProviderProbeResult> {
const mode = request.mode ?? "protocols";
const safetyIssue = providerApiKeySafetyIssue({
apiKey: mode === "connectivity" || mode === "models" ? request.apiKey : undefined,
apiKey: request.apiKey,
baseUrl: request.baseUrl
});
if (safetyIssue) {
@@ -1458,36 +1458,53 @@ function geminiApiEndpoint(baseUrl: string, path: string, defaultVersion: "v1" |
}
function withGeminiKey(url: string, apiKey: string | undefined): string {
if (!apiKey) {
const key = apiKeyCredentialValue(apiKey);
if (!key) {
return url;
}
const parsed = new URL(url);
parsed.searchParams.set("key", apiKey);
parsed.searchParams.set("key", key);
return compactProviderUrl(parsed);
}
function openAiHeaders(apiKey: string | undefined): Record<string, string> {
return apiKey
? {
authorization: `Bearer ${apiKey}`
}
: {};
return authorizationHeaders(apiKey);
}
function anthropicHeaders(apiKey: string | undefined): Record<string, string> {
const key = apiKeyCredentialValue(apiKey);
return {
"anthropic-version": "2023-06-01",
...(apiKey ? { "x-api-key": apiKey } : {})
...authorizationHeaders(apiKey),
...(key ? { "x-api-key": key } : {})
};
}
function geminiHeaders(apiKey: string | undefined): Record<string, string> {
return apiKey
? {
"x-goog-api-key": apiKey
}
: {};
const key = apiKeyCredentialValue(apiKey);
return {
...authorizationHeaders(apiKey),
...(key ? { "x-goog-api-key": key } : {})
};
}
function authorizationHeaders(apiKey: string | undefined): Record<string, string> {
const trimmed = apiKey?.trim();
if (!trimmed) {
return {};
}
return {
authorization: /^Bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`
};
}
function apiKeyCredentialValue(apiKey: string | undefined): string | undefined {
const trimmed = apiKey?.trim();
if (!trimmed) {
return undefined;
}
return trimmed.replace(/^Bearer\s+/i, "");
}
function headersForProtocol(protocol: GatewayProviderCapabilityProtocol, apiKey: string | undefined): Record<string, string> {
@@ -1 +1 @@
export { ProxyAgent } from "undici";
export { Agent, ProxyAgent } from "undici";
+8 -8
View File
@@ -26,6 +26,14 @@ export class ModelRegistry {
return undefined;
}
if (options.providerName) {
const provider = this.findProvider(options.providerName);
const model = provider ? configuredProviderModel(provider, normalized) : undefined;
if (provider && model) {
return providerModelRef(provider, model, normalized);
}
}
const parsed = parseProviderModelSelector(normalized);
if (parsed) {
const provider = this.findProvider(parsed.provider);
@@ -45,14 +53,6 @@ export class ModelRegistry {
};
}
if (options.providerName) {
const provider = this.findProvider(options.providerName);
const model = provider ? configuredProviderModel(provider, normalized) : undefined;
if (provider && model) {
return providerModelRef(provider, model, normalized);
}
}
const exactMatches = this.providerModelMatches(normalized, false);
if (exactMatches.length === 1) {
return providerModelRef(exactMatches[0].provider, exactMatches[0].model, normalized);
@@ -26,6 +26,28 @@ test("generated Codex CLI middleware converts Windows SDK paths before URL schem
assert.equal(fn("@the-next-ai/bot-gateway-sdk"), "@the-next-ai/bot-gateway-sdk");
});
test("generated Codex CLI middleware materializes bundled Bot Gateway stdio runner", () => {
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-bot-runner-"));
const source = path.join(dir, "resources", "app.asar", "dist", "main", "bot-gateway-sdk", "bin", "bot-gateway-stdio.mjs");
const configDir = path.join(dir, "config");
mkdirSync(path.dirname(source), { recursive: true });
writeFileSync(source, "#!/usr/bin/env node\nconsole.log('ok');\n");
const resolveCommand = evaluateRuntimeFunction(
"resolveBundledBotGatewayCommand",
["normalizeDuplicateShebangs", "materializeBotGatewayStdioRunnerPath"],
configDir
);
const command = resolveCommand({ bundledStdioPath: () => source });
const expectedRunner = path.join(configDir, "bot-gateway", "runners", "bot-gateway-stdio.mjs");
assert.equal(command.command, process.execPath);
assert.deepEqual(command.args, [expectedRunner]);
assert.equal(command.cwd, path.dirname(expectedRunner));
assert.notEqual(command.cwd, path.dirname(source));
assert.equal(readFileSync(expectedRunner, "utf8"), "#!/usr/bin/env node\nconsole.log('ok');\n");
});
test("Codex app-server uses ChatGPT's bundled Node as a signed supervisor", { skip: process.platform !== "darwin" }, () => {
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-signed-supervisor-"));
const runtimeFile = writeRuntimeScript(dir);
@@ -1055,9 +1077,19 @@ function writeRuntimeScript(dir) {
return file;
}
function evaluateRuntimeFunction(name) {
const source = extractRuntimeFunctionSource(codexCliMiddlewareRuntimeScript(), name);
return Function("path", "pathToFileURL", `${source}; return ${name};`)(path, pathToFileURL);
function evaluateRuntimeFunction(name, dependencies = [], configDir = "") {
const runtime = codexCliMiddlewareRuntimeScript();
const source = [
...dependencies.map((dependency) => extractRuntimeFunctionSource(runtime, dependency)),
extractRuntimeFunctionSource(runtime, name)
].join("\n");
const fsRuntime = { existsSync, mkdirSync, readFileSync, writeFileSync };
return Function("path", "pathToFileURL", "fs", "CONFIG_DIR", `${source}; return ${name};`)(
path,
pathToFileURL,
fsRuntime,
configDir
);
}
function extractRuntimeFunctionSource(source, name) {
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import { PassThrough, Readable } from "node:stream";
import test from "node:test";
import { createHostedWebSearchProtocolContext } from "@ccr/core/gateway/features/hosted-web-search/index.ts";
import {
fusionFallbackToolDefinitions,
fusionWebSearchToolNameForRequest,
@@ -504,6 +505,51 @@ test("gateway resolves normalized Fusion web search tool names for Anthropic pro
assert.equal(fusionWebSearchToolNameForRequest(config, "Fusion/kimisearch"), "fusion_2_web_search");
});
test("gateway resolves Claude discovery aliases before hosted web search matching", () => {
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" }]
}
]
};
const context = createHostedWebSearchProtocolContext({
body: Buffer.from(JSON.stringify({
messages: [{ content: "weather", role: "user" }],
model: "claude-Fusion/kimisearch",
tools: [{ name: "web_search", type: "web_search_20250305" }]
})),
config,
method: "POST",
path: "/v1/messages",
requestId: "request-1",
routedModel: "claude-Fusion/kimisearch",
sinceMs: 0
});
assert.equal(context?.toolName, "fusion_2_web_search");
});
test("gateway does not route hosted web search through an unrelated Fusion search profile", () => {
const config = {
Providers: [
@@ -756,7 +756,14 @@ test("profile service injects ToolHub MCP into Codex config", { skip: !process.e
separateProfileFile,
initialSeparateProfile
.replace('model = "Provider/model"', 'model = "User/selected-in-cli"')
.replace(/\s*$/, '\nmodel_reasoning_effort = "ultra"\n')
.replace(/\s*$/, [
"",
'model_reasoning_effort = "ultra"',
"",
'[mcp_servers.ccr-toolhub.tools."tool_hub.resolve"]',
'approval_mode = "approve"',
""
].join("\n"))
);
await applyProfileConfig(config);
@@ -773,6 +780,7 @@ test("profile service injects ToolHub MCP into Codex config", { skip: !process.e
const preservedSeparateProfile = readFileSync(separateProfileFile, "utf8");
assert.match(preservedSeparateProfile, /model = "User\/selected-in-cli"/);
assert.match(preservedSeparateProfile, /model_reasoning_effort = "ultra"/);
assert.equal(preservedSeparateProfile.includes("[mcp_servers.ccr-toolhub"), false);
config.Providers[0].models.push("model-2");
config.profile.profiles[0].model = "Provider/model-2";
@@ -1,6 +1,10 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env.ts";
import { materializeBotGatewayStdioRunnerPath } from "@ccr/core/agents/bot-gateway/qr-login-service.ts";
import { botGatewaySdkImportSpecifier } from "@ccr/core/agents/bot-gateway/sdk-import.ts";
function botGateway(overrides = {}) {
@@ -141,3 +145,17 @@ test("botGatewaySdkImportSpecifier converts Windows absolute paths before URL sc
assert.equal(botGatewaySdkImportSpecifier("file:///tmp/sdk/index.js"), "file:///tmp/sdk/index.js");
assert.equal(botGatewaySdkImportSpecifier("@the-next-ai/bot-gateway-sdk"), "@the-next-ai/bot-gateway-sdk");
});
test("materializeBotGatewayStdioRunnerPath copies bundled runner out of app.asar paths", () => {
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-bot-runner-"));
const source = path.join(dir, "resources", "app.asar", "dist", "main", "bot-gateway-sdk", "bin", "bot-gateway-stdio.mjs");
const configDir = path.join(dir, "config");
mkdirSync(path.dirname(source), { recursive: true });
writeFileSync(source, "#!/usr/bin/env node\n#!/usr/bin/env node\nconsole.log('ok');\n");
const materialized = materializeBotGatewayStdioRunnerPath(source, configDir);
assert.equal(materialized, path.join(configDir, "bot-gateway", "runners", "bot-gateway-stdio.mjs"));
assert.notEqual(path.dirname(materialized), path.dirname(source));
assert.equal(readFileSync(materialized, "utf8"), "#!/usr/bin/env node\nconsole.log('ok');\n");
});
@@ -276,6 +276,61 @@ test("Claude Code exposes a configured 1M context window as a visible model vari
assert.equal(rewrite?.routedModel, "Zhipu AI (China) - Coding Plan/aaa");
});
test("issue 1632 Claude discovery inherits Fusion fixed model provider context", () => {
const config = createConfig({
providers: [
{
modelMetadata: {
"glm-5.2": {
contextWindow: 1_000_000,
maxContextWindow: 1_000_000
}
},
models: ["glm-5.2"],
name: "Zhipu AI (China) - Coding Plan",
type: "openai_chat_completions"
}
],
virtualModelProfiles: [
{
baseModel: { fixedModel: "Zhipu AI (China) - Coding Plan/glm-5.2", mode: "fixed" },
displayName: "GLM 5.2 Fusion",
enabled: true,
execution: { clientToolsPolicy: "allow", mode: "tool_loop", streamMode: "optimistic" },
id: "glm-5-2-fusion",
key: "glm-5.2-fusion",
match: { exactAliases: ["glm-5.2-fusion"], prefixes: [], suffixes: [] },
materialization: { enabled: true, includeInGatewayModels: true },
tools: []
}
]
});
const route = buildClaudeAppGatewayModelRoutes(config).find((item) => item.targetModel === "Fusion/glm-5.2-fusion");
const inferenceModel = buildClaudeAppGatewayInferenceModels(config).find((item) => item.name === route?.id);
const appModel = createClaudeModelsResponse(config).data.find((item) => item.id === route?.id);
const codeModel = createClaudeCodeModelsResponse(config).data.find((item) =>
item.display_name === "Fusion/glm-5.2-fusion (1M context)"
);
assert.ok(route);
assert.equal(route.oneMillionContext, true);
assert.equal(inferenceModel?.supports1m, true);
assert.equal(appModel?.max_input_tokens, 1_000_000);
assert.equal(appModel?.capabilities.context_window.max_input_tokens, 1_000_000);
assert.equal(appModel?.capabilities.context_window.supports_1m_context, true);
assert.ok(codeModel);
assert.match(codeModel.id, /\[1m\]$/);
assert.equal(codeModel.max_input_tokens, 1_000_000);
const rewrite = prepareClaudeAppDiscoveredModelRequest(
config,
"POST",
"/v1/messages",
Buffer.from(JSON.stringify({ messages: [], model: codeModel.id }))
);
assert.equal(rewrite?.routedModel, "Fusion/glm-5.2-fusion");
});
test("Claude App discovery publishes the effective provider context for uncatalogued models", () => {
const config = createConfig({
providers: [
@@ -0,0 +1,123 @@
import assert from "node:assert/strict";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
prepareClaudeAppVmStorage,
resolveClaudeAppDefaultUserDataDirs,
resolveClaudeAppVmBundleDir,
resolveSharedClaudeAppVmSeedBundleDir
} from "@ccr/core/agents/claude-app/vm-storage.ts";
test("Claude App VM storage prepares profile bundle from the default Claude App data dir", () => {
withRuntimeEnv((root) => {
const configDir = path.join(root, "ccr");
const sourceBundle = resolveClaudeAppVmBundleDir(resolveClaudeAppDefaultUserDataDirs()[0]);
const targetUserDataDir = path.join(configDir, "profiles", "target", "claude", ".claude-code-router", "claude-app-user-data", "target");
writeVmBundle(sourceBundle, "default-vm");
const result = prepareClaudeAppVmStorage(configDir, targetUserDataDir);
assert.equal(result.action, "prepared");
assert.equal(readRootfs(resolveSharedClaudeAppVmSeedBundleDir(configDir)), "default-vm");
assert.equal(readRootfs(resolveClaudeAppVmBundleDir(targetUserDataDir)), "default-vm");
});
});
test("Claude App VM storage can seed new profiles from an existing CCR profile", () => {
withRuntimeEnv((root) => {
const configDir = path.join(root, "ccr");
const sourceUserDataDir = path.join(configDir, "profiles", "source", "claude", ".claude-code-router", "claude-app-user-data", "source");
const targetUserDataDir = path.join(configDir, "profiles", "target", "claude", ".claude-code-router", "claude-app-user-data", "target");
const sourceBundle = resolveClaudeAppVmBundleDir(sourceUserDataDir);
writeVmBundle(sourceBundle, "profile-vm");
const result = prepareClaudeAppVmStorage(configDir, targetUserDataDir);
assert.equal(result.action, "prepared");
assert.equal(result.sourceBundleDir, sourceBundle);
assert.equal(readRootfs(resolveSharedClaudeAppVmSeedBundleDir(configDir)), "profile-vm");
assert.equal(readRootfs(resolveClaudeAppVmBundleDir(targetUserDataDir)), "profile-vm");
});
});
test("Claude App VM storage leaves an existing profile VM untouched", () => {
withRuntimeEnv((root) => {
const configDir = path.join(root, "ccr");
const sourceBundle = resolveClaudeAppVmBundleDir(resolveClaudeAppDefaultUserDataDirs()[0]);
const targetUserDataDir = path.join(configDir, "profiles", "target", "claude", ".claude-code-router", "claude-app-user-data", "target");
const targetBundle = resolveClaudeAppVmBundleDir(targetUserDataDir);
writeVmBundle(sourceBundle, "source-vm");
writeVmBundle(targetBundle, "target-vm");
const result = prepareClaudeAppVmStorage(configDir, targetUserDataDir);
assert.deepEqual(result, {
action: "skipped",
reason: "target-present",
targetBundleDir: targetBundle
});
assert.equal(readRootfs(targetBundle), "target-vm");
});
});
test("Claude App VM storage replaces incomplete temporary VM bundles", () => {
withRuntimeEnv((root) => {
const configDir = path.join(root, "ccr");
const sourceBundle = resolveClaudeAppVmBundleDir(resolveClaudeAppDefaultUserDataDirs()[0]);
const targetUserDataDir = path.join(configDir, "profiles", "target", "claude", ".claude-code-router", "claude-app-user-data", "target");
const targetBundle = resolveClaudeAppVmBundleDir(targetUserDataDir);
writeVmBundle(sourceBundle, "source-vm");
mkdirSync(path.join(targetBundle, ".wvm-tmp-123"), { recursive: true });
writeFileSync(path.join(targetBundle, ".wvm-tmp-123", "rootfs.img"), "partial-vm");
writeFileSync(path.join(targetBundle, ".cowork-adopted"), "marker");
const result = prepareClaudeAppVmStorage(configDir, targetUserDataDir);
assert.equal(result.action, "prepared");
assert.equal(readRootfs(targetBundle), "source-vm");
assert.equal(existsSync(path.join(targetBundle, ".wvm-tmp-123")), false);
});
});
function writeVmBundle(bundleDir, content) {
mkdirSync(bundleDir, { recursive: true });
writeFileSync(path.join(bundleDir, "rootfs.img"), content);
writeFileSync(path.join(bundleDir, "machineIdentifier"), "machine");
}
function readRootfs(bundleDir) {
return readFileSync(path.join(bundleDir, "rootfs.img"), "utf8");
}
function withRuntimeEnv(run) {
const root = mkdtempSync(path.join(os.tmpdir(), "ccr-claude-app-vm-storage-"));
const previous = {
appData: process.env.CCR_INTERNAL_APP_DATA_DIR,
home: process.env.CCR_INTERNAL_HOME_DIR,
seed: process.env.CCR_CLAUDE_APP_VM_SEED_DIR,
seedDisabled: process.env.CCR_CLAUDE_APP_VM_SEED_DISABLED
};
try {
process.env.CCR_INTERNAL_APP_DATA_DIR = path.join(root, "app-data");
process.env.CCR_INTERNAL_HOME_DIR = path.join(root, "home");
delete process.env.CCR_CLAUDE_APP_VM_SEED_DIR;
delete process.env.CCR_CLAUDE_APP_VM_SEED_DISABLED;
run(root);
} finally {
setOptionalEnv("CCR_INTERNAL_APP_DATA_DIR", previous.appData);
setOptionalEnv("CCR_INTERNAL_HOME_DIR", previous.home);
setOptionalEnv("CCR_CLAUDE_APP_VM_SEED_DIR", previous.seed);
setOptionalEnv("CCR_CLAUDE_APP_VM_SEED_DISABLED", previous.seedDisabled);
rmSync(root, { force: true, recursive: true });
}
}
function setOptionalEnv(name, value) {
if (value === undefined) {
delete process.env[name];
return;
}
process.env[name] = value;
}
@@ -39,6 +39,23 @@ test("codex catalog treats unknown models as text-only while enabling apply_patc
assert.equal(model.apply_patch_tool_type, "freeform");
});
test("codex catalog publishes configured provider model descriptions", () => {
const model = catalogModelFor({
Providers: [
{
modelDescriptions: {
"MODEL-A": "Fast sidecar model for simple code search."
},
models: ["model-a"],
name: "Custom",
type: "openai_chat_completions"
}
]
}, "Custom/model-a");
assert.equal(model.description, "Fast sidecar model for simple code search.");
});
test("codex catalog uses model catalog capabilities for known text models", () => {
const model = catalogModelFor({
Providers: [
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -24,7 +25,7 @@ test("Claude Code local provider prefers macOS Keychain credentials over stale f
const candidate = claudeCodeCandidate();
assert.equal(candidate.status, "available");
assert.equal(candidate.importable, true);
assert.equal(candidate.sourceFile, "keychain:Claude Code-credentials");
assert.equal(candidate.sourceFile, `keychain:Claude Code-credentials (${keychainAccount})`);
const result = importClaudeCodeProvider(candidate, []);
assert.equal(result.providerPlugins[0].auth.headers.authorization, "Bearer keychain-access-token");
@@ -100,6 +101,223 @@ test("Core gateway config replaces imported Claude Code OAuth token with live ma
});
});
// Claude Code >= 2.1 writes the credential item under the current $USER and
// leaves any pre-2.1 item (account "unknown") in place on the same service
// name. A lookup without `-a` matches the stale one, which only carries MCP
// plugin OAuth. See musistudio/claude-code-router#1601.
test("Claude Code local provider reads the credential item stored under the current account", { skip: process.platform === "win32" }, async () => {
await withClaudeCodeHome(async () => {
await withPlatform("darwin", async () => {
await withFakeSecurityKeychain([
{
account: "unknown",
modified: "20260604091803",
record: { mcpOAuth: { "plugin:github|1": { expiresAt: 1 } } },
service: "Claude Code-credentials"
},
{
account: keychainAccount,
modified: "20260729042837",
record: { claudeAiOauth: { accessToken: "live-access-token", refreshToken: "live-refresh-token" } },
service: "Claude Code-credentials"
}
], async () => {
const candidate = claudeCodeCandidate();
assert.equal(candidate.status, "available");
assert.equal(candidate.importable, true);
assert.equal(candidate.sourceFile, `keychain:Claude Code-credentials (${keychainAccount})`);
const result = importClaudeCodeProvider(candidate, []);
assert.equal(result.providerPlugins[0].auth.headers.authorization, "Bearer live-access-token");
});
});
});
});
// The only path to an item written under a different account (a login made as
// another user, or a $USER that has since changed) is `security dump-keychain`
// enumeration; the expected-name lookup cannot reach it.
test("Claude Code local provider enumerates the keychain to find an item under another account", { skip: process.platform === "win32" }, async () => {
await withClaudeCodeHome(async () => {
await withPlatform("darwin", async () => {
await withFakeSecurityKeychain([
{
account: "other-user",
modified: "20260729042837",
record: { claudeAiOauth: { accessToken: "enumerated-access-token" } },
service: "Claude Code-credentials",
accountlessLookup: false
}
], async () => {
const candidate = claudeCodeCandidate();
assert.equal(candidate.status, "available");
assert.equal(candidate.importable, true);
assert.equal(candidate.sourceFile, "keychain:Claude Code-credentials (other-user)");
const result = importClaudeCodeProvider(candidate, []);
assert.equal(result.providerPlugins[0].auth.headers.authorization, "Bearer enumerated-access-token");
});
});
});
});
// mcpOAuth holds per-plugin tokens that are not Anthropic API credentials;
// importing one yields a provider that 401s on every request.
test("Claude Code local provider ignores mcpOAuth plugin tokens in favour of claudeAiOauth", { skip: process.platform === "win32" }, async () => {
await withClaudeCodeHome(async () => {
await withPlatform("darwin", async () => {
await withFakeSecurityKeychain([
{
account: keychainAccount,
modified: "20260729042837",
record: {
mcpOAuth: { "plugin:github|1": { accessToken: "mcp-plugin-token", refreshToken: "mcp-plugin-refresh" } },
claudeAiOauth: { accessToken: "live-access-token", refreshToken: "live-refresh-token" }
},
service: "Claude Code-credentials"
}
], async () => {
const result = importClaudeCodeProvider(claudeCodeCandidate(), []);
assert.equal(result.providerPlugins[0].auth.headers.authorization, "Bearer live-access-token");
});
});
});
});
// Regression for the review on #1604: an mcpOAuth-only record must yield no
// token at all, not a plugin token demoted to a fallback.
test("Claude Code local provider does not import an mcpOAuth-only keychain token", { skip: process.platform === "win32" }, async () => {
await withClaudeCodeHome(async () => {
await withPlatform("darwin", async () => {
await withFakeSecurityKeychain([
{
account: keychainAccount,
modified: "20260729042837",
record: {
mcpOAuth: {
"plugin:github|1": {
accessToken: "mcp-plugin-token",
refreshToken: "mcp-plugin-refresh"
}
}
},
service: "Claude Code-credentials"
}
], async () => {
const candidate = claudeCodeCandidate();
assert.equal(candidate.status, "locked");
assert.equal(candidate.importable, false);
assert.match(candidate.detail, /no OAuth token/);
assert.match(candidate.detail, /mcpOAuth/);
assert.throws(
() => importClaudeCodeProvider(candidate, []),
/Claude Code access token was not found/
);
});
});
});
});
// An mcpOAuth-only keychain record must not short-circuit the file fallback.
test("Claude Code local provider prefers file credentials over an mcpOAuth-only keychain token", { skip: process.platform === "win32" }, async () => {
await withClaudeCodeHome(async (home) => {
await withPlatform("darwin", async () => {
await withFakeSecurityKeychain([
{
account: keychainAccount,
modified: "20260729042837",
record: {
mcpOAuth: {
"plugin:github|1": {
accessToken: "mcp-plugin-token",
refreshToken: "mcp-plugin-refresh"
}
}
},
service: "Claude Code-credentials"
}
], async () => {
const credentialFile = writeClaudeCredentials(home, {
accessToken: "file-access-token",
refreshToken: "file-refresh-token"
});
const candidate = claudeCodeCandidate();
assert.equal(candidate.status, "available");
assert.equal(candidate.importable, true);
assert.equal(candidate.sourceFile, credentialFile);
const result = importClaudeCodeProvider(candidate, []);
assert.equal(result.providerPlugins[0].auth.headers.authorization, "Bearer file-access-token");
});
});
});
});
// With CLAUDE_CONFIG_DIR set, Claude Code appends the first 8 hex of
// sha256(NFC(configDir)) to the service name.
test("Claude Code local provider reads the config-dir-suffixed keychain service", { skip: process.platform === "win32" }, async () => {
await withClaudeCodeHome(async (home) => {
await withPlatform("darwin", async () => {
const configDir = path.join(home, "alt-claude");
const suffix = createHash("sha256").update(configDir.normalize("NFC")).digest("hex").slice(0, 8);
const service = `Claude Code-credentials-${suffix}`;
await withEnv("CLAUDE_CONFIG_DIR", configDir, async () => {
await withFakeSecurityKeychain([
{
account: keychainAccount,
modified: "20260729042837",
record: { claudeAiOauth: { accessToken: "suffixed-access-token" } },
service
}
], async () => {
const candidate = claudeCodeCandidate();
assert.equal(candidate.status, "available");
assert.equal(candidate.sourceFile, `keychain:${service} (${keychainAccount})`);
});
});
});
});
});
test("Claude Code local provider explains login state that carries no OAuth token", { skip: process.platform === "win32" }, async () => {
await withClaudeCodeHome(async () => {
await withPlatform("darwin", async () => {
await withFakeSecurityKeychain([
{
account: keychainAccount,
modified: "20260729042837",
record: { mcpOAuth: { "plugin:github|1": { expiresAt: 1 } } },
service: "Claude Code-credentials"
}
], async () => {
const candidate = claudeCodeCandidate();
assert.equal(candidate.status, "locked");
assert.equal(candidate.importable, false);
assert.match(candidate.detail, /no OAuth token/);
assert.match(candidate.detail, /mcpOAuth/);
});
});
});
});
// errSecItemNotFound is the ordinary logged-out answer: it must not be reported
// as an unreadable store.
test("Claude Code local provider reports a missing candidate when no keychain item exists", { skip: process.platform === "win32" }, async () => {
await withClaudeCodeHome(async () => {
await withPlatform("darwin", async () => {
await withFakeSecurityKeychain([], async () => {
const candidate = claudeCodeCandidate();
assert.equal(candidate.status, "missing");
assert.equal(candidate.importable, false);
assert.equal(candidate.detail, "No local login state was found for this agent.");
});
});
});
});
async function withClaudeCodeHome(run) {
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-claude-code-provider-"));
const previousHome = process.env.HOME;
@@ -133,21 +351,109 @@ async function withFakeSecurityFailure(run) {
await withFakeSecurityScript("exit 44\n", run);
}
// Pins $USER as well as PATH: the provider looks the keychain item up under the
// current account, so the expected item name has to be deterministic.
async function withFakeSecurityScript(body, run) {
const binDir = mkdtempSync(path.join(os.tmpdir(), "ccr-security-bin-"));
const securityPath = path.join(binDir, "security");
const previousPath = process.env.PATH;
const previousUser = process.env.USER;
writeFileSync(securityPath, `#!/bin/sh\n${body}`);
chmodSync(securityPath, 0o755);
process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ""}`;
process.env.USER = keychainAccount;
try {
await run();
} finally {
restoreEnv("PATH", previousPath);
restoreEnv("USER", previousUser);
rmSync(binDir, { force: true, recursive: true });
}
}
const keychainAccount = "ccr-test-user";
// Stands in for the macOS keychain: `find-generic-password` matches on
// service+account, an omitted `-a` matches the first item registered for the
// service (as the real Keychain picks an arbitrary one), and `dump-keychain`
// prints the metadata-only listing the provider parses.
async function withFakeSecurityKeychain(items, run) {
await withFakeSecurityScript(fakeSecurityBody(items), run);
}
function fakeSecurityBody(items) {
const dump = [
...items.map(item => [
`keychain: "/tmp/login.keychain-db"`,
"version: 512",
"class: \"genp\"",
"attributes:",
` "acct"<blob>="${item.account}"`,
` "mdat"<timedate>=0x00 "${item.modified}Z\\000"`,
` "svce"<blob>="${item.service}"`
].join("\n")),
[
`keychain: "/tmp/login.keychain-db"`,
"attributes:",
` "acct"<blob>="unrelated"`,
` "svce"<blob>="Bitwarden Safe Storage"`
].join("\n")
].join("\n");
// `accountlessLookup: false` models an item the Keychain will not return for
// a `-s`-only query, so only enumeration can reach it.
const firstForService = new Map();
for (const item of items) {
if (item.accountlessLookup !== false && !firstForService.has(item.service)) {
firstForService.set(item.service, item);
}
}
const branches = [
...items.map((item, index) => ({ key: `${item.service}|${item.account}`, record: item.record, tag: `CCR_J${index}` })),
...[...firstForService.values()].map((item, index) => ({ key: `${item.service}|`, record: item.record, tag: `CCR_A${index}` }))
];
return [
`if [ "$1" = "dump-keychain" ]; then`,
`cat <<'CCR_DUMP'`,
dump,
"CCR_DUMP",
"exit 0",
"fi",
"shift",
"acct=''",
"svc=''",
"while [ $# -gt 0 ]; do",
` case "$1" in`,
` -a) acct="$2"; shift 2 ;;`,
` -s) svc="$2"; shift 2 ;;`,
" *) shift ;;",
" esac",
"done",
`case "$svc|$acct" in`,
...branches.map(branch => [
`"${branch.key}")`,
`cat <<'${branch.tag}'`,
JSON.stringify(branch.record),
branch.tag,
"exit 0 ;;"
].join("\n")),
"esac",
`echo 'security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.' >&2`,
"exit 44"
].join("\n");
}
async function withEnv(name, value, run) {
const previous = process.env[name];
process.env[name] = value;
try {
await run();
} finally {
restoreEnv(name, previous);
}
}
function writeClaudeCredentials(home, credentials) {
const directory = path.join(home, ".claude");
const credentialFile = path.join(directory, ".credentials.json");
@@ -96,6 +96,49 @@ test("Codex OAuth plugins retain the default base URL after runtime identity nor
assert.equal(compiled.providers[0].baseurl, codexDefaultBaseUrl);
});
test("Codex OAuth plugins prefer live login credentials over imported snapshots", async (t) => {
const home = useTemporaryCodexHome(t, "ccr-codex-runtime-live-credentials-");
fs.mkdirSync(path.join(home, ".codex"), { recursive: true });
fs.writeFileSync(path.join(home, ".codex", "auth.json"), JSON.stringify({
tokens: {
access_token: "access-live",
account_id: "acct-live",
refresh_token: "refresh-live"
}
}));
const config = createDefaultAppConfig();
config.providerPlugins = [{
codexOauth: {
accessToken: "access-imported",
accountId: "acct-imported",
refreshToken: "refresh-imported"
},
key: "ccr-local-agent-codex-api-codex-oauth",
providerName: "Codex API"
}];
config.Providers = [{
api_base_url: codexDefaultBaseUrl,
api_key: "ccr-local-agent-login",
id: "codex-api",
models: ["gpt-5.5"],
name: "Codex API",
type: "openai_responses"
}];
const compiled = await compileCoreGatewayConfig(
config,
"raw-trace-token",
"billing-usage-token",
"core-auth-token"
);
const codexPlugin = compiled.providerPlugins.find((item) => item.key === "ccr-local-agent-codex-api-codex-oauth");
assert.equal(codexPlugin.codexOauth.accessToken, "access-live");
assert.equal(codexPlugin.codexOauth.refreshToken, "refresh-live");
assert.equal(codexPlugin.codexOauth.accountId, "acct-live");
});
test("Codex local providers synthesize OAuth plugins when persisted plugins are missing", async (t) => {
const home = useTemporaryCodexHome(t, "ccr-codex-runtime-missing-plugins-");
fs.mkdirSync(path.join(home, ".codex"), { recursive: true });
@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import test from "node:test";
test("top-level provider protocol becomes a capability when none are configured", async () => {
const { parseProvidersForTest } = await import("@ccr/core/config/config.ts");
const providers = parseProvidersForTest([
{
name: "Codex API",
protocol: "openai_responses",
baseUrl: "https://chatgpt.com/backend-api/codex",
models: ["gpt-5.5"]
}
]);
assert.equal(providers?.length, 1);
assert.deepEqual(providers[0].capabilities, [
{ baseUrl: "https://chatgpt.com/backend-api/codex", type: "openai_responses" }
]);
});
test("explicit capabilities win over the top-level protocol", async () => {
const { parseProvidersForTest } = await import("@ccr/core/config/config.ts");
const providers = parseProvidersForTest([
{
name: "Codex API",
protocol: "openai_responses",
baseUrl: "https://chatgpt.com/backend-api/codex",
capabilities: [
{ type: "openai_chat_completions", baseUrl: "https://example.com/v1" }
],
models: []
}
]);
assert.deepEqual(providers[0].capabilities, [
{ baseUrl: "https://example.com/v1", endpoint: undefined, source: undefined, type: "openai_chat_completions" }
]);
});
test("protocol aliases are normalized", async () => {
const { parseProvidersForTest } = await import("@ccr/core/config/config.ts");
const providers = parseProvidersForTest([
{
name: "Claude Code API",
protocol: "anthropic_messages",
baseUrl: "https://api.anthropic.com",
models: []
}
]);
assert.deepEqual(providers[0].capabilities, [
{ baseUrl: "https://api.anthropic.com", type: "anthropic_messages" }
]);
});
test("unknown or missing protocol yields no synthesized capability", async () => {
const { parseProvidersForTest } = await import("@ccr/core/config/config.ts");
const providers = parseProvidersForTest([
{ name: "DeepInfra", api_base_url: "https://api.deepinfra.com/v1/openai", models: [] },
{ name: "Mystery", protocol: "carrier_pigeon", baseUrl: "https://example.com", models: [] }
]);
assert.equal(providers[0].capabilities, undefined);
assert.equal(providers[1].capabilities, undefined);
});
test("protocol without any base URL yields no synthesized capability", async () => {
const { parseProvidersForTest } = await import("@ccr/core/config/config.ts");
const providers = parseProvidersForTest([
{ name: "Codex API", protocol: "openai_responses", models: [] }
]);
assert.equal(providers[0].capabilities, undefined);
});
@@ -0,0 +1,235 @@
import assert from "node:assert/strict";
import { Readable, Writable } from "node:stream";
import test from "node:test";
import { ClaudeCodeRouterPlugin } from "@ccr/core/gateway/claude-code-router-plugin.ts";
import {
rewriteAnthropicMessageStartModelStream,
rewriteAnthropicSseBlockMessageStartModelForTest,
shouldRewriteAnthropicMessageStartModel
} from "@ccr/core/gateway/features/anthropic-response-model.ts";
import { GatewayRequestPipeline } from "@ccr/core/gateway/request/pipeline.ts";
async function streamText(stream) {
const chunks = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString("utf8");
}
test("Anthropic SSE response model rewrite keeps Claude Code visible model consistent", async () => {
const thinkingBlock = 'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"keep this"}}\n\n';
const output = await streamText(rewriteAnthropicMessageStartModelStream(
Readable.from([
"event: message_start\n",
'data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"k3","content":[]}}\n',
"\n",
thinkingBlock.slice(0, 37),
thinkingBlock.slice(37),
"data: [DONE]\n\n"
]),
"kimi-test/k3"
));
assert.match(output, /event: message_start\n/);
assert.match(output, /"type":"message_start"/);
assert.match(output, /"model":"kimi-test\/k3"/);
assert.doesNotMatch(output, /"model":"k3"/);
assert.match(output, new RegExp(escapeRegExp(thinkingBlock)));
assert.match(output, /data: \[DONE\]\n\n$/);
});
test("Anthropic SSE response model rewrite leaves unrelated blocks unchanged", () => {
const block = 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}';
assert.equal(rewriteAnthropicSseBlockMessageStartModelForTest(block, "kimi-test/k3"), block);
assert.equal(rewriteAnthropicSseBlockMessageStartModelForTest("data: [DONE]", "kimi-test/k3"), "data: [DONE]");
assert.equal(rewriteAnthropicSseBlockMessageStartModelForTest("data: not-json", "kimi-test/k3"), "data: not-json");
});
test("Anthropic response model rewrite activates only for Anthropic SSE with a model", () => {
assert.equal(shouldRewriteAnthropicMessageStartModel({
contentType: "text/event-stream; charset=utf-8",
model: "kimi-test/k3",
protocol: "anthropic_messages"
}), true);
assert.equal(shouldRewriteAnthropicMessageStartModel({
contentType: "application/json",
model: "kimi-test/k3",
protocol: "anthropic_messages"
}), false);
assert.equal(shouldRewriteAnthropicMessageStartModel({
contentType: "text/event-stream",
model: "kimi-test/k3",
protocol: "openai_responses"
}), false);
assert.equal(shouldRewriteAnthropicMessageStartModel({
contentType: "text/event-stream",
model: "",
protocol: "anthropic_messages"
}), false);
});
test("gateway pipeline returns the Claude Code visible model when upstream responds with bare model", async () => {
const result = await runAnthropicPipelineModelRewrite("kimi-test/k3");
assert.equal(result.upstreamBody?.model, "k3");
assert.equal(result.response.statusCode, 200);
assert.equal(result.response.headers["content-type"], "text/event-stream; charset=utf-8");
assert.equal(result.response.headers["content-length"], undefined);
assert.match(result.output, /"model":"kimi-test\/k3"/);
assert.doesNotMatch(result.output, /"model":"k3"/);
assert.match(result.output, /"type":"thinking"/);
});
test("gateway pipeline preserves Claude Code hex model id in Anthropic SSE response", async () => {
const encodedModel = `anthropic/claude-ccr-h${Buffer.from("kimi-test/k3", "utf8").toString("hex")}`;
const result = await runAnthropicPipelineModelRewrite(encodedModel);
assert.equal(result.upstreamBody?.model, "k3");
assert.match(result.output, new RegExp(`"model":"${escapeRegExp(encodedModel)}"`));
assert.doesNotMatch(result.output, /"model":"kimi-test\/k3"/);
assert.doesNotMatch(result.output, /"model":"k3"/);
});
async function runAnthropicPipelineModelRewrite(requestModel) {
const config = createPipelineConfigForAnthropicModelRewrite();
const plugin = new ClaudeCodeRouterPlugin(config);
const pipeline = new GatewayRequestPipeline({
getBrowserWebSearchMcpIntegration: () => undefined,
getConfig: () => config,
getCoreAuthToken: () => "core-token",
getPlugin: () => plugin,
getStatus: () => ({
coreEndpoint: "http://127.0.0.1:65535",
endpoint: "http://127.0.0.1:3456"
})
});
const originalFetch = globalThis.fetch;
const originalWarn = console.warn;
let upstreamBody;
console.warn = (message, ...args) => {
if (String(message).startsWith("[usage] Failed to record usage:")) {
return;
}
originalWarn(message, ...args);
};
globalThis.fetch = async (_input, init) => {
upstreamBody = JSON.parse(String(init?.body));
return new Response(
[
"event: message_start\n",
'data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"k3","content":[]}}\n',
"\n",
'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"historical thinking must remain visible"}}\n\n',
"data: [DONE]\n\n"
].join(""),
{
headers: {
"content-length": "307",
"content-type": "text/event-stream; charset=utf-8"
},
status: 200
}
);
};
try {
const request = Readable.from([JSON.stringify({
max_tokens: 64,
messages: [{ content: "hello", role: "user" }],
model: requestModel,
stream: true
})]);
request.headers = {
"content-type": "application/json",
"user-agent": "claude-code/1.0"
};
request.method = "POST";
request.url = "/v1/messages";
const response = new CapturingResponse();
const finished = new Promise((resolve, reject) => {
response.once("finish", resolve);
response.once("error", reject);
});
await pipeline.proxyRequest(request, response, "/v1/messages");
await finished;
return {
output: response.bodyText(),
response,
upstreamBody
};
} finally {
await new Promise((resolve) => setImmediate(resolve));
globalThis.fetch = originalFetch;
console.warn = originalWarn;
}
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function createPipelineConfigForAnthropicModelRewrite() {
return {
CUSTOM_ROUTER_PATH: "",
Providers: [
{
capabilities: [{ baseUrl: "http://kimi.example/v1/messages", type: "anthropic_messages" }],
models: ["k3"],
name: "kimi-test"
}
],
Router: {
builtInRules: {
"claude-code": { enabled: false },
codex: { enabled: false }
},
fallback: { mode: "off", models: [], retryCount: 0 },
rules: []
},
contextArchive: {
enabled: false,
mcpEnabled: false
},
observability: {
agentAnalysis: false,
requestLogs: false
},
preferredProvider: "kimi-test",
profile: {
enabled: false,
profiles: []
},
toolHub: { enabled: false },
virtualModelProfiles: []
};
}
class CapturingResponse extends Writable {
constructor() {
super();
this.chunks = [];
this.headers = {};
this.statusCode = 0;
}
writeHead(statusCode, headers) {
this.statusCode = statusCode;
this.headers = Object.fromEntries(
Object.entries(headers ?? {}).map(([key, value]) => [key.toLowerCase(), String(value)])
);
return this;
}
_write(chunk, _encoding, callback) {
this.chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
callback();
}
bodyText() {
return Buffer.concat(this.chunks).toString("utf8");
}
}
@@ -0,0 +1,119 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import test from "node:test";
import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts";
import { authorize } from "@ccr/core/gateway/auth/api-key-authorizer.ts";
const authorizerSourceFile = path.join(
process.cwd(),
"packages",
"core",
"src",
"gateway",
"auth",
"api-key-authorizer.ts"
);
const gatewayApiKey = "ccr-3f8a1c7d9e2b4056a1c7d9e2b4056f8a";
function configWithApiKeys(apiKeys) {
const config = createDefaultAppConfig();
config.APIKEYS = apiKeys;
return config;
}
function createResponse() {
const response = {
payload: undefined,
statusCode: undefined,
end(chunk) {
response.payload = JSON.parse(String(chunk));
},
writeHead(statusCode) {
response.statusCode = statusCode;
}
};
return response;
}
async function authorizeRequest(config, request) {
const response = createResponse();
const result = await authorize({ headers: {}, url: "/v1/messages", ...request }, response, config);
return { response, result };
}
test("gateway authorization accepts the configured API key on every supported carrier", async () => {
const config = configWithApiKeys([
{ createdAt: new Date(0).toISOString(), id: "primary", key: gatewayApiKey }
]);
for (const request of [
{ headers: { authorization: `Bearer ${gatewayApiKey}` } },
{ headers: { "x-api-key": gatewayApiKey } },
{ url: `/__ccr/remote/status?api_key=${gatewayApiKey}` }
]) {
const { response, result } = await authorizeRequest(config, request);
assert.equal(result.ok, true);
assert.equal(result.apiKey.id, "primary");
assert.equal(response.statusCode, undefined);
}
});
test("gateway authorization rejects near-miss tokens of any length without throwing", async () => {
const config = configWithApiKeys([
{ createdAt: new Date(0).toISOString(), id: "primary", key: gatewayApiKey }
]);
for (const token of [
`X${gatewayApiKey.slice(1)}`,
`${gatewayApiKey.slice(0, -1)}b`,
gatewayApiKey.toUpperCase(),
gatewayApiKey.slice(0, -1),
`${gatewayApiKey}-extra`
]) {
const { response, result } = await authorizeRequest(config, {
headers: { authorization: `Bearer ${token}` }
});
assert.equal(result.ok, false);
assert.equal(response.statusCode, 401);
assert.equal(response.payload.error.message, "Invalid API key.");
}
});
test("gateway authorization separates a missing token from an expired key", async () => {
const config = configWithApiKeys([
{
createdAt: new Date(0).toISOString(),
expiresAt: new Date(Date.now() - 60_000).toISOString(),
id: "expired",
key: gatewayApiKey
}
]);
const missing = await authorizeRequest(config, {});
assert.equal(missing.result.ok, false);
assert.equal(missing.response.statusCode, 401);
assert.equal(missing.response.payload.error.message, "API key is missing.");
const expired = await authorizeRequest(config, {
headers: { authorization: `Bearer ${gatewayApiKey}` }
});
assert.equal(expired.result.ok, false);
assert.equal(expired.response.statusCode, 401);
assert.equal(expired.response.payload.error.message, "API key is expired.");
});
// A constant-time comparison is behaviour-preserving by construction, so the
// tests above pass on both sides of the change and only prove there is no
// regression. The invariant itself is asserted on the module source, the same
// way test/architecture/gateway-service-architecture.test.mjs asserts that the
// config compiler never reaches for node:fs.
test("gateway API key matching never uses a short-circuiting equality check", () => {
const source = readFileSync(authorizerSourceFile, "utf8");
assert.match(source, /from "node:crypto"/);
assert.match(source, /timingSafeEqual\(/);
assert.doesNotMatch(source, /\.key\s*===/);
});
@@ -0,0 +1,153 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
prepareCodexMultiAgentBridgeRequest,
transformCodexMultiAgentBridgeResponseValue,
transformCodexMultiAgentBridgeSseEvent
} from "@ccr/core/gateway/service.ts";
const config = {
Providers: [],
Router: {
builtInRules: {
"claude-code": { enabled: true },
codex: { enabled: true }
},
fallback: { mode: "off", models: [], retryCount: 1 },
rules: []
}
};
function multiAgentNamespaceTool() {
return {
type: "namespace",
name: "multi_agent_v1",
description: "Tools for spawning and managing sub-agents.",
tools: [
{
type: "function",
name: "spawn_agent",
description: "Spawn a sub-agent.",
strict: false,
parameters: {
type: "object",
properties: {
message: { type: "string" },
model: { type: "string" }
},
additionalProperties: false
}
},
{
type: "function",
name: "wait_agent",
description: "Wait for agents.",
strict: false,
parameters: {
type: "object",
properties: {
targets: { type: "array", items: { type: "string" } }
},
required: ["targets"],
additionalProperties: false
}
}
]
};
}
test("Codex multi-agent bridge expands namespace tools for non-GPT models", () => {
const result = prepareCodexMultiAgentBridgeRequest({
body: Buffer.from(JSON.stringify({
input: [
{
arguments: JSON.stringify({ message: "inspect tests" }),
call_id: "call_agent",
name: "spawn_agent",
namespace: "multi_agent_v1",
type: "function_call"
}
],
model: "Provider/claude-sonnet",
parallel_tool_calls: true,
tool_choice: { type: "tool", name: "multi_agent_v1" },
tools: [
{ type: "function", name: "exec_command" },
multiAgentNamespaceTool()
]
})),
config,
headers: { "user-agent": "codex-test" },
method: "POST",
path: "/v1/responses"
});
assert.ok(result);
const body = JSON.parse(result.body.toString("utf8"));
assert.deepEqual(
body.tools.map((tool) => `${tool.type}:${tool.name}`),
["function:exec_command", "function:multi_agent_v1_spawn_agent", "function:multi_agent_v1_wait_agent"]
);
assert.match(body.tools[1].description, /multi_agent_v1\.spawn_agent/);
assert.equal(body.tool_choice, undefined);
assert.equal(body.parallel_tool_calls, true);
assert.equal(body.input[0].name, "multi_agent_v1_spawn_agent");
assert.equal(body.input[0].namespace, undefined);
});
test("Codex multi-agent bridge leaves GPT models untouched", () => {
const result = prepareCodexMultiAgentBridgeRequest({
body: Buffer.from(JSON.stringify({
model: "openai/gpt-5-codex",
tools: [multiAgentNamespaceTool()]
})),
config,
headers: { "user-agent": "codex-test" },
method: "POST",
path: "/v1/responses"
});
assert.equal(result, undefined);
});
test("Codex multi-agent bridge rewrites flattened function response items to namespace calls", () => {
const result = transformCodexMultiAgentBridgeResponseValue({
type: "response.output_item.done",
item: {
arguments: JSON.stringify({ message: "inspect tests" }),
call_id: "call_agent",
name: "multi_agent_v1_spawn_agent",
type: "function_call"
}
});
assert.equal(result.changed, true);
assert.deepEqual(result.value.item, {
arguments: JSON.stringify({ message: "inspect tests" }),
call_id: "call_agent",
name: "spawn_agent",
namespace: "multi_agent_v1",
type: "function_call"
});
});
test("Codex multi-agent bridge rewrites flattened function SSE events", () => {
const event = transformCodexMultiAgentBridgeSseEvent([
"event: response.output_item.done",
`data: ${JSON.stringify({
type: "response.output_item.done",
item: {
arguments: JSON.stringify({ targets: ["agent-1"] }),
call_id: "call_wait",
name: "multi_agent_v1_wait_agent",
type: "function_call"
}
})}`
].join("\n"));
assert.match(event, /^event: response\.output_item\.done\n/);
const data = JSON.parse(event.split("\ndata: ")[1]);
assert.equal(data.item.type, "function_call");
assert.equal(data.item.name, "wait_agent");
assert.equal(data.item.namespace, "multi_agent_v1");
});
@@ -0,0 +1,103 @@
import assert from "node:assert/strict";
import test from "node:test";
import vm from "node:vm";
import { gatewayFetchPreloadScriptForTest } from "@ccr/core/gateway/core-runtime/supervisor.ts";
test("gateway fetch preload applies API_TIMEOUT_MS to direct upstream fetches", async () => {
const harness = executePreload({
CCR_UNDICI_MODULE: "mock-undici",
CCR_UPSTREAM_TIMEOUT_MS: "600000"
});
assert.equal(harness.agentOptions.length, 1);
assert.equal(harness.agentOptions[0].headersTimeout, 600000);
assert.equal(harness.agentOptions[0].bodyTimeout, 600000);
assert.equal(harness.proxyAgentOptions.length, 0);
await harness.context.fetch("https://api.example.test/v1/chat/completions", { method: "POST" });
assert.equal(harness.fetchCalls.length, 1);
assert.equal(harness.fetchCalls[0].init.dispatcher.kind, "direct");
});
test("gateway fetch preload combines proxy routing with timeout dispatcher options", async () => {
const harness = executePreload({
CCR_UNDICI_MODULE: "mock-undici",
CCR_UPSTREAM_PROXY_URL: "http://127.0.0.1:8888",
CCR_UPSTREAM_TIMEOUT_MS: "600000",
NO_PROXY: "api.internal.test,.bypass.test"
});
assert.equal(harness.agentOptions.length, 1);
assert.equal(harness.proxyAgentOptions.length, 1);
assert.equal(harness.proxyAgentOptions[0].uri, "http://127.0.0.1:8888");
assert.equal(harness.proxyAgentOptions[0].headersTimeout, 600000);
assert.equal(harness.proxyAgentOptions[0].bodyTimeout, 600000);
await harness.context.fetch("https://api.external.test/v1/messages", {});
await harness.context.fetch("https://api.internal.test/v1/messages", {});
await harness.context.fetch("http://127.0.0.1:3456/health", {});
await harness.context.fetch("https://service.bypass.test/v1/messages", {});
assert.equal(harness.fetchCalls[0].init.dispatcher.kind, "proxy");
assert.equal(harness.fetchCalls[1].init.dispatcher.kind, "direct");
assert.equal(harness.fetchCalls[2].init.dispatcher.kind, "direct");
assert.equal(harness.fetchCalls[3].init.dispatcher.kind, "direct");
});
test("gateway fetch preload preserves explicit fetch dispatchers", async () => {
const harness = executePreload({
CCR_UNDICI_MODULE: "mock-undici",
CCR_UPSTREAM_TIMEOUT_MS: "600000"
});
const dispatcher = { kind: "caller" };
await harness.context.fetch("https://api.example.test/v1/messages", { dispatcher });
assert.equal(harness.fetchCalls.length, 1);
assert.equal(harness.fetchCalls[0].init.dispatcher, dispatcher);
});
function executePreload(env) {
const agentOptions = [];
const proxyAgentOptions = [];
const fetchCalls = [];
class Agent {
constructor(options) {
this.kind = "direct";
this.options = options;
agentOptions.push(options);
}
}
class ProxyAgent {
constructor(options) {
this.kind = "proxy";
this.options = options;
proxyAgentOptions.push(options);
}
}
const context = {
URL,
fetch: async (input, init) => {
fetchCalls.push({ init, input });
return { ok: true };
},
process: { env },
require: (moduleName) => {
assert.equal(moduleName, "mock-undici");
return { Agent, ProxyAgent };
}
};
vm.createContext(context);
vm.runInContext(gatewayFetchPreloadScriptForTest(), context);
return {
agentOptions,
context,
fetchCalls,
proxyAgentOptions
};
}
@@ -0,0 +1,265 @@
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts";
import { compileCoreGatewayConfig } from "@ccr/core/gateway/core-runtime/config-compiler.ts";
import {
createGatewayPlugin,
isLocalAgentOauthProviderPlugin
} from "@ccr/core/gateway/core-runtime/local-agent-auth-provider-hook.ts";
import { virtualApplyPatchToolName } from "@ccr/core/gateway/internal/shared.ts";
test("Grok local agent auth hook refreshes live login state before authenticating upstream requests", async (t) => {
await withGrokHome(t, async (grokHome) => {
writeGrokAuth(grokHome, {
expires_at: "2000-01-01T00:00:00Z",
key: "expired-grok-access-token",
oidc_client_id: "grok-client-id",
oidc_issuer: "https://auth.x.ai",
refresh_token: "grok-refresh-token"
});
const previousFetch = globalThis.fetch;
const previousTokenEndpoint = process.env.GROK_OIDC_TOKEN_ENDPOINT;
process.env.GROK_OIDC_TOKEN_ENDPOINT = "http://127.0.0.1/grok/oauth/token";
globalThis.fetch = async (input, init) => {
assert.equal(String(input), "http://127.0.0.1/grok/oauth/token");
assert.equal(String(init?.body ?? ""), "client_id=grok-client-id&grant_type=refresh_token&refresh_token=grok-refresh-token");
return new Response(JSON.stringify({
access_token: "refreshed-grok-access-token",
expires_in: 3600,
refresh_token: "refreshed-grok-refresh-token"
}), { headers: { "content-type": "application/json" }, status: 200 });
};
t.after(() => {
globalThis.fetch = previousFetch;
restoreEnv("GROK_OIDC_TOKEN_ENDPOINT", previousTokenEndpoint);
});
const [hook] = createGatewayPlugin({
config: {
providerPlugins: [grokOauthProviderPlugin()]
}
}).providerHooks;
assert.equal(hook.key, "config:ccr-local-agent-grok-cli-api-grok-cli-oauth");
const patch = "*** Begin Patch\n*** Add File: grok.txt\n+hi\n*** End Patch\n";
const upstreamRequest = {
body: {
external_web_access: true,
input: [
{ type: "custom_tool_call", call_id: "call_patch", name: "apply_patch", input: patch },
{ type: "custom_tool_call_output", call_id: "call_patch", output: "Success" }
],
model: "wrong-model",
parallel_tool_calls: true,
tool_choice: "auto",
tools: [
{ type: "function", name: "exec_command" },
{ type: "custom", name: "apply_patch", format: { type: "grammar", syntax: "lark", definition: "start: begin_patch" } },
{ type: "namespace", name: "multi_agent_v1" },
{ type: "web_search" }
]
},
headers: {
"content-type": "application/json",
"x-api-key": "client-key"
},
method: "POST",
url: "https://cli-chat-proxy.grok.com/v1/responses"
};
const authResult = await hook.authenticate({
model: "grok-4.5",
upstreamRequest
});
assert.equal(authResult.ok, true);
assert.equal(authResult.value.headers.authorization, "Bearer refreshed-grok-access-token");
assert.equal(authResult.value.headers["x-api-key"], undefined);
assert.equal(upstreamRequest.headers["x-api-key"], "client-key");
const requestResult = await hook.transformRequest({
model: "grok-4.5",
upstreamRequest: authResult.value
});
assert.equal(requestResult.ok, true);
assert.equal(requestResult.value.headers["x-grok-client-identifier"], "xai-grok-cli");
assert.equal(requestResult.value.headers["x-grok-client-version"], "0.2.93");
assert.equal(requestResult.value.headers["x-grok-model-override"], "grok-4.5");
assert.equal(requestResult.value.body.external_web_access, undefined);
assert.deepEqual(requestResult.value.body.tools.map((tool) => tool.type), ["function", "function"]);
assert.equal(requestResult.value.body.tools[1].name, virtualApplyPatchToolName);
assert.equal(requestResult.value.body.tools.some((tool) => tool.type === "custom" || tool.type === "namespace"), false);
assert.equal(requestResult.value.body.tool_choice, "auto");
assert.equal(requestResult.value.body.parallel_tool_calls, true);
assert.match(requestResult.value.body.instructions, /When modifying files, call virtual_apply_patch/);
assert.equal(requestResult.value.body.input[0].type, "function_call");
assert.equal(requestResult.value.body.input[0].name, virtualApplyPatchToolName);
assert.deepEqual(JSON.parse(requestResult.value.body.input[0].arguments), { patch });
assert.equal(requestResult.value.body.input[1].type, "function_call_output");
const persisted = JSON.parse(readFileSync(path.join(grokHome, "auth.json"), "utf8"));
assert.equal(persisted["https://auth.x.ai::test-account"].key, "refreshed-grok-access-token");
});
});
test("Grok local agent request hook removes unsupported Responses tools and stale tool choice", () => {
const [hook] = createGatewayPlugin({
config: {
providerPlugins: [grokOauthProviderPlugin()]
}
}).providerHooks;
const requestResult = hook.transformRequest({
model: "grok-4.5",
upstreamRequest: {
body: {
parallel_tool_calls: true,
tool_choice: { type: "tool", name: "multi_agent_v1" },
tools: [
{ type: "namespace", name: "multi_agent_v1" },
{ type: "function", name: "exec_command" }
]
},
headers: {},
method: "POST",
url: "https://cli-chat-proxy.grok.com/v1/responses"
}
});
assert.equal(requestResult.ok, true);
assert.deepEqual(requestResult.value.body.tools, [{ type: "function", name: "exec_command" }]);
assert.equal(requestResult.value.body.tool_choice, undefined);
assert.equal(requestResult.value.body.parallel_tool_calls, true);
});
test("core gateway config installs the local agent dynamic auth runtime hook when OAuth plugins are present", async () => {
const config = createDefaultAppConfig();
config.providerPlugins = [grokOauthProviderPlugin()];
config.Providers = [
{
api_base_url: "https://cli-chat-proxy.grok.com/v1",
api_key: "ccr-local-agent-login",
id: "grok-cli-api",
models: ["grok-4.5"],
name: "Grok CLI API",
type: "openai_responses"
}
];
const compiled = await compileCoreGatewayConfig(
config,
"raw-trace-token",
"billing-usage-token",
"core-auth-token"
);
const plugins = Array.isArray(compiled.plugins) ? compiled.plugins : [];
const localAgentAuthPlugin = plugins.find((plugin) => plugin.key === "ccr-local-agent-auth-provider-hooks");
assert.ok(localAgentAuthPlugin);
assert.match(localAgentAuthPlugin.modulePath, /local-agent-auth-provider-hook\.js$/);
});
test("core gateway config removes Grok unsupported Responses options through declarative request transforms", async (t) => {
await withGrokHome(t, async (grokHome) => {
writeGrokAuth(grokHome, {
expires_at: "2099-01-01T00:00:00Z",
key: "live-grok-access-token",
oidc_client_id: "grok-client-id",
oidc_issuer: "https://auth.x.ai",
refresh_token: "grok-refresh-token"
});
const plugin = grokOauthProviderPlugin();
plugin.request.bodyRemove = ["metadata"];
const config = createDefaultAppConfig();
config.providerPlugins = [plugin];
config.Providers = [
{
api_base_url: "https://cli-chat-proxy.grok.com/v1",
api_key: "ccr-local-agent-login",
id: "grok-cli-api",
models: ["grok-4.5"],
name: "Grok CLI API",
type: "openai_responses"
}
];
const compiled = await compileCoreGatewayConfig(
config,
"raw-trace-token",
"billing-usage-token",
"core-auth-token"
);
const providerPlugins = Array.isArray(compiled.providerPlugins) ? compiled.providerPlugins : [];
const grokPlugin = providerPlugins.find((value) => value.key === "ccr-local-agent-grok-cli-api-grok-cli-oauth");
assert.ok(grokPlugin);
assert.deepEqual(grokPlugin.request.bodyRemove, ["metadata", "external_web_access"]);
});
});
test("local agent OAuth plugin detector only matches managed OAuth imports", () => {
assert.equal(isLocalAgentOauthProviderPlugin(grokOauthProviderPlugin()), true);
assert.equal(isLocalAgentOauthProviderPlugin({
key: "ccr-local-agent-grok-cli-api-grok-cli-api-key"
}), false);
assert.equal(isLocalAgentOauthProviderPlugin({
key: "external-grok-cli-oauth"
}), false);
});
function grokOauthProviderPlugin() {
return {
auth: {
headers: {
authorization: "Bearer imported-stale-token"
},
removeHeaders: ["x-api-key"],
strict: true
},
key: "ccr-local-agent-grok-cli-api-grok-cli-oauth",
providerName: "Grok CLI API",
request: {
headers: {
"x-grok-client-identifier": "xai-grok-cli",
"x-grok-client-version": "0.2.93",
"x-grok-model-override": "{{ model }}"
},
strict: true
}
};
}
async function withGrokHome(t, run) {
const previousGrokHome = process.env.GROK_HOME;
const previousGrokAuthFile = process.env.GROK_AUTH_FILE;
const previousGrokCliVersion = process.env.GROK_CLI_VERSION;
const grokHome = mkdtempSync(path.join(os.tmpdir(), "ccr-grok-hook-test-"));
process.env.GROK_HOME = grokHome;
process.env.GROK_CLI_VERSION = "0.2.93";
delete process.env.GROK_AUTH_FILE;
try {
await run(grokHome);
} finally {
restoreEnv("GROK_HOME", previousGrokHome);
restoreEnv("GROK_AUTH_FILE", previousGrokAuthFile);
restoreEnv("GROK_CLI_VERSION", previousGrokCliVersion);
rmSync(grokHome, { force: true, recursive: true });
}
}
function restoreEnv(name, value) {
if (value === undefined) {
delete process.env[name];
} else {
process.env[name] = value;
}
}
function writeGrokAuth(grokHome, auth) {
writeFileSync(path.join(grokHome, "auth.json"), JSON.stringify({
"https://auth.x.ai::test-account": auth
}, null, 2));
}
@@ -45,6 +45,21 @@ test("model registry canonicalizes provider models and rejects ambiguous bare mo
assert.equal(registry.resolve("Primary/not-configured"), undefined);
});
test("model registry prefers target-provider exact slash models over provider selectors", () => {
const registry = new ModelRegistry(routingConfig({
Providers: [
{ id: "openai", models: ["gpt-oss-20b"], name: "OpenAI" },
{ id: "groq", models: ["openai/gpt-oss-20b"], name: "Groq" }
]
}));
const resolved = registry.resolve("openai/gpt-oss-20b", { providerName: "Groq" });
assert.equal(resolved?.kind, "provider");
assert.equal(resolved?.canonicalSelector, "Groq/openai/gpt-oss-20b");
assert.equal(resolved?.model, "openai/gpt-oss-20b");
});
test("model registry accepts known internal provider suffixes only", () => {
const registry = new ModelRegistry(routingConfig());
@@ -152,6 +167,32 @@ test("router config compilation rejects conflicting provider and model targets",
assert.equal(compiled.rules[0].diagnostics[0].code, "rule-provider-model-conflict");
});
test("router config compilation accepts target-provider slash-namespaced model ids", () => {
const config = routingConfig({
Providers: [
{ id: "openai", models: ["gpt-oss-20b"], name: "OpenAI" },
{ id: "groq", models: ["openai/gpt-oss-20b"], name: "Groq" }
]
});
config.Router.rules = [{
condition: { left: "request.url", operator: "contains", right: "/v1" },
enabled: true,
id: "slash-model",
name: "Slash model",
rewrites: [
{ key: "request.header.x-target-provider", operation: "set", value: "Groq" },
{ key: "request.body.model", operation: "set", value: "openai/gpt-oss-20b" }
],
type: "condition"
}];
const compiled = compileRouterConfig(config);
assert.equal(compiled.rules[0].active, true);
assert.deepEqual(compiled.rules[0].diagnostics, []);
assert.equal(compiled.rules[0].model?.canonicalSelector, "Groq/openai/gpt-oss-20b");
});
test("router config compilation validates provider conflicts against final header rewrites", () => {
const config = routingConfig();
config.Router.rules = [
@@ -0,0 +1,309 @@
import assert from "node:assert/strict";
import test from "node:test";
import { fetchUpstreamWithFallback, prepareGatewayUpstreamAttemptForTest } from "@ccr/core/gateway/upstream/executor.ts";
import { RequestRouteTraceRecorder } from "@ccr/core/observability/route-trace.ts";
const retryConfig = {
Providers: [],
Router: { fallback: { mode: "retry", models: [], retryCount: 1 }, rules: [] },
virtualModelProfiles: []
};
const retryFallback = { mode: "retry", models: [], retryCount: 1 };
async function assertRetryBackoffStopsAfterAbort(fetchImpl) {
const originalFetch = globalThis.fetch;
const originalSetTimeout = globalThis.setTimeout;
const controller = new AbortController();
let fetchCount = 0;
globalThis.fetch = async (...args) => {
fetchCount += 1;
return fetchImpl(...args);
};
globalThis.setTimeout = (_callback, delay, ..._args) => {
const timer = originalSetTimeout(() => {}, delay);
timer.unref?.();
queueMicrotask(() => controller.abort(new Error("client disconnected")));
return timer;
};
try {
const outcome = await Promise.race([
fetchUpstreamWithFallback({
body: Buffer.from('{"model":"test-model"}'),
config: retryConfig,
coreAuthToken: "core-token",
fallback: retryFallback,
headers: {},
method: "POST",
path: "/v1/messages",
routedModel: "test-model",
signal: controller.signal,
upstreamUrl: "http://127.0.0.1:3456/v1/messages"
}).then(
() => ({ kind: "resolved" }),
(error) => ({ error, kind: "rejected" })
),
new Promise((resolve) => setImmediate(() => resolve({ kind: "pending" })))
]);
assert.notEqual(outcome.kind, "pending");
assert.equal(outcome.kind, "rejected");
assert.match(outcome.error.message, /client disconnected/);
assert.equal(fetchCount, 1);
} finally {
globalThis.fetch = originalFetch;
globalThis.setTimeout = originalSetTimeout;
}
}
test("retry backoff stops after client aborts a retryable HTTP response", async () => {
await assertRetryBackoffStopsAfterAbort(async () => new Response(null, { status: 503 }));
});
test("retry backoff stops after client aborts a network error", async () => {
await assertRetryBackoffStopsAfterAbort(async () => {
throw new Error("upstream unavailable");
});
});
test("target-provider routing preserves slash-namespaced model ids", () => {
const cases = [
{
model: "openai/gpt-oss-20b",
provider: "Groq",
url: "https://api.groq.example/openai/v1"
},
{
model: "nvidia/nemotron-3-ultra-550b-a55b",
provider: "NVIDIA",
url: "https://integrate.api.nvidia.com/v1"
},
{
model: "google/gemini-2.5-pro",
provider: "OpenRouter",
url: "https://openrouter.ai/api/v1"
}
];
for (const item of cases) {
const attempt = prepareGatewayUpstreamAttemptForTest({
body: {
messages: [],
model: item.model
},
config: {
Providers: [
{
capabilities: [{ baseUrl: item.url, type: "openai_chat_completions" }],
credentials: [{ apiKey: "provider-key", id: "provider-main" }],
models: [item.model],
name: item.provider
}
],
Router: { fallback: { mode: "off", models: [], retryCount: 0 }, rules: [] },
virtualModelProfiles: []
},
headers: {
"x-target-provider": item.provider
},
method: "POST",
path: "/v1/chat/completions",
routedModel: item.model
});
assert.equal(attempt.body.model, item.model);
assert.equal(attempt.logicalProvider, item.provider);
}
});
test("target-provider routing keeps vendor-prefixed model ids even when the prefix names another provider", () => {
const config = {
Providers: [
{
capabilities: [{ baseUrl: "https://api.openai.example/v1", type: "openai_chat_completions" }],
credentials: [{ apiKey: "openai-key", id: "openai-main" }],
id: "openai",
models: ["gpt-oss-20b"],
name: "OpenAI"
},
{
capabilities: [{ baseUrl: "https://api.groq.example/openai/v1", type: "openai_chat_completions" }],
credentials: [{ apiKey: "groq-key", id: "groq-main" }],
id: "groq",
models: ["openai/gpt-oss-20b"],
name: "Groq"
}
],
Router: { fallback: { mode: "off", models: [], retryCount: 0 }, rules: [] },
virtualModelProfiles: []
};
const attempt = prepareGatewayUpstreamAttemptForTest({
body: {
messages: [],
model: "openai/gpt-oss-20b"
},
config,
headers: {
"x-target-provider": "Groq"
},
method: "POST",
path: "/v1/chat/completions",
routedModel: "openai/gpt-oss-20b"
});
assert.equal(attempt.body.model, "openai/gpt-oss-20b");
assert.equal(attempt.logicalProvider, "Groq");
});
test("target-provider routing preserves slash model ids for providers without explicit capabilities", () => {
const config = {
Providers: [
{
api_base_url: "https://api.openai.example/v1",
credentials: [{ apiKey: "openai-key", id: "openai-main" }],
id: "openai",
models: ["gpt-oss-20b"],
name: "OpenAI",
type: "openai_chat_completions"
},
{
api_base_url: "https://api.groq.example/openai/v1",
credentials: [{ apiKey: "groq-key", id: "groq-main" }],
id: "groq",
models: ["openai/gpt-oss-20b"],
name: "Groq",
provider: "openai",
type: "openai_chat_completions"
}
],
Router: { fallback: { mode: "off", models: [], retryCount: 0 }, rules: [] },
virtualModelProfiles: []
};
const attempt = prepareGatewayUpstreamAttemptForTest({
body: {
messages: [],
model: "openai/gpt-oss-20b"
},
config,
headers: {
"x-target-provider": "Groq"
},
method: "POST",
path: "/v1/chat/completions",
routedModel: "openai/gpt-oss-20b"
});
assert.equal(attempt.body.model, "openai/gpt-oss-20b");
assert.equal(attempt.logicalProvider, "Groq");
assert.equal(attempt.credentialProtocol, "openai_chat_completions");
assert.equal(attempt.headers["x-target-providers"], "groq::openai_chat_completions::cred:groq-main");
});
test("model-chain fallback rebuilds every protocol attempt from the canonical request", async () => {
const config = {
Providers: [
{
capabilities: [{ baseUrl: "https://anthropic-primary.example", type: "anthropic_messages" }],
id: "anthropic-primary",
models: ["claude-primary"],
name: "Anthropic Primary"
},
{
capabilities: [{ baseUrl: "https://openai-fallback.example", type: "openai_responses" }],
id: "openai-fallback",
models: ["gpt-fallback"],
name: "OpenAI Fallback"
},
{
capabilities: [{ baseUrl: "https://anthropic-recovery.example", type: "anthropic_messages" }],
id: "anthropic-recovery",
models: ["claude-recovery"],
name: "Anthropic Recovery"
}
],
Router: { fallback: { mode: "off", models: [], retryCount: 0 }, rules: [] },
virtualModelProfiles: []
};
const fallback = {
mode: "model-chain",
models: ["OpenAI Fallback/gpt-fallback", "Anthropic Recovery/claude-recovery"],
retryCount: 0
};
const canonicalBody = {
context_management: { edits: [{ type: "clear_tool_uses_20250919" }] },
messages: [{ content: "hello", role: "user" }],
model: "Anthropic Primary/claude-primary",
output_config: { effort: "high", verbosity: "medium" },
system: [{ cache_control: { type: "ephemeral" }, text: "system", type: "text" }],
thinking: { type: "adaptive" }
};
const captured = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (_url, init) => {
captured.push({
body: JSON.parse(init.body),
headers: init.headers
});
const status = captured.length === 1 ? 429 : captured.length === 2 ? 400 : 200;
return new Response('{"ok":true}', {
headers: {
"content-type": "application/json",
"retry-after": "0.001"
},
status
});
};
try {
const trace = new RequestRouteTraceRecorder(Date.now());
const result = await fetchUpstreamWithFallback({
body: Buffer.from(JSON.stringify(canonicalBody)),
config,
coreAuthToken: "core-token",
fallback,
headers: {},
method: "POST",
path: "/v1/messages",
routedModel: canonicalBody.model,
trace,
upstreamUrl: "http://127.0.0.1:3456/v1/messages"
});
assert.equal(result.response.status, 200);
assert.equal(captured.length, 3);
assert.deepEqual(captured.map((attempt) => attempt.body.model), [
"claude-primary",
"gpt-fallback",
"claude-recovery"
]);
assert.deepEqual(captured[0].body.thinking, { type: "adaptive" });
assert.equal(captured[1].body.thinking, undefined);
assert.deepEqual(captured[2].body.thinking, { type: "adaptive" });
assert.deepEqual(captured[2].body.context_management, canonicalBody.context_management);
assert.deepEqual(captured[2].body.output_config, canonicalBody.output_config);
assert.equal(captured[0].headers["x-target-provider"], "anthropic-primary::anthropic_messages");
assert.equal(captured[1].headers["x-target-provider"], "openai-fallback::openai_responses");
assert.equal(captured[2].headers["x-target-provider"], "anthropic-recovery::anthropic_messages");
const finishedTrace = trace.finish();
const capabilityRoutingHops = finishedTrace.hops
.filter((hop) => hop.name === "provider.capability-routing");
assert.deepEqual(
capabilityRoutingHops.map((hop) => hop.attempt),
[1, 2, 3]
);
assert.deepEqual(
capabilityRoutingHops[0].changes.map((change) => change.path),
["/body/model", "/routing/model"]
);
assert.deepEqual(
finishedTrace.hops
.find((hop) => hop.name === "fallback.execution-plan")
?.changes.map((change) => change.path),
["/routing/fallback"]
);
} finally {
globalThis.fetch = originalFetch;
}
});
@@ -6,6 +6,7 @@ import test from "node:test";
import {
localAgentProviderAccountCredentialForTest,
localCodexAccountCredentialForTest,
setProviderAccountWebContentFetchHandler,
testProviderAccountConnector
} from "@ccr/core/providers/account-service.ts";
import {
@@ -101,6 +102,108 @@ test("Grok subscription connector maps access status payload", async (t) => {
assert.equal(result.meters.find((meter) => meter.id === "grok_subscription_access")?.remaining, 100);
});
test("webcontent-json connector uses browser-session handler without provider API key", async (t) => {
let captured;
setProviderAccountWebContentFetchHandler(async (request) => {
captured = request;
return {
payload: {
balance: 42,
message: "Signed in"
}
};
});
t.after(() => {
setProviderAccountWebContentFetchHandler(undefined);
});
const result = await testProviderAccountConnector({
apiKey: "should-not-reach-handler",
baseUrl: "https://vendor.example.com/v1",
connector: {
browser: {
loginUrl: "https://vendor.example.com/login",
requestOrigin: "https://vendor.example.com",
timeoutMs: 12000
},
endpoint: "https://vendor.example.com/api/account",
headers: {
"x-csrf-token": "csrf"
},
mapping: {
meters: [
{
id: "balance",
kind: "balance",
label: "Balance",
remaining: "$.balance",
unit: "USD"
}
],
message: "$.message"
},
type: "webcontent-json"
},
providerName: "Vendor"
});
assert.equal(captured.endpoint, "https://vendor.example.com/api/account");
assert.equal(captured.method, "GET");
assert.equal(captured.requestOrigin, "https://vendor.example.com");
assert.equal(captured.loginUrl, "https://vendor.example.com/login");
assert.equal(captured.provider.api_key, "");
assert.equal(captured.provider.apiKey, undefined);
assert.equal(captured.headers["x-csrf-token"], "csrf");
assert.equal(result.message, "Signed in");
assert.equal(result.meters[0].remaining, 42);
assert.equal(result.meters[0].source, "webcontent-json");
});
test("webcontent-json connector rejects cross-origin browser requests before invoking handler", async (t) => {
let called = false;
setProviderAccountWebContentFetchHandler(async () => {
called = true;
return { payload: {} };
});
t.after(() => {
setProviderAccountWebContentFetchHandler(undefined);
});
await assert.rejects(
() => testProviderAccountConnector({
baseUrl: "https://vendor.example.com/v1",
connector: {
browser: {
requestOrigin: "https://app.vendor.example.com"
},
endpoint: "https://api.vendor.example.com/account",
mapping: { meters: [] },
type: "webcontent-json"
},
providerName: "Vendor"
}),
/origin must match/
);
assert.equal(called, false);
});
test("webcontent-json connector reports unsupported outside CCR Desktop", async () => {
setProviderAccountWebContentFetchHandler(undefined);
await assert.rejects(
() => testProviderAccountConnector({
baseUrl: "https://vendor.example.com/v1",
connector: {
endpoint: "https://vendor.example.com/account",
mapping: { meters: [] },
type: "webcontent-json"
},
providerName: "Vendor"
}),
/only available in CCR Desktop/
);
});
test("Codex local account credential refreshes when only a refresh token is available", async (t) => {
const previousHome = process.env.CCR_INTERNAL_HOME_DIR;
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-codex-account-refresh-"));
@@ -12,7 +12,8 @@ import { detectedProviderFromHeaders, newApiKeyUsageAccountConfig, newApiUserSel
import {
checkGatewayProviderConnectivity,
isProviderProtocolEndpointSupportedForProbe,
probeGatewayProvider
probeGatewayProvider,
probeGatewayProviderCandidates
} from "@ccr/core/providers/probe.ts";
test("protocol support probe does not treat Gemini auth errors as every protocol", () => {
@@ -218,6 +219,99 @@ test("provider probe exposes image and video capabilities when their endpoints r
);
});
test("candidate protocol probe carries the entered API key and Authorization fallback", async (t) => {
const previousFetch = globalThis.fetch;
const calls = [];
globalThis.fetch = async (input, init) => {
const url = new URL(String(input));
const headers = new Headers(init?.headers);
calls.push({
authorization: headers.get("authorization"),
pathname: url.pathname,
protocol: url.pathname.includes("/messages")
? "anthropic"
: url.pathname.includes(":generateContent")
? "gemini"
: "openai",
xApiKey: headers.get("x-api-key"),
xGoogApiKey: headers.get("x-goog-api-key")
});
return new Response(JSON.stringify({ error: { message: "Unauthorized" } }), {
headers: { "content-type": "application/json" },
status: 401
});
};
t.after(() => {
globalThis.fetch = previousFetch;
});
await probeGatewayProviderCandidates({
apiKey: "sk-probe-key",
candidates: [{
baseUrl: "http://127.0.0.1:49124",
protocols: ["openai_chat_completions", "anthropic_messages", "gemini_generate_content"],
source: "custom"
}],
forceRefresh: true,
mode: "protocols",
protocols: ["openai_chat_completions", "anthropic_messages", "gemini_generate_content"]
});
assert.deepEqual(
calls.map((call) => call.protocol),
["openai", "anthropic", "gemini"]
);
assert.equal(calls[0]?.authorization, "Bearer sk-probe-key");
assert.equal(calls[1]?.authorization, "Bearer sk-probe-key");
assert.equal(calls[1]?.xApiKey, "sk-probe-key");
assert.equal(calls[2]?.authorization, "Bearer sk-probe-key");
assert.equal(calls[2]?.xGoogApiKey, "sk-probe-key");
});
test("model discovery carries Authorization fallback for protocol-specific API keys", async (t) => {
const previousFetch = globalThis.fetch;
const calls = [];
globalThis.fetch = async (input, init) => {
const url = new URL(String(input));
const headers = new Headers(init?.headers);
calls.push({
authorization: headers.get("authorization"),
key: url.searchParams.get("key"),
pathname: url.pathname,
xApiKey: headers.get("x-api-key"),
xGoogApiKey: headers.get("x-goog-api-key")
});
return new Response(JSON.stringify({ data: [] }), {
headers: { "content-type": "application/json" },
status: 200
});
};
t.after(() => {
globalThis.fetch = previousFetch;
});
await probeGatewayProvider({
apiKey: "Bearer sk-model-key",
baseUrl: "http://127.0.0.1:49124",
forceRefresh: true,
mode: "models",
protocols: ["openai_chat_completions", "anthropic_messages", "gemini_generate_content"]
});
const modelCalls = calls.filter((call) => call.pathname.endsWith("/models"));
assert.equal(modelCalls.length >= 3, true);
assert.equal(calls.every((call) => call.authorization === "Bearer sk-model-key"), true);
assert.equal(modelCalls.some((call) => call.xApiKey === "sk-model-key"), true);
assert.equal(
modelCalls.some((call) => call.key === "sk-model-key" && call.xGoogApiKey === "sk-model-key"),
true
);
});
test("connectivity probe applies provider plugin auth for local agent imports", async (t) => {
const previousFetch = globalThis.fetch;
let called = false;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@claude-code-router/electron",
"version": "3.0.17",
"version": "3.0.19",
"private": true,
"description": "Claude Code Router Electron desktop shell.",
"author": "musistudio",
+4 -1
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow, dialog, ipcMain, nativeImage, shell, type OpenDialogOptions, type Rectangle, type SaveDialogOptions } from "electron";
import { app, BrowserWindow, dialog, ipcMain, nativeImage, session, shell, WebContentsView, type OpenDialogOptions, type Rectangle, type SaveDialogOptions } from "electron";
import { randomUUID } from "node:crypto";
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import path from "node:path";
@@ -50,12 +50,15 @@ import trayController from "./tray-controller";
import { appUpdateService } from "./update-service";
import { getUsageStats } from "@ccr/core/usage/store";
import { applyNativeThemePreference } from "./native-theme";
import { registerProviderAccountWebContentFetchHandler } from "./provider-account-webcontent";
import windowsManager from "./windows";
import { CLAUDE_DESIGN_PLUGIN_ID, GATEWAY_PLUGIN_PERMISSION_IDS, GATEWAY_PLUGIN_SURFACE_IDS, type AgentAnalysisFilter, type AgentAnalysisTracePayloadRequest, type ApiKeyConfig, type AppCaptureElementPngRequest, type AppCaptureElementPngResult, type AppConfig, type AppDataExportResult, type AppImageExportTargetRequest, type AppImageExportTargetResult, type AppInfo, type AppRenderHtmlPngRequest, type AppRenderHtmlPngResult, type AppSaveConfigOptions, type BotGatewayQrLoginCancelRequest, type BotGatewayQrLoginStartRequest, type BotGatewayQrLoginWaitRequest, type BotGatewayQrWindowCloseRequest, type BotGatewayQrWindowOpenRequest, type GatewayPluginAppConfig, type GatewayPluginPermission, type GatewayPluginSurface, type GatewayProviderConnectivityCheckRequest, type GatewayProviderProbeCandidatesRequest, type GatewayProviderProbeRequest, type GatewayStatus, type LocalAgentProviderImportRequest, type PluginDependency, type PluginDirectorySelection, type ProfileApplyResult, type ProfileOpenRequest, type ProfileOpenResult, type ProviderAccountResetRequest, type ProviderAccountSnapshotRequestOptions, type ProviderAccountTestRequest, type ProviderCatalogModelsRequest, type ProviderIconDetectionRequest, type ProviderManifestFetchRequest, type RequestLogListFilter, type RouteScriptTestRequest, type RouteScriptValidationRequest, type UsageStatsFilter, type UsageStatsRange } from "@ccr/core/contracts/app";
const imageExportTargets = new Map<string, string>();
const gatewayPluginPermissionIdSet = new Set<string>(GATEWAY_PLUGIN_PERMISSION_IDS);
const gatewayPluginSurfaceIdSet = new Set<string>(GATEWAY_PLUGIN_SURFACE_IDS);
registerProviderAccountWebContentFetchHandler({ BrowserWindow, WebContentsView, session });
function applyAppThemePreference(theme: AppConfig["theme"]): void {
applyNativeThemePreference(theme);
trayController.refreshTheme(theme);
@@ -0,0 +1,279 @@
import {
setProviderAccountWebContentFetchHandler,
type ProviderAccountWebContentFetchHandler,
type ProviderAccountWebContentFetchRequest
} from "@ccr/core/providers/account-service";
type WebContentBrowserWindow = {
close: () => void;
contentView?: {
addChildView?: (view: any) => void;
removeChildView?: (view: any) => void;
};
isDestroyed?: () => boolean;
};
type WebContentView = {
setBounds?: (bounds: { height: number; width: number; x: number; y: number }) => void;
webContents: {
executeJavaScript: (code: string, userGesture?: boolean) => Promise<unknown>;
loadURL: (url: string) => Promise<unknown>;
};
};
type WebContentSession = {
forceReloadProxyConfig?: () => Promise<void>;
};
export type ProviderAccountWebContentElectronDeps = {
BrowserWindow: new (options: any) => WebContentBrowserWindow;
WebContentsView: new (options: any) => WebContentView;
session: {
fromPartition: (partition: string) => WebContentSession;
};
};
type WebContentFetchResult = {
byteLength?: number;
contentType?: string;
error?: string;
ok?: boolean;
status?: number;
statusText?: string;
text?: string;
};
const browserPartition = "persist:ccr-built-in-browser";
const defaultTimeoutMs = 15_000;
const maxTimeoutMs = 60_000;
const maxResponseBytes = 2 * 1024 * 1024;
const blockedHeaderNames = new Set([
"content-length",
"cookie",
"cookie2",
"host",
"origin"
]);
export function registerProviderAccountWebContentFetchHandler(deps: ProviderAccountWebContentElectronDeps): void {
setProviderAccountWebContentFetchHandler(createProviderAccountWebContentFetchHandler(deps));
}
export function createProviderAccountWebContentFetchHandler(
deps: ProviderAccountWebContentElectronDeps
): ProviderAccountWebContentFetchHandler {
return async (request) => {
const timeoutMs = normalizeTimeoutMs(request.timeoutMs);
const session = deps.session.fromPartition(browserPartition);
await session.forceReloadProxyConfig?.().catch(() => undefined);
const window = new deps.BrowserWindow({
height: 40,
show: false,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true
},
width: 40
});
const view = new deps.WebContentsView({
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
partition: browserPartition,
sandbox: true,
webSecurity: true
}
});
try {
window.contentView?.addChildView?.(view);
view.setBounds?.({ height: 40, width: 40, x: 0, y: 0 });
await withTimeout(view.webContents.loadURL(request.requestOrigin), timeoutMs, "Timed out loading browser session request origin.");
const result = await withTimeout(
view.webContents.executeJavaScript(webContentFetchScript(request, timeoutMs), true),
timeoutMs + 1000,
"Timed out running browser session account request."
);
return {
payload: parseWebContentFetchResult(result)
};
} finally {
try {
window.contentView?.removeChildView?.(view);
} catch {
// The hidden worker window may already be closing.
}
if (!window.isDestroyed?.()) {
window.close();
}
}
};
}
function webContentFetchScript(request: ProviderAccountWebContentFetchRequest, timeoutMs: number): string {
const serializedRequest = JSON.stringify({
bodyJson: request.method === "POST" ? JSON.stringify(request.body ?? {}) : undefined,
endpoint: request.endpoint,
headers: normalizeRequestHeaders(request.headers),
maxResponseBytes,
method: request.method,
timeoutMs
});
return `
(async () => {
const request = ${serializedRequest};
const headers = { ...request.headers };
if (request.method === "POST" && !Object.keys(headers).some((key) => key.toLowerCase() === "content-type")) {
headers["content-type"] = "application/json";
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), request.timeoutMs);
try {
const response = await fetch(request.endpoint, {
body: request.method === "POST" ? request.bodyJson : undefined,
cache: "no-store",
credentials: "include",
headers,
method: request.method,
signal: controller.signal
});
const contentType = response.headers.get("content-type") || "";
const text = await response.text();
const byteLength = new TextEncoder().encode(text).byteLength;
return {
byteLength,
contentType,
ok: response.ok,
status: response.status,
statusText: response.statusText,
text: byteLength <= request.maxResponseBytes ? text : ""
};
} catch (error) {
return {
error: error && typeof error === "object" && "message" in error ? String(error.message) : String(error)
};
} finally {
clearTimeout(timer);
}
})()
`;
}
function normalizeRequestHeaders(headers: Record<string, string> | undefined): Record<string, string> {
const output: Record<string, string> = {
accept: "application/json"
};
for (const [key, value] of Object.entries(headers ?? {})) {
const normalizedKey = key.trim();
if (!normalizedKey || blockedHeaderNames.has(normalizedKey.toLowerCase())) {
continue;
}
if (typeof value === "string") {
output[normalizedKey] = value;
}
}
return output;
}
function parseWebContentFetchResult(value: unknown): unknown {
if (!isRecord(value)) {
throw new Error("Browser session account request returned an invalid worker result.");
}
const result = value as WebContentFetchResult;
if (result.error) {
throw new Error(`Browser session account request failed: ${result.error}`);
}
if (Number(result.byteLength) > maxResponseBytes) {
throw new Error(`Browser session account endpoint returned more than ${maxResponseBytes} bytes.`);
}
const text = typeof result.text === "string" ? result.text : "";
if (!result.ok) {
const errorMessage = jsonErrorMessage(text) || readableResponseSnippet(text) || result.statusText;
throw new Error(`Account endpoint returned HTTP ${result.status ?? 0}${errorMessage ? `: ${errorMessage}` : ""}.`);
}
if (!responseLooksJson(result.contentType ?? "", text)) {
throw new Error(`Account endpoint returned non-JSON response${result.contentType ? ` (${result.contentType.split(";")[0]})` : ""}.`);
}
try {
return JSON.parse(text) as unknown;
} catch {
throw new Error("Account endpoint returned malformed JSON.");
}
}
function responseLooksJson(contentType: string, text: string): boolean {
const normalizedContentType = contentType.toLowerCase();
if (normalizedContentType.includes("json")) {
return true;
}
return /^[\s]*[\[{]/.test(text);
}
function jsonErrorMessage(text: string): string | undefined {
if (!responseLooksJson("", text)) {
return undefined;
}
try {
const payload = JSON.parse(text) as unknown;
if (!isRecord(payload)) {
return undefined;
}
const message = readString(payload.message) || readString(payload.detail);
if (message) {
return message;
}
if (isRecord(payload.error)) {
return readString(payload.error.message) || readString(payload.error.type);
}
return readString(payload.error);
} catch {
return undefined;
}
}
function readableResponseSnippet(text: string): string | undefined {
const compact = text.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
if (!compact) {
return undefined;
}
return compact.length > 160 ? `${compact.slice(0, 157)}...` : compact;
}
function normalizeTimeoutMs(value: number | undefined): number {
return Math.max(1, Math.min(maxTimeoutMs, Number.isFinite(value) ? Number(value) : defaultTimeoutMs));
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
})
]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export const providerAccountWebContentInternalsForTest = {
maxResponseBytes,
normalizeRequestHeaders,
parseWebContentFetchResult,
webContentFetchScript
};
@@ -0,0 +1,128 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createProviderAccountWebContentFetchHandler,
providerAccountWebContentInternalsForTest
} from "@ccr/electron/main/provider-account-webcontent.ts";
test("browser account fetch handler uses built-in browser partition and sanitizes manual cookie headers", async () => {
let browserWindowOptions: any;
let viewOptions: any;
let loadedUrl = "";
let scriptRequest: any;
let closed = false;
class FakeBrowserWindow {
contentView = {
addChildView: () => undefined,
removeChildView: () => undefined
};
constructor(options: any) {
browserWindowOptions = options;
}
close() {
closed = true;
}
isDestroyed() {
return false;
}
}
class FakeWebContentsView {
webContents = {
executeJavaScript: async (script: string) => {
scriptRequest = extractSerializedRequest(script);
return {
byteLength: 14,
contentType: "application/json",
ok: true,
status: 200,
statusText: "OK",
text: "{\"balance\":10}"
};
},
loadURL: async (url: string) => {
loadedUrl = url;
}
};
constructor(options: any) {
viewOptions = options;
}
setBounds() {
return undefined;
}
}
const handler = createProviderAccountWebContentFetchHandler({
BrowserWindow: FakeBrowserWindow,
WebContentsView: FakeWebContentsView,
session: {
fromPartition(partition: string) {
assert.equal(partition, "persist:ccr-built-in-browser");
return {};
}
}
});
const result = await handler({
endpoint: "https://vendor.example.com/api/account",
headers: {
cookie: "session=secret",
"x-csrf-token": "csrf"
},
method: "GET",
provider: {
models: [],
name: "Vendor"
},
requestOrigin: "https://vendor.example.com"
});
assert.deepEqual(result.payload, { balance: 10 });
assert.equal(loadedUrl, "https://vendor.example.com");
assert.equal(closed, true);
assert.equal(browserWindowOptions.show, false);
assert.equal(viewOptions.webPreferences.partition, "persist:ccr-built-in-browser");
assert.equal(scriptRequest.endpoint, "https://vendor.example.com/api/account");
assert.equal(scriptRequest.headers.accept, "application/json");
assert.equal(scriptRequest.headers.cookie, undefined);
assert.equal(scriptRequest.headers["x-csrf-token"], "csrf");
});
test("browser account fetch parser reports HTTP JSON errors", () => {
assert.throws(
() => providerAccountWebContentInternalsForTest.parseWebContentFetchResult({
byteLength: 29,
contentType: "application/json",
ok: false,
status: 401,
statusText: "Unauthorized",
text: "{\"error\":{\"message\":\"login\"}}"
}),
/HTTP 401: login/
);
});
test("browser account fetch parser rejects oversized responses", () => {
assert.throws(
() => providerAccountWebContentInternalsForTest.parseWebContentFetchResult({
byteLength: providerAccountWebContentInternalsForTest.maxResponseBytes + 1,
contentType: "application/json",
ok: true,
status: 200,
text: ""
}),
/more than/
);
});
function extractSerializedRequest(script: string): any {
const match = script.match(/const request = (\{[\s\S]*?\});\s+const headers/);
assert.ok(match?.[1]);
return JSON.parse(match[1]);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@claude-code-router/ui",
"version": "3.0.17",
"version": "3.0.19",
"private": true,
"description": "Claude Code Router web management UI.",
"scripts": {
+7 -2
View File
@@ -1263,7 +1263,8 @@ function App() {
}
useEffect(() => {
if (!window.ccr || !providerAddOpen) {
const providerFormVisible = providerAddOpen || (activeView === "onboarding" && onboardingStep === "provider");
if (!window.ccr || !providerFormVisible) {
return;
}
if (providerDraft.protocolDetectionMode === "manual") {
@@ -1369,7 +1370,11 @@ function App() {
return applyProviderProbeResult(current, result.probe);
});
if (probeMode !== "models" && !providerProbeHasSupportedProtocol(result.probe)) {
// In "models" mode the probe still reports protocol support, so a rejected API key
// surfaces here as unsupported protocols and an empty catalog. Report it instead of
// leaving the model picker silently empty, but stay quiet when models were discovered
// (a provider can expose a working catalog while a protocol probe endpoint 404s).
if (!providerProbeHasSupportedProtocol(result.probe) && (probeMode !== "models" || result.probe.models.length === 0)) {
const message = result.probe.protocols.find((item) => item.message)?.message || "Request failed.";
setProviderProbeError(translateAppErrorMessage(copy, message));
}
@@ -2374,7 +2374,14 @@ function ProviderAccountsOverview({
})}
</div>
) : (
<div className={cn("grid h-full min-h-0 grid-cols-1 overflow-y-auto pr-1", providerAccountGapClass(dimensions), providerAccountGridClass(dimensions, visibleAccounts.length))}>
<div
className={cn(
"grid h-full min-h-0 auto-rows-max content-start grid-cols-1 overflow-y-auto pb-2 pr-2 [scrollbar-gutter:stable]",
providerAccountGapClass(dimensions),
providerAccountGridClass(dimensions, visibleAccounts.length)
)}
data-provider-account-grid="true"
>
{visibleAccounts.map((account) => {
return <ProviderAccountSummaryCard account={account} dimensions={dimensions} key={providerAccountSnapshotKey(account)} refreshing={refreshing} variant={variant} onRefresh={onRefresh} />;
})}
@@ -2406,7 +2413,7 @@ function ProviderAccountSinglePanel({
return (
<div className={cn("flex h-full min-h-0 min-w-0 flex-col overflow-hidden", providerAccountStackClass(dimensions))}>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="flex min-w-0 shrink-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className={cn("truncate font-semibold", dimensions.height <= 1 ? "text-[12px]" : "text-[13px]")}>{providerAccountSnapshotLabel(account)}</div>
{providerAccountShowRefreshTime(dimensions) ? <div className="mt-0.5 truncate text-[11px] text-muted-foreground">{formatProviderAccountRefreshTime(account, t)}</div> : null}
@@ -2453,7 +2460,7 @@ function ProviderAccountSummaryCard({
const showQuotaVisual = providerAccountUsesQuotaVisual(variant) && quotaMeters.length > 0;
return (
<div className={cn("overview-nested-surface min-h-0 min-w-0 overflow-hidden border", providerAccountCardPaddingClass(dimensions))}>
<div className={cn("overview-nested-surface h-fit min-w-0 self-start overflow-hidden border", providerAccountCardPaddingClass(dimensions))}>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-[13px] font-semibold">{providerAccountSnapshotLabel(account)}</div>
@@ -2470,7 +2477,7 @@ function ProviderAccountSummaryCard({
<ProviderAccountBalanceMetric dimensions={dimensions} meter={balanceMeter} compact />
</div>
) : meters.length > 0 ? (
<div className={cn("mt-2 min-h-0 overflow-hidden", providerAccountStackClass(dimensions))}>
<div className={cn("mt-2 min-h-0", providerAccountStackClass(dimensions))}>
{meters.map((meter) => (
<ProviderAccountMeterLine account={account} dimensions={dimensions} key={meter.id} meter={meter} onRefresh={onRefresh} />
))}
@@ -2563,7 +2570,7 @@ function ProviderAccountMeterLine({
) : null}
<div className={titleClassName}>{title}</div>
</div>
<div className={valueClassName}>{formatProviderAccountMeterValue(meter)}</div>
<div className={valueClassName}>{formatProviderAccountMeterValue(meter, t)}</div>
</>
);
@@ -195,7 +195,7 @@ export function MainLayout({
}}
aria-hidden={!sidebarOpen}
className={cn(
"app-sidebar flex shrink-0 flex-col overflow-hidden bg-sidebar/95 max-[720px]:h-auto",
"app-sidebar flex min-h-0 shrink-0 flex-col overflow-hidden bg-sidebar/95 max-[720px]:h-auto",
sidebarOpen && compactLayout && "border-b border-border"
)}
id="primary-sidebar"
@@ -217,7 +217,7 @@ export function MainLayout({
<div className="app-drag min-w-0 flex-1" />
</div>
<nav className="flex min-h-0 flex-1 flex-col gap-4 px-2 py-3 max-[720px]:flex-none max-[720px]:flex-row max-[720px]:gap-1 max-[720px]:overflow-x-auto max-[720px]:py-2" aria-label={copy.sidebar.primaryNavigation}>
<nav className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-2 py-3 max-[720px]:flex-none max-[720px]:flex-row max-[720px]:gap-1 max-[720px]:overflow-x-auto max-[720px]:overflow-y-hidden max-[720px]:py-2" aria-label={copy.sidebar.primaryNavigation}>
{navigationGroups.map((group) => (
<div className="grid min-w-0 gap-1 max-[720px]:contents" key={group.id}>
<div className="px-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/65 max-[720px]:hidden">
@@ -4,10 +4,12 @@ import type { RequestRouteTrace, RequestRouteTraceChange, RequestRouteTraceHop }
import {
AnimatedIconSwap, Check, ChevronDown, ChevronLeft,
ChevronRight, clampNumber, clientInitial, cn, Copy, copyTextToClipboard,
Database, Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle, filterLogText, formatBytes, formatCompactNumber, formatDuration,
formatLogBodyView, formatLogDateTime, formatLogTokenSummary, formatNetworkRequestRaw, formatNetworkResponseRaw, formatRouteTracePath, formatUsdCost,
isJsonContainer, jsonChildPath, logRequestModel,
logResponseModel, logSelectOptions, motion, MoveRight, Network, networkCodeLabel,
createLogBodyPreviewText, Database, Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle, formatBytes, formatCompactNumber, formatDuration,
formatLogDateTime, formatLogTokenSummary, formatNetworkRequestRaw, formatNetworkResponseRaw, formatRouteTracePath, formatUsdCost,
FormattedLogBody,
isJsonContainer, isLargeLogBody, jsonChildPath, logRequestModel,
LogBodyFormatMode, logBodyLargeTextThreshold, logBodyPreviewTextLimit, LogBodyWorkerResponse,
logResolvedRouteModel, logSelectOptions, motion, MoveRight, Network, networkCodeLabel,
networkExchangeMatchesQuery, networkHeaderRows, networkLifecycleLabel, networkQueryRows, networkRowId, networkSummaryRows,
Pause, Play, ProxyNetworkBody, ProxyNetworkExchange, ProxyNetworkSnapshot, ProxyStatus,
ReactNode, ReactPointerEvent, RefreshCw, RequestLogBody, RequestLogEntry, RequestLogListFilter,
@@ -19,11 +21,10 @@ import { TooltipPortal } from "@/components/ui/tooltip";
type NetworkRequestTab = "body" | "header" | "query" | "raw" | "summary";
type NetworkResponseTab = "body" | "header" | "raw";
const logBodyViewCacheLimit = 12;
const logJsonAutoExpandEntryLimit = 60;
const logJsonContainerPreviewLimit = 80;
const logJsonAutoExpandTextLimit = 160 * 1024;
const logBodyViewCache = new Map<string, ReturnType<typeof formatLogBodyView>>();
const logBodyWorkerFilterDebounceMs = 180;
type LogTableColumnId = "time" | "status" | "stream" | "model" | "credential" | "tokens" | "duration";
type LogTableColumn = {
id: LogTableColumnId;
@@ -842,10 +843,10 @@ function LogMobileCard({
</div>
<div className="mt-2 min-w-0 text-[11px] text-muted-foreground">
<div className="truncate font-mono" title={createdAt}>{createdAt}</div>
<div className="mt-1 flex min-w-0 items-center gap-1" title={`${logRequestModel(item)} -> ${logResponseModel(item)}`}>
<div className="mt-1 flex min-w-0 items-center gap-1" title={`${logRequestModel(item)} -> ${logResolvedRouteModel(item)}`}>
<span className="min-w-0 truncate">{logRequestModel(item)}</span>
<MoveRight className="h-3 w-3 shrink-0" aria-hidden="true" />
<span className="min-w-0 truncate">{logResponseModel(item)}</span>
<span className="min-w-0 truncate">{logResolvedRouteModel(item)}</span>
</div>
</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-[11px]">
@@ -1378,8 +1379,8 @@ function LogMetric({ label, value }: { label: string; value: string }) {
function LogModelRouteCell({ entry }: { entry: RequestLogEntry }) {
const requestModel = logRequestModel(entry);
const responseModel = logResponseModel(entry);
return <LogModelTooltip requestModel={requestModel} responseModel={responseModel} />;
const resolvedModel = logResolvedRouteModel(entry);
return <LogModelTooltip requestModel={requestModel} resolvedModel={resolvedModel} />;
}
type LogModelTooltipState = {
@@ -1391,14 +1392,14 @@ type LogModelTooltipState = {
function LogModelTooltip({
requestModel,
responseModel
resolvedModel
}: {
requestModel: string;
responseModel: string;
resolvedModel: string;
}) {
const triggerRef = useRef<HTMLDivElement>(null);
const [tooltip, setTooltip] = useState<LogModelTooltipState>();
const value = `${requestModel} -> ${responseModel}`;
const value = `${requestModel} -> ${resolvedModel}`;
useEffect(() => {
if (!tooltip) return;
@@ -1443,7 +1444,7 @@ function LogModelTooltip({
>
<span className="min-w-0 max-w-[45%] truncate">{requestModel}</span>
<MoveRight className="mx-1 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="min-w-0 max-w-[45%] truncate">{responseModel}</span>
<span className="min-w-0 max-w-[45%] truncate">{resolvedModel}</span>
</div>
{tooltip ? (
<TooltipPortal
@@ -1496,6 +1497,247 @@ function LogStreamCell({ entry }: { entry: RequestLogEntry }) {
type LogPayloadTab = "body" | "header";
type LogBodyPanelView = FormattedLogBody & {
bodyKey: string;
error: string;
formattedTextLength: number;
large: boolean;
loading: boolean;
mode: LogBodyFormatMode;
preview: boolean;
query: string;
sourceSizeBytes: number;
visible: string;
};
function useLogBodyWorkerView(
body: RequestLogBody | undefined,
bodyKey: string,
mode: LogBodyFormatMode,
query: string
): LogBodyPanelView {
const debouncedQuery = useDebouncedValue(query, logBodyWorkerFilterDebounceMs);
const latestQueryRef = useRef(debouncedQuery);
const workerRef = useRef<Worker>();
const formatRequestIdRef = useRef(0);
const filterRequestIdRef = useRef(0);
const [bodyView, setBodyView] = useState<LogBodyPanelView>(() => createInitialLogBodyPanelView(body, bodyKey, mode, query));
useEffect(() => {
latestQueryRef.current = debouncedQuery;
}, [debouncedQuery]);
useEffect(() => {
const initial = createInitialLogBodyPanelView(body, bodyKey, mode, latestQueryRef.current);
setBodyView(initial);
workerRef.current?.terminate();
workerRef.current = undefined;
if (isStaticLogBody(body)) {
setBodyView(createStaticLogBodyPanelView(body, bodyKey, mode, latestQueryRef.current));
return;
}
if (typeof Worker === "undefined") {
setBodyView({
...initial,
error: "Body formatter worker is unavailable.",
loading: false,
visible: initial.visible || "Body formatter worker is unavailable."
});
return;
}
const worker = createLogBodyFormatterWorker();
const formatRequestId = formatRequestIdRef.current + 1;
formatRequestIdRef.current = formatRequestId;
filterRequestIdRef.current += 1;
workerRef.current = worker;
worker.onmessage = (event: MessageEvent<LogBodyWorkerResponse>) => {
const response = event.data;
if (response.kind === "format-result") {
if (response.id !== formatRequestIdRef.current || response.bodyKey !== bodyKey || response.mode !== mode) {
return;
}
setBodyView(logBodyPanelViewFromWorkerResult(response));
if (response.query !== latestQueryRef.current) {
postLogBodyFilter(worker, bodyKey, mode, latestQueryRef.current, filterRequestIdRef);
}
return;
}
if (response.kind === "filter-result") {
if (response.id !== filterRequestIdRef.current || response.bodyKey !== bodyKey || response.mode !== mode) {
return;
}
setBodyView((current) => current.bodyKey === bodyKey && current.mode === mode
? { ...current, query: response.query, visible: response.visible }
: current);
return;
}
if (
(response.operation === "format" && response.id === formatRequestIdRef.current) ||
(response.operation === "filter" && response.id === filterRequestIdRef.current)
) {
setBodyView((current) => current.bodyKey === bodyKey && current.mode === mode
? { ...current, error: response.message, loading: false, visible: current.visible || response.message }
: current);
}
};
worker.onerror = (event) => {
setBodyView((current) => current.bodyKey === bodyKey && current.mode === mode
? { ...current, error: event.message || "Body formatter worker failed.", loading: false }
: current);
};
worker.postMessage({
body,
bodyKey,
id: formatRequestId,
kind: "format",
largeTextThreshold: logBodyLargeTextThreshold,
mode,
previewTextLimit: logBodyPreviewTextLimit,
query: latestQueryRef.current
});
return () => {
if (workerRef.current === worker) {
workerRef.current = undefined;
}
worker.terminate();
};
}, [body, bodyKey, mode]);
useEffect(() => {
const worker = workerRef.current;
if (!worker || bodyView.loading || bodyView.bodyKey !== bodyKey || bodyView.mode !== mode || bodyView.query === debouncedQuery) {
return;
}
postLogBodyFilter(worker, bodyKey, mode, debouncedQuery, filterRequestIdRef);
}, [bodyKey, bodyView.bodyKey, bodyView.loading, bodyView.mode, bodyView.query, debouncedQuery, mode]);
return bodyView;
}
function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = window.setTimeout(() => setDebounced(value), delayMs);
return () => window.clearTimeout(timer);
}, [delayMs, value]);
return debounced;
}
function createLogBodyFormatterWorker(): Worker {
return new Worker(new URL("../../assets/log-body.worker.js", window.location.href), { type: "module" });
}
function postLogBodyFilter(
worker: Worker,
bodyKey: string,
mode: LogBodyFormatMode,
query: string,
idRef: { current: number }
) {
const id = idRef.current + 1;
idRef.current = id;
worker.postMessage({
bodyKey,
id,
kind: "filter",
mode,
query
});
}
function createInitialLogBodyPanelView(
body: RequestLogBody | undefined,
bodyKey: string,
mode: LogBodyFormatMode,
query: string
): LogBodyPanelView {
if (isStaticLogBody(body)) {
return createStaticLogBodyPanelView(body, bodyKey, mode, query);
}
const large = isLargeLogBody(body, logBodyLargeTextThreshold);
const preview = large && mode !== "full";
const text = preview
? createLogBodyPreviewText(body, logBodyPreviewTextLimit)
: "Loading body...";
return {
bodyKey,
error: "",
formattedTextLength: text.length,
large,
loading: true,
mode,
preview,
query,
sourceSizeBytes: body?.sizeBytes ?? 0,
text,
visible: text
};
}
function createStaticLogBodyPanelView(
body: RequestLogBody | undefined,
bodyKey: string,
mode: LogBodyFormatMode,
query: string
): LogBodyPanelView {
const text = body?.text || "No body";
return {
bodyKey,
error: "",
formattedTextLength: text.length,
large: false,
loading: false,
mode,
preview: false,
query,
sourceSizeBytes: body?.sizeBytes ?? 0,
text,
visible: filterStaticLogBodyText(text, query)
};
}
function logBodyPanelViewFromWorkerResult(result: Extract<LogBodyWorkerResponse, { kind: "format-result" }>): LogBodyPanelView {
return {
bodyKey: result.bodyKey,
error: "",
formattedTextLength: result.formattedTextLength,
json: result.json,
large: result.large,
loading: false,
mode: result.mode,
preview: result.preview,
query: result.query,
sourceSizeBytes: result.sourceSizeBytes,
text: result.text,
visible: result.visible
};
}
function isStaticLogBody(body: RequestLogBody | undefined): boolean {
return !body || (!body.text && body.sizeBytes === 0);
}
function filterStaticLogBodyText(text: string, query: string): string {
const normalized = query.trim().toLowerCase();
if (!normalized) {
return text;
}
return text.toLowerCase().includes(normalized) ? text : "No matching lines";
}
function LogJsonPanel({
body,
className,
@@ -1514,20 +1756,25 @@ function LogJsonPanel({
const t = useAppText();
const [selectedTab, setSelectedTab] = useState<LogPayloadTab>("body");
const [preferTextBody, setPreferTextBody] = useState(false);
const [bodyMode, setBodyMode] = useState<LogBodyFormatMode>("preview");
const [fullscreenOpen, setFullscreenOpen] = useState(false);
const [query, setQuery] = useState("");
const bodyKey = logBodyCacheKey(body);
const bodyView = useMemo(() => cachedFormatLogBodyView(bodyKey, body), [bodyKey]);
const bodyView = useLogBodyWorkerView(body, bodyKey, bodyMode, query);
const formatted = bodyView.text;
const visible = useMemo(() => filterLogText(formatted, query), [formatted, query]);
const visible = bodyView.visible;
const headerRows = useMemo(() => networkHeaderRows(headers ?? {}), [headers]);
const [expandedJsonPaths, setExpandedJsonPaths] = useState<Set<string>>(() => createInitialVisibleJsonPaths(bodyView));
const showJsonTree = bodyView.json !== undefined && query.trim() === "" && !preferTextBody;
const showJsonTree = bodyView.json !== undefined && query.trim() === "" && !preferTextBody && !bodyView.preview;
useEffect(() => {
setPreferTextBody(false);
setBodyMode("preview");
}, [bodyKey]);
useEffect(() => {
setExpandedJsonPaths(createInitialVisibleJsonPaths(bodyView));
setPreferTextBody(false);
}, [bodyKey]);
}, [bodyView.bodyKey, bodyView.json, bodyView.text]);
useEffect(() => {
if (!fullscreenOpen) {
@@ -1582,6 +1829,7 @@ function LogJsonPanel({
<LogJsonBodyToolbar
body={body}
bodyView={bodyView}
onLoadFullBody={() => setBodyMode("full")}
onQueryChange={setQuery}
onToggleTextBody={() => setPreferTextBody((current) => !current)}
preferTextBody={preferTextBody}
@@ -1610,6 +1858,7 @@ function LogJsonPanel({
copyText={formatted}
expandedJsonPaths={expandedJsonPaths}
onClose={() => setFullscreenOpen(false)}
onLoadFullBody={() => setBodyMode("full")}
onQueryChange={setQuery}
onToggleJsonPath={toggleJsonPath}
onToggleTextBody={() => setPreferTextBody((current) => !current)}
@@ -1636,6 +1885,7 @@ function LogJsonPanel({
function LogJsonBodyToolbar({
body,
bodyView,
onLoadFullBody,
onQueryChange,
onToggleTextBody,
preferTextBody,
@@ -1643,7 +1893,8 @@ function LogJsonBodyToolbar({
title
}: {
body?: RequestLogBody;
bodyView: ReturnType<typeof formatLogBodyView>;
bodyView: LogBodyPanelView;
onLoadFullBody: () => void;
onQueryChange: (value: string) => void;
onToggleTextBody: () => void;
preferTextBody: boolean;
@@ -1651,6 +1902,12 @@ function LogJsonBodyToolbar({
title: string;
}) {
const t = useAppText();
const canLoadFullBody = bodyView.preview && bodyView.large && query.trim() === "";
const canToggleJsonText = bodyView.json !== undefined && query.trim() === "";
const showToggleButton = canLoadFullBody || canToggleJsonText;
const toggleLabel = canLoadFullBody
? bodyView.loading && bodyView.mode === "full" ? t("Loading full payload...") : t("Show full content")
: preferTextBody ? "JSON" : t("Show full content");
return (
<div className="network-body-meta flex min-h-9 shrink-0 items-center gap-2 border-b px-3 py-1.5">
@@ -1664,15 +1921,20 @@ function LogJsonBodyToolbar({
value={query}
/>
</div>
{bodyView.json !== undefined && query.trim() === "" ? (
{showToggleButton ? (
<button
className="network-tab shrink-0 border-0 bg-transparent p-0 text-[11px] font-semibold outline-none"
onClick={onToggleTextBody}
disabled={bodyView.loading && bodyView.mode === "full"}
onClick={canLoadFullBody ? onLoadFullBody : onToggleTextBody}
type="button"
>
{preferTextBody ? "JSON" : t("Show full content")}
{toggleLabel}
</button>
) : null}
{bodyView.loading ? <span className="network-muted shrink-0 text-[11px] font-semibold">{t("Loading full payload...")}</span> : null}
{bodyView.error ? <span className="network-error-box shrink-0 rounded px-2 py-0.5 text-[11px] font-semibold">{bodyView.error}</span> : null}
{bodyView.preview ? <span className="network-service-paused rounded-full px-2 py-0.5 text-[11px] font-semibold">{t("preview")}</span> : null}
{bodyView.sourceSizeBytes > 0 ? <span className="network-muted hidden shrink-0 text-[11px] font-semibold sm:inline">{formatBytes(bodyView.sourceSizeBytes)}</span> : null}
{body?.contentType ? <span className="network-muted hidden shrink-0 text-[11px] font-semibold sm:inline">{body.contentType}</span> : null}
{body?.truncated ? <span className="network-service-paused rounded-full px-2 py-0.5 text-[11px] font-semibold">{t("truncated")}</span> : null}
</div>
@@ -1706,6 +1968,7 @@ function LogJsonFullscreenViewer({
copyText,
expandedJsonPaths,
onClose,
onLoadFullBody,
onQueryChange,
onToggleJsonPath,
onToggleTextBody,
@@ -1718,11 +1981,12 @@ function LogJsonFullscreenViewer({
visible
}: {
body?: RequestLogBody;
bodyView: ReturnType<typeof formatLogBodyView>;
bodyView: LogBodyPanelView;
copyLabel: string;
copyText: string;
expandedJsonPaths: Set<string>;
onClose: () => void;
onLoadFullBody: () => void;
onQueryChange: (value: string) => void;
onToggleJsonPath: (path: string) => void;
onToggleTextBody: () => void;
@@ -1760,6 +2024,7 @@ function LogJsonFullscreenViewer({
<LogJsonBodyToolbar
body={body}
bodyView={bodyView}
onLoadFullBody={onLoadFullBody}
onQueryChange={onQueryChange}
onToggleTextBody={onToggleTextBody}
preferTextBody={preferTextBody}
@@ -1798,27 +2063,7 @@ function logBodyCacheKey(body: RequestLogBody | undefined): string {
].join("\u001f");
}
function cachedFormatLogBodyView(key: string, body: RequestLogBody | undefined): ReturnType<typeof formatLogBodyView> {
const cached = logBodyViewCache.get(key);
if (cached) {
logBodyViewCache.delete(key);
logBodyViewCache.set(key, cached);
return cached;
}
const value = formatLogBodyView(body);
logBodyViewCache.set(key, value);
while (logBodyViewCache.size > logBodyViewCacheLimit) {
const oldest = logBodyViewCache.keys().next().value;
if (!oldest) {
break;
}
logBodyViewCache.delete(oldest);
}
return value;
}
function createInitialVisibleJsonPaths(bodyView: ReturnType<typeof formatLogBodyView>): Set<string> {
function createInitialVisibleJsonPaths(bodyView: FormattedLogBody): Set<string> {
if (!isJsonContainer(bodyView.json)) {
return new Set();
}
@@ -6,7 +6,7 @@ import {
useState,
UserRound, X
} from "../shared/index";
import { AddProviderForm, providerSetupStepIds, type ProviderSetupStepId } from "./providers";
import { AddProviderForm, ProviderConnectivityCheckDialog, providerSetupStepIds, type ProviderSetupStepId } from "./providers";
import { AddProfileForm } from "./profiles";
type OnboardingMascotTone = "cyan" | "orange" | "violet";
@@ -69,7 +69,7 @@ export function OnboardingView({
config: AppConfig;
endpoint: string;
gatewayStatus: GatewayStatus;
onCheckProvider: () => Promise<ProviderConnectivityCheckReport>;
onCheckProvider: (models?: string[]) => Promise<ProviderConnectivityCheckReport>;
onChangeProfile: (patch: Partial<AddProfileDraft>) => void;
onChangeProvider: (patch: Partial<AddProviderDraft>, resetProbe?: boolean) => void;
onComplete: () => void | Promise<void>;
@@ -88,6 +88,7 @@ export function OnboardingView({
}) {
const t = useAppText();
const shouldReduceMotion = useReducedMotion();
const [providerCheckOpen, setProviderCheckOpen] = useState(false);
const [providerIconDetecting, setProviderIconDetecting] = useState(false);
const [providerSetupStep, setProviderSetupStep] = useState<ProviderSetupStepId>("provider");
const providerReady = isOnboardingProviderReady(config);
@@ -105,7 +106,8 @@ export function OnboardingView({
? providerDraftHasReadyCredentialPool(providerDraft)
: providerDraft.apiKey.trim()
);
const providerModelsReady = mergeProviderModelLists(providerDraft.selectedModels, splitLines(providerDraft.modelsText)).length > 0;
const providerCheckModels = mergeProviderModelLists(providerDraft.selectedModels, splitLines(providerDraft.modelsText));
const providerModelsReady = providerCheckModels.length > 0;
const providerSetupIndex = Math.max(0, providerSetupStepIds.indexOf(providerSetupStep));
const previousProviderSetupStep = activeStep === "provider" ? providerSetupStepIds[providerSetupIndex - 1] : undefined;
const nextProviderSetupStep = activeStep === "provider" ? providerSetupStepIds[providerSetupIndex + 1] : undefined;
@@ -260,7 +262,7 @@ export function OnboardingView({
error={providerError}
activeStep={providerSetupStep}
mode={providerReady ? "edit" : "add"}
onCheck={onCheckProvider}
onCheck={async () => setProviderCheckOpen(true)}
onChange={onChangeProvider}
onIconDetectingChange={setProviderIconDetecting}
onSelectStep={(step) => {
@@ -330,6 +332,15 @@ export function OnboardingView({
</motion.div>
</div>
</div>
{providerCheckOpen ? (
<ProviderConnectivityCheckDialog
connectivityLoading={providerConnectivityLoading}
models={providerCheckModels}
onCheck={onCheckProvider}
onClose={() => setProviderCheckOpen(false)}
/>
) : null}
</motion.div>
);
}
@@ -954,7 +954,10 @@ export function AddProfileForm({
{showAdvancedSettings ? (
<div className="sm:col-span-2">
<button
className="flex min-h-9 w-full min-w-0 items-center justify-between gap-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-left outline-none transition-colors hover:bg-muted/35 focus-visible:ring-2 focus-visible:ring-ring/25"
className={cn(
"flex min-h-9 w-full min-w-0 items-center justify-between gap-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-left outline-none transition-colors hover:bg-muted/35 focus-visible:ring-2 focus-visible:ring-ring/25",
advancedOpen && "rounded-b-none"
)}
onClick={() => setAdvancedOpen((current) => !current)}
type="button"
>
@@ -978,26 +981,15 @@ export function AddProfileForm({
initial={{ height: 0, opacity: 0 }}
transition={{ duration: 0.16 }}
>
<div className="mt-3 grid grid-cols-1 gap-3 rounded-md border border-border bg-background/60 p-3 sm:grid-cols-2">
<div className="grid grid-cols-1 gap-3 rounded-b-md border border-t-0 border-border bg-background/60 p-3 sm:grid-cols-2">
{draft.agent === "claude-code" || draft.agent === "codex" ? (
<ProfileEnhancedRouteSetting draft={draft} onChange={onChange} />
) : null}
<ProfileRoutingSettings
draft={draft}
onChange={onChange}
providers={providers}
/>
{showAppPathField && appPathLabel ? (
<Field className="sm:col-span-2" label={t(appPathLabel)} requirement="optional" requirementLabel={optionalFieldLabel}>
<div className={cn(
"rounded-md border border-border bg-background p-1 transition-colors",
appPathDragActive ? "border-primary bg-primary/5" : "border-border"
)}>
<Input
placeholder={t("Drop the app here or paste the executable path")}
value={draft.appPath}
onChange={(event) => onChange({ appPath: event.target.value })}
/>
</div>
</Field>
) : null}
{draft.agent !== "claude-code" && draft.agent !== "grok" && draft.agent !== "kimi" && draft.agent !== "pi" ? (
<>
<Field label={t("Provider ID")} requirement="required" requirementLabel={requiredFieldLabel}>
@@ -1032,6 +1024,16 @@ export function AddProfileForm({
{validation.handoff ? <ProfileFieldHint>{t(validation.handoff)}</ProfileFieldHint> : null}
</div>
) : null}
{showAppPathField && appPathLabel ? (
<Field className="sm:col-span-2" label={t(appPathLabel)} requirement="optional" requirementLabel={optionalFieldLabel}>
<Input
className={appPathDragActive ? "border-primary bg-primary/5" : undefined}
placeholder={t("Drop the app here or paste the executable path")}
value={draft.appPath}
onChange={(event) => onChange({ appPath: event.target.value })}
/>
</Field>
) : null}
<Field className="sm:col-span-2" label={t("Environment variables")} requirement="optional" requirementLabel={optionalFieldLabel}>
<KeyValueRowsControl
addLabel={t("Add env variable")}
@@ -1074,11 +1076,6 @@ function ProfileRoutingSettings({
const t = useAppText();
const [ruleDialog, setRuleDialog] = useState<{ draft: AddRoutingRuleDraft; index?: number }>();
const canSubmitRule = ruleDialog ? isRoutingRuleDraftSubmittable(ruleDialog.draft) : false;
const showEnhancedRoute = draft.agent === "claude-code" || draft.agent === "codex";
const showRoutingControls = draft.routingEnabled || showEnhancedRoute;
const enhancedRouteDescription = draft.agent === "codex"
? t("Enhanced route description Codex")
: t("Enhanced route description Claude Code");
function openAddRuleDialog() {
setRuleDialog({
@@ -1142,37 +1139,9 @@ function ProfileRoutingSettings({
onChange={(routingEnabled) => onChange({ routingEnabled })}
/>
</div>
{showRoutingControls ? (
{draft.routingEnabled ? (
<div className="mt-3 grid grid-cols-1 gap-3 border-t border-border/70 pt-3">
{showEnhancedRoute ? (
<div className="flex items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2">
<span className="flex min-w-0 items-center gap-1.5">
<span className="text-[12px] font-medium">{t("Enhanced route")}</span>
<Tooltip
aria-label={enhancedRouteDescription}
className="h-5 w-5 items-center justify-center rounded-full text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
content={enhancedRouteDescription}
contentClassName="w-[260px] max-w-[calc(100vw-64px)] whitespace-normal px-2.5 py-2 text-left font-medium leading-4"
side="right"
tabIndex={0}
>
<button
aria-label={enhancedRouteDescription}
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:bg-muted focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring/25"
type="button"
>
<Info className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</Tooltip>
</span>
<Toggle
checked={draft.routingEnhancedRoute}
onChange={(routingEnhancedRoute) => onChange({ routingEnhancedRoute })}
/>
</div>
) : null}
{draft.routingEnabled ? (
<div className="rounded-md border border-border bg-background p-3">
<div className="rounded-md border border-border bg-background p-3">
<div className="flex min-w-0 items-center justify-between gap-3">
<span className="text-[12px] font-medium">{t("Profile routes")}</span>
<Button onClick={openAddRuleDialog} size="sm" type="button" variant="outline">
@@ -1216,7 +1185,6 @@ function ProfileRoutingSettings({
))}
</div>
</div>
) : null}
</div>
) : null}
{ruleDialog ? (
@@ -1235,6 +1203,49 @@ function ProfileRoutingSettings({
);
}
function ProfileEnhancedRouteSetting({
draft,
onChange
}: {
draft: AddProfileDraft;
onChange: (patch: Partial<AddProfileDraft>) => void;
}) {
const t = useAppText();
const enhancedRouteDescription = draft.agent === "codex"
? t("Enhanced route description Codex")
: t("Enhanced route description Claude Code");
return (
<div className="sm:col-span-2 rounded-md border border-border bg-muted/20 p-3">
<div className="flex min-w-0 items-center justify-between gap-3">
<span className="flex min-w-0 items-center gap-1.5">
<span className="text-[12px] font-semibold">{t("Enhanced route")}</span>
<Tooltip
aria-label={enhancedRouteDescription}
className="h-5 w-5 items-center justify-center rounded-full text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
content={enhancedRouteDescription}
contentClassName="w-[260px] max-w-[calc(100vw-64px)] whitespace-normal px-2.5 py-2 text-left font-medium leading-4"
side="right"
tabIndex={0}
>
<button
aria-label={enhancedRouteDescription}
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:bg-muted focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring/25"
type="button"
>
<Info className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</Tooltip>
</span>
<Toggle
checked={draft.routingEnhancedRoute}
onChange={(routingEnhancedRoute) => onChange({ routingEnhancedRoute })}
/>
</div>
</div>
);
}
function createProfileRoutingRuleDraftFromRule(rule: RouterRule): AddRoutingRuleDraft {
const draft = createRoutingRuleDraftFromRule(rule);
return draft.type === "condition"
@@ -20,7 +20,7 @@ import {
import { PopoverPortal } from "@/components/ui/popover";
import { TooltipPortal } from "@/components/ui/tooltip";
import { providerUrlWithDefaultScheme } from "@ccr/core/providers/url";
import type { LocalAgentProviderCandidate } from "@ccr/core/contracts/app";
import type { LocalAgentProviderCandidate, ProviderAccountHttpJsonConnectorConfig, ProviderAccountWebContentJsonConnectorConfig } from "@ccr/core/contracts/app";
import type { ReactNode } from "react";
const useClientLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
@@ -1827,14 +1827,14 @@ function ProviderConnectionStatusRow({
return (
<div className={cn(
"flex min-w-0 items-start gap-2 rounded-md border bg-background px-3 py-2",
state === "success" && "border-emerald-200",
state === "warning" && "border-amber-200",
state === "success" && "border-emerald-500/30",
state === "warning" && "border-amber-500/30",
state === "pending" && "border-border"
)}>
<span className={cn(
"mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full",
state === "success" && "bg-emerald-50 text-emerald-700",
state === "warning" && "bg-amber-50 text-amber-700",
state === "success" && "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
state === "warning" && "bg-amber-500/10 text-amber-700 dark:text-amber-300",
state === "pending" && "bg-muted text-muted-foreground"
)}>
{loading ? <LoaderCircle className="h-3.5 w-3.5 animate-spin" /> : state === "success" ? <Check className="h-3.5 w-3.5" /> : <Info className="h-3.5 w-3.5" />}
@@ -2532,7 +2532,7 @@ export function AddProviderForm({
</div>
</div>
{error ? <div className="mt-3 flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-[12px] text-destructive"><CircleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" /><span>{error}</span></div> : null}
{error ? <div className="mt-3 flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-[12px] text-destructive" role="alert"><CircleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" /><span>{error}</span></div> : null}
</>
);
}
@@ -2850,14 +2850,16 @@ function ProviderUsageSettings({
useEffect(() => {
setTestResult(undefined);
setTestError("");
}, [draft.accountMode, draft.usageRequestUrl, draft.usageRequestMethod]);
}, [draft.accountConnectorsText, draft.accountMode, draft.usageRequestUrl, draft.usageRequestMethod]);
async function testUsageRequest() {
if (!window.ccr?.testProviderAccountConnector) {
setTestError(t("Request failed."));
return;
}
const connector = providerHttpJsonConnectorFromDraft(draft, { requireMeters: false });
const connector = draft.accountMode === "raw"
? providerRawAccountTestConnector(draft.accountConnectorsText)
: providerHttpJsonConnectorFromDraft(draft, { requireMeters: false });
if (typeof connector === "string") {
setTestError(formatError(new Error(connector)));
return;
@@ -2877,7 +2879,7 @@ function ProviderUsageSettings({
setTestError("");
try {
const result = await window.ccr.testProviderAccountConnector({
apiKey: usageApiKey,
apiKey: connector.type === "http-json" ? usageApiKey : undefined,
baseUrl: draft.baseUrl.trim(),
connector,
providerName: draft.name.trim()
@@ -3031,7 +3033,7 @@ function ProviderUsageSettings({
onChange={(event) => onChange({ accountConnectorsText: event.target.value })}
/>
<div className="flex min-w-0 items-center justify-between gap-2 text-[11px] text-muted-foreground">
<span className="min-w-0 truncate">{t("Supports standard, http-json, plugin, and local-estimate connectors.")}</span>
<span className="min-w-0 truncate">{t("Supports standard, http-json, webcontent-json, plugin, and local-estimate connectors.")}</span>
<button
className="shrink-0 text-primary hover:underline"
type="button"
@@ -3056,6 +3058,18 @@ function ProviderUsageSettings({
</Button>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Button disabled={testLoading} onClick={() => void testUsageRequest()} size="sm" type="button" variant="outline">
<AnimatedIconSwap iconKey={testLoading ? "testing" : "check"}>
{testLoading ? <LoaderCircle className="h-3.5 w-3.5 animate-spin" /> : <ShieldCheck className="h-3.5 w-3.5" />}
</AnimatedIconSwap>
{t("Test first JSON connector")}
</Button>
{testResult ? <Badge variant={testResult.meters.length > 0 ? "success" : "outline"}>{testResult.meters.length} {t("meters")}</Badge> : null}
</div>
{testResult ? (
<ProviderUsageTestResultPanel result={testResult} onSelectPath={() => undefined} />
) : null}
</div>
) : null}
</div>
@@ -3071,6 +3085,39 @@ function ProviderUsageSettings({
);
}
function providerRawAccountTestConnector(
connectorsText: string
): ProviderAccountHttpJsonConnectorConfig | ProviderAccountWebContentJsonConnectorConfig | string {
let connectors: unknown;
try {
connectors = JSON.parse(connectorsText.trim() || "[]");
} catch (error) {
return `Account connectors JSON is invalid: ${error instanceof Error ? error.message : String(error)}`;
}
if (!Array.isArray(connectors)) {
return "Account connectors must be a JSON array.";
}
const connector = connectors.find((item) =>
isPlainRecord(item) && (item.type === "http-json" || item.type === "webcontent-json")
);
if (!isPlainRecord(connector)) {
return "Add an http-json or webcontent-json connector to test.";
}
if (connector.type === "webcontent-json") {
return {
...(connector as ProviderAccountWebContentJsonConnectorConfig),
method: connector.method === "POST" ? "POST" : "GET",
type: "webcontent-json"
};
}
return {
...(connector as ProviderAccountHttpJsonConnectorConfig),
auth: connector.auth === "provider-api-key-raw" || connector.auth === "none" ? connector.auth : "provider-api-key",
method: connector.method === "POST" ? "POST" : "GET",
type: "http-json"
};
}
function ProviderUsageTestResultPanel({
onSelectPath,
result
@@ -3174,11 +3221,8 @@ export function AddProviderDialog({
}) {
const t = useAppText();
const [checkConfirmOpen, setCheckConfirmOpen] = useState(false);
const [checkConfirmBusy, setCheckConfirmBusy] = useState(false);
const [iconDetecting, setIconDetecting] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [checkModelSelection, setCheckModelSelection] = useState<string[]>([]);
const [checkResult, setCheckResult] = useState<ProviderConnectivityCheckReport>();
const [activeStep, setActiveStep] = useState<ProviderSetupStepId>("provider");
const checkModels = mergeProviderModelLists(draft.selectedModels, splitLines(draft.modelsText));
const submitLoading = probeLoading || connectivityLoading || iconDetecting || submitting;
@@ -3247,33 +3291,6 @@ export function AddProviderDialog({
setActiveStep(nextStep);
}
function openCheckConfirm() {
setCheckModelSelection(checkModels);
setCheckResult(undefined);
setCheckConfirmOpen(true);
}
async function confirmCheck() {
if (!onCheck) {
return;
}
setCheckConfirmBusy(true);
try {
setCheckResult(await onCheck(checkModelSelection));
} finally {
setCheckConfirmBusy(false);
}
}
function toggleCheckModel(model: string) {
setCheckModelSelection((current) =>
current.includes(model)
? current.filter((item) => item !== model)
: mergeProviderModelLists(current, [model])
);
setCheckResult(undefined);
}
async function submit() {
if (submitDisabled) {
return;
@@ -3329,7 +3346,7 @@ export function AddProviderDialog({
hideSetupProgress={!wizardMode}
importProvider={importProvider}
mode={mode}
onCheck={onCheck ? async () => openCheckConfirm() : undefined}
onCheck={onCheck ? async () => setCheckConfirmOpen(true) : undefined}
onChange={onChange}
onIconDetectingChange={setIconDetecting}
onSelectStep={wizardMode ? selectSetupStep : undefined}
@@ -3366,91 +3383,141 @@ export function AddProviderDialog({
</DialogContent>
</Dialog>
{checkConfirmOpen ? (
<Dialog className="z-[110]" onOpenChange={(open) => !open && !checkConfirmBusy && setCheckConfirmOpen(false)}>
<DialogContent className="max-w-[520px]">
<DialogHeader>
<div className="min-w-0">
<DialogTitle>{t("Check Connection")}</DialogTitle>
</div>
<Button
aria-label={t("Close dialog")}
disabled={checkConfirmBusy}
onClick={() => setCheckConfirmOpen(false)}
size="iconSm"
title={t("Close")}
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</DialogHeader>
<DialogBody>
<div className="space-y-3">
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2.5">
<div className="flex items-start gap-2 text-[12px] font-medium text-amber-900 dark:text-amber-100">
<CircleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{t("This check sends real model requests with your provider API key and may consume account balance.")}</span>
</div>
<div className="mt-2 text-[11px] leading-4 text-muted-foreground">
{t("Generated output is limited to 1 token for connectivity checks.")}
</div>
</div>
<div className="rounded-md border border-border bg-background p-2">
<div className="mb-2 flex min-w-0 flex-wrap items-center justify-between gap-2">
<div className="min-w-0 truncate text-[12px] font-semibold">{t("Models to check")}</div>
<div className="flex shrink-0 gap-1">
<Button className="h-6 px-1.5 text-[10px]" disabled={checkConfirmBusy || connectivityLoading || checkModels.length === 0} onClick={() => { setCheckModelSelection(checkModels); setCheckResult(undefined); }} type="button" variant="outline">
{t("All")}
</Button>
<Button className="h-6 px-1.5 text-[10px]" disabled={checkConfirmBusy || connectivityLoading || checkModelSelection.length === 0} onClick={() => { setCheckModelSelection([]); setCheckResult(undefined); }} type="button" variant="outline">
{t("Clear")}
</Button>
</div>
</div>
<div className="max-h-[180px] overflow-auto">
<div className="grid grid-cols-1 gap-2">
{checkModels.map((model) => {
const checked = checkModelSelection.includes(model);
return (
<Label
className={cn(
"flex min-h-8 min-w-0 cursor-pointer items-center gap-2 rounded-md border border-border bg-background px-2 py-1.5 text-left text-[12px] transition-colors hover:bg-muted",
checked && "border-primary bg-accent"
)}
key={model}
>
<Checkbox checked={checked} disabled={checkConfirmBusy || connectivityLoading} onCheckedChange={() => toggleCheckModel(model)} />
<span className="min-w-0 flex-1 truncate font-mono text-[11px]" title={model}>{model}</span>
</Label>
);
})}
</div>
</div>
</div>
{checkResult ? <ProviderConnectivityResultPanel result={checkResult} /> : null}
</div>
</DialogBody>
<DialogFooter>
<Button disabled={checkConfirmBusy} onClick={() => setCheckConfirmOpen(false)} type="button" variant="outline">
{checkResult ? t("Close") : t("Cancel")}
</Button>
<Button disabled={checkConfirmBusy || connectivityLoading || checkModelSelection.length === 0} onClick={() => void confirmCheck()} type="button">
<AnimatedIconSwap iconKey={checkConfirmBusy || connectivityLoading ? "checking" : "start"}>
{checkConfirmBusy || connectivityLoading ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <ShieldCheck className="h-4 w-4" />}
</AnimatedIconSwap>
{t("Start check")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{checkConfirmOpen && onCheck ? (
<ProviderConnectivityCheckDialog
connectivityLoading={connectivityLoading}
models={checkModels}
onCheck={onCheck}
onClose={() => setCheckConfirmOpen(false)}
/>
) : null}
</>
);
}
/**
* Confirmation step for the connectivity check. The check spends real provider credits, so every
* surface that offers "Check Connection" the add/edit dialog and the onboarding wizard must go
* through this dialog rather than calling onCheck directly.
*/
export function ProviderConnectivityCheckDialog({
connectivityLoading,
models,
onCheck,
onClose
}: {
connectivityLoading: boolean;
models: string[];
onCheck: (models: string[]) => Promise<ProviderConnectivityCheckReport>;
onClose: () => void;
}) {
const t = useAppText();
const [busy, setBusy] = useState(false);
const [selection, setSelection] = useState<string[]>(() => mergeProviderModelLists(models));
const [result, setResult] = useState<ProviderConnectivityCheckReport>();
const running = busy || connectivityLoading;
async function confirmCheck() {
setBusy(true);
try {
setResult(await onCheck(selection));
} finally {
setBusy(false);
}
}
function toggleCheckModel(model: string) {
setSelection((current) =>
current.includes(model)
? current.filter((item) => item !== model)
: mergeProviderModelLists(current, [model])
);
setResult(undefined);
}
return (
<Dialog className="z-[110]" onOpenChange={(open) => !open && !busy && onClose()}>
<DialogContent className="max-w-[520px]">
<DialogHeader>
<div className="min-w-0">
<DialogTitle>{t("Check Connection")}</DialogTitle>
</div>
<Button
aria-label={t("Close dialog")}
disabled={busy}
onClick={onClose}
size="iconSm"
title={t("Close")}
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</DialogHeader>
<DialogBody>
<div className="space-y-3">
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2.5">
<div className="flex items-start gap-2 text-[12px] font-medium text-amber-900 dark:text-amber-100">
<CircleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{t("This check sends real model requests with your provider API key and may consume account balance.")}</span>
</div>
<div className="mt-2 text-[11px] leading-4 text-muted-foreground">
{t("Generated output is limited to 1 token for connectivity checks.")}
</div>
</div>
<div className="rounded-md border border-border bg-background p-2">
<div className="mb-2 flex min-w-0 flex-wrap items-center justify-between gap-2">
<div className="min-w-0 truncate text-[12px] font-semibold">{t("Models to check")}</div>
<div className="flex shrink-0 gap-1">
<Button className="h-6 px-1.5 text-[10px]" disabled={running || models.length === 0} onClick={() => { setSelection(mergeProviderModelLists(models)); setResult(undefined); }} type="button" variant="outline">
{t("All")}
</Button>
<Button className="h-6 px-1.5 text-[10px]" disabled={running || selection.length === 0} onClick={() => { setSelection([]); setResult(undefined); }} type="button" variant="outline">
{t("Clear")}
</Button>
</div>
</div>
<div className="max-h-[180px] overflow-auto">
<div className="grid grid-cols-1 gap-2">
{models.map((model) => {
const checked = selection.includes(model);
return (
<Label
className={cn(
"flex min-h-8 min-w-0 cursor-pointer items-center gap-2 rounded-md border border-border bg-background px-2 py-1.5 text-left text-[12px] transition-colors hover:bg-muted",
checked && "border-primary bg-accent"
)}
key={model}
>
<Checkbox checked={checked} disabled={running} onCheckedChange={() => toggleCheckModel(model)} />
<span className="min-w-0 flex-1 truncate font-mono text-[11px]" title={model}>{model}</span>
</Label>
);
})}
</div>
</div>
</div>
{result ? <ProviderConnectivityResultPanel result={result} /> : null}
</div>
</DialogBody>
<DialogFooter>
<Button disabled={busy} onClick={onClose} type="button" variant="outline">
{result ? t("Close") : t("Cancel")}
</Button>
<Button disabled={running || selection.length === 0} onClick={() => void confirmCheck()} type="button">
<AnimatedIconSwap iconKey={running ? "checking" : "start"}>
{running ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <ShieldCheck className="h-4 w-4" />}
</AnimatedIconSwap>
{t("Start check")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ProviderConnectivityResultPanel({ result }: { result: ProviderConnectivityCheckReport }) {
const t = useAppText();
@@ -3646,7 +3713,7 @@ function ProviderModelPicker({
observer?.disconnect();
window.removeEventListener("resize", updateWidth);
};
}, [loading]);
}, []);
useEffect(() => {
if (!customModelEditing) {
@@ -3666,26 +3733,28 @@ function ProviderModelPicker({
</div>
<Badge variant="outline">{loading ? <LoaderCircle className="h-3 w-3 animate-spin" /> : catalog.length}</Badge>
</div>
{!loading ? (
<div className="border-b border-border p-2">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label={t("Search provider models")}
className="pl-8"
onChange={(event) => onQueryChange(event.target.value)}
placeholder={t("Search provider models")}
value={query}
/>
</div>
<div className="border-b border-border p-2">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label={t("Search provider models")}
className="pl-8"
disabled={loading}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={t("Search provider models")}
value={query}
/>
</div>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{loading ? (
<ProviderModelListSkeleton />
) : visibleCatalogModels.length === 0 ? (
<div className="rounded-md border border-dashed border-border bg-muted/20 px-3 py-8 text-center text-[12px] text-muted-foreground">
{catalog.length === 0 ? t("No provider models") : t("No matching models")}
<div>{catalog.length === 0 ? t("No provider models") : t("No matching models")}</div>
{catalog.length === 0 ? (
<div className="mt-1 text-[11px] leading-4">{t("This provider did not return a model list. Add model IDs with Custom model.")}</div>
) : null}
</div>
) : (
<div className="space-y-1.5">
@@ -3738,10 +3807,9 @@ function ProviderModelPicker({
</div>
<Badge variant={selectedModels.length > 0 ? "secondary" : "outline"}>{selectedModels.length}</Badge>
</div>
{!loading ? (
<div className="border-b border-border p-2">
<div className="relative h-9 min-w-0" ref={addedControlsRef}>
<AnimatePresence initial={false} mode="wait">
<div className="border-b border-border p-2">
<div className="relative h-9 min-w-0" ref={addedControlsRef}>
<AnimatePresence initial={false} mode="wait">
{customModelEditing ? (
<motion.div
animate={{ opacity: 1, width: customModelEditorWidth }}
@@ -3869,33 +3937,28 @@ function ProviderModelPicker({
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</AnimatePresence>
</div>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{loading ? (
<ProviderModelListSkeleton compact />
) : (
<ModelMetadataEditor
defaults={defaults}
displayNames={displayNames}
emptyLabel={selectedModels.length === 0 ? t("No models added") : t("No matching models")}
header={false}
metadata={metadata}
models={visibleAddedModels}
onChange={onMetadataChange}
onRemoveModel={removeModel}
sourceModels={catalog}
/>
)}
<ModelMetadataEditor
defaults={defaults}
displayNames={displayNames}
emptyLabel={selectedModels.length === 0 ? t("No models added") : t("No matching models")}
header={false}
metadata={metadata}
models={visibleAddedModels}
onChange={onMetadataChange}
onRemoveModel={removeModel}
sourceModels={catalog}
/>
</div>
</section>
</div>
);
}
function ProviderModelListSkeleton({ compact = false }: { compact?: boolean }) {
function ProviderModelListSkeleton() {
const t = useAppText();
return (
@@ -3910,7 +3973,7 @@ function ProviderModelListSkeleton({ compact = false }: { compact?: boolean }) {
"provider-skeleton-shimmer h-3 rounded-full",
index % 3 === 0 ? "w-7/12" : index % 3 === 1 ? "w-9/12" : "w-5/12"
)} />
{!compact && index % 2 === 0 ? <div className="provider-skeleton-shimmer h-2 w-4/12 rounded-full" /> : null}
{index % 2 === 0 ? <div className="provider-skeleton-shimmer h-2 w-4/12 rounded-full" /> : null}
</div>
<div className="provider-skeleton-shimmer h-4 w-4 shrink-0 rounded-full" />
</div>
@@ -359,6 +359,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Models detected from this provider": "Models detected from this provider",
"No models added": "No models added",
"No provider models": "No provider models",
"This provider did not return a model list. Add model IDs with Custom model.": "This provider did not return a model list. Add model IDs with Custom model.",
"No available models": "No available models",
"No local login state was found for this agent.": "No local login state was found for this agent.",
"No providers": "No providers",
@@ -1948,6 +1949,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"No available models": "没有可用模型",
"No models added": "未添加模型",
"No provider models": "没有供应商模型",
"This provider did not return a model list. Add model IDs with Custom model.": "该供应商未返回模型列表,请使用“自定义模型”手动添加模型 ID。",
"No protocol detection yet": "尚未检测协议",
"No response fields": "没有响应字段",
"No unavailable models": "没有不可用模型",
@@ -2006,8 +2008,11 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Subscription unit": "订阅单位",
"Status field": "状态字段",
"Message field": "消息字段",
"Add an http-json or webcontent-json connector to test.": "请添加一个 http-json 或 webcontent-json 连接器用于测试。",
"Supports standard, http-json, plugin, and local-estimate connectors.": "支持 standard、http-json、plugin 和 local-estimate 连接器。",
"Supports standard, http-json, webcontent-json, plugin, and local-estimate connectors.": "支持 standard、http-json、webcontent-json、plugin 和 local-estimate 连接器。",
"Switch to HTTP JSON request to configure method, URL, headers, body, and response fields.": "切换到 HTTP JSON 请求即可配置 method、URL、header、body 和响应字段。",
"Test first JSON connector": "测试第一个 JSON 连接器",
"Test usage request": "测试用量请求",
"This check sends real model requests with your provider API key and may consume account balance.": "本次检测会使用你的供应商 API Key 发起真实模型请求,可能消耗账户余额。",
"The gateway is configured. Start the service from the toolbar when you are ready to test traffic.": "网关已经配置好。准备测试请求时,请从工具栏启动服务。",
@@ -11,6 +11,7 @@ export * from "./profiles";
export * from "./services";
export * from "./provider-accounts";
export * from "./logs";
export * from "./log-body-worker-protocol";
export * from "./common";
export * from "./config";
export * from "./api-keys";
@@ -0,0 +1,103 @@
import type { RequestLogBody } from "@ccr/core/contracts/app";
import type { FormattedLogBody } from "./logs";
export const logBodyLargeTextThreshold = 256 * 1024;
export const logBodyPreviewTextLimit = 160 * 1024;
export type LogBodyFormatMode = "full" | "preview";
export type LogBodyFormatRequest = {
body?: RequestLogBody;
bodyKey: string;
id: number;
kind: "format";
largeTextThreshold?: number;
mode: LogBodyFormatMode;
previewTextLimit?: number;
query: string;
};
export type LogBodyFilterRequest = {
bodyKey: string;
id: number;
kind: "filter";
mode: LogBodyFormatMode;
query: string;
};
export type LogBodyWorkerRequest = LogBodyFilterRequest | LogBodyFormatRequest;
export type LogBodyFormatResult = FormattedLogBody & {
bodyKey: string;
formattedTextLength: number;
id: number;
kind: "format-result";
large: boolean;
mode: LogBodyFormatMode;
ok: true;
preview: boolean;
query: string;
sourceSizeBytes: number;
visible: string;
};
export type LogBodyFilterResult = {
bodyKey: string;
id: number;
kind: "filter-result";
mode: LogBodyFormatMode;
ok: true;
query: string;
visible: string;
};
export type LogBodyWorkerError = {
bodyKey?: string;
id: number;
kind: "error";
message: string;
mode?: LogBodyFormatMode;
operation: LogBodyWorkerRequest["kind"];
};
export type LogBodyWorkerResponse = LogBodyFilterResult | LogBodyFormatResult | LogBodyWorkerError;
export function isLargeLogBody(
body: RequestLogBody | undefined,
threshold = logBodyLargeTextThreshold
): boolean {
if (!body) {
return false;
}
return Math.max(body.sizeBytes, body.text.length) > threshold;
}
export function createLogBodyPreviewText(
body: RequestLogBody | undefined,
limit = logBodyPreviewTextLimit
): string {
if (!body || (!body.text && body.sizeBytes === 0)) {
return "No body";
}
const text = body.text || "";
if (!text) {
return "Body text is not loaded.";
}
if (text.length <= limit) {
return text;
}
const headLength = Math.max(0, Math.floor(limit * 0.65));
const tailLength = Math.max(0, limit - headLength);
const omitted = Math.max(0, text.length - headLength - tailLength);
const head = text.slice(0, headLength);
const tail = tailLength > 0 ? text.slice(-tailLength) : "";
return [
head,
"",
`... ${omitted} characters omitted from preview ...`,
"",
tail
].join("\n");
}
@@ -0,0 +1,100 @@
import {
createLogBodyPreviewText,
isLargeLogBody,
logBodyLargeTextThreshold,
logBodyPreviewTextLimit,
type LogBodyFilterRequest,
type LogBodyFormatRequest,
type LogBodyFormatResult,
type LogBodyWorkerRequest,
type LogBodyWorkerResponse
} from "./log-body-worker-protocol";
import { filterLogText, formatLogBodyView, type FormattedLogBody } from "./logs";
type CachedFormattedBody = FormattedLogBody & {
bodyKey: string;
large: boolean;
mode: LogBodyFormatRequest["mode"];
preview: boolean;
sourceSizeBytes: number;
};
type LogBodyWorkerGlobal = {
onmessage: ((event: MessageEvent<LogBodyWorkerRequest>) => void) | null;
postMessage: (message: LogBodyWorkerResponse) => void;
};
const worker = self as unknown as LogBodyWorkerGlobal;
let cachedBody: CachedFormattedBody | undefined;
worker.onmessage = (event: MessageEvent<LogBodyWorkerRequest>) => {
const request = event.data;
try {
if (request.kind === "format") {
worker.postMessage(formatBody(request) satisfies LogBodyWorkerResponse);
return;
}
worker.postMessage(filterBody(request) satisfies LogBodyWorkerResponse);
} catch (error) {
worker.postMessage({
bodyKey: "bodyKey" in request ? request.bodyKey : undefined,
id: request.id,
kind: "error",
message: error instanceof Error ? error.message : String(error),
mode: "mode" in request ? request.mode : undefined,
operation: request.kind
} satisfies LogBodyWorkerResponse);
}
};
function formatBody(request: LogBodyFormatRequest): LogBodyFormatResult {
const threshold = request.largeTextThreshold ?? logBodyLargeTextThreshold;
const previewLimit = request.previewTextLimit ?? logBodyPreviewTextLimit;
const large = isLargeLogBody(request.body, threshold);
const preview = large && request.mode !== "full";
const bodyView = preview
? { text: createLogBodyPreviewText(request.body, previewLimit) }
: formatLogBodyView(request.body);
const sourceSizeBytes = request.body?.sizeBytes ?? 0;
const visible = filterLogText(bodyView.text, request.query);
cachedBody = {
...bodyView,
bodyKey: request.bodyKey,
large,
mode: request.mode,
preview,
sourceSizeBytes
};
return {
...bodyView,
bodyKey: request.bodyKey,
formattedTextLength: bodyView.text.length,
id: request.id,
kind: "format-result",
large,
mode: request.mode,
ok: true,
preview,
query: request.query,
sourceSizeBytes,
visible
};
}
function filterBody(request: LogBodyFilterRequest): LogBodyWorkerResponse {
if (!cachedBody || cachedBody.bodyKey !== request.bodyKey || cachedBody.mode !== request.mode) {
throw new Error("Formatted body cache is not available.");
}
return {
bodyKey: request.bodyKey,
id: request.id,
kind: "filter-result",
mode: request.mode,
ok: true,
query: request.query,
visible: filterLogText(cachedBody.text, request.query)
};
}
+17
View File
@@ -68,6 +68,23 @@ export function logResponseModel(entry: RequestLogEntry): string {
"unknown";
}
export function logResolvedRouteModel(entry: RequestLogEntry): string {
const resolvedModel = entry.resolvedModel || entry.model || "unknown";
return logRouteModelName(resolvedModel);
}
function logRouteModelName(value: string): string {
const resolvedModel = value.trim();
if (!resolvedModel || resolvedModel === "unknown") {
return "unknown";
}
const selectorIndex = resolvedModel.indexOf("::");
const routedModel = selectorIndex >= 0 ? resolvedModel.slice(selectorIndex + 2) : resolvedModel;
const separator = routedModel.lastIndexOf("/");
const model = separator >= 0 ? routedModel.slice(separator + 1) : routedModel;
return model || resolvedModel;
}
export function logBodyModel(body: RequestLogBody | undefined): string | undefined {
if (!body || body.encoding === "base64" || !body.text.trim()) {
return undefined;
@@ -145,7 +145,10 @@ export function providerAccountProgressClass(status: ProviderAccountSnapshot["st
return "bg-emerald-500";
}
export function formatProviderAccountMeterValue(meter: ProviderAccountMeter): string {
export function formatProviderAccountMeterValue(
meter: ProviderAccountMeter,
translate: (value: string) => string = (value) => value
): string {
const value = meter.remaining ?? meter.used ?? meter.limit;
if (value === undefined) {
return "-";
@@ -170,10 +173,11 @@ export function formatProviderAccountMeterValue(meter: ProviderAccountMeter): st
if (unit === "minutes") {
return `${formatProviderAccountNumber(value)}m`;
}
const displayUnit = translate(unit);
if (meter.kind === "balance") {
return `${formatProviderAccountNumber(value)} ${unit}`;
return `${formatProviderAccountNumber(value)} ${displayUnit}`;
}
return `${formatCompactNumber(value)} ${unit}`;
return `${formatCompactNumber(value)} ${displayUnit}`;
}
export function formatProviderAccountNumber(value: number): string {
+22 -1
View File
@@ -1526,6 +1526,27 @@ export function providerAccountConnectorExample(): string {
]
}
},
{
type: "webcontent-json",
endpoint: "https://vendor.example.com/api/account/usage",
browser: {
loginUrl: "https://vendor.example.com/login",
requestOrigin: "https://vendor.example.com",
partition: "built-in-browser",
timeoutMs: 15000
},
mapping: {
meters: [
{
id: "browser_balance",
label: "Browser balance",
kind: "balance",
unit: "USD",
remaining: "$.balance"
}
]
}
},
{
type: "plugin",
pluginId: "vendor-plugin",
@@ -1691,7 +1712,7 @@ export async function probeProviderCandidates(
): Promise<ProviderProbeCandidateResult | undefined> {
const mode = options.mode ?? "protocols";
return await window.ccr?.probeProviderCandidates({
apiKey: mode === "connectivity" || mode === "models" ? apiKey : undefined,
apiKey: apiKey || undefined,
candidates,
mode,
models: mode === "connectivity" ? models : [],
+37 -2
View File
@@ -2,12 +2,12 @@ import assert from "node:assert/strict";
import test from "node:test";
import * as React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { GatewayStartupErrorBanner, groupSidebarNavigation, UpdateEntryButton } from "@ccr/ui/pages/home/components/layout.tsx";
import { GatewayStartupErrorBanner, groupSidebarNavigation, MainLayout, UpdateEntryButton } from "@ccr/ui/pages/home/components/layout.tsx";
import { MediaModelConfigurationPanel, VirtualModelsView } from "@ccr/ui/pages/home/components/virtual-models.tsx";
import { AppI18nContext, appCopy } from "@ccr/ui/pages/home/shared/i18n.tsx";
import { createVirtualModelDraft } from "@ccr/ui/pages/home/shared/virtual-models.ts";
import { appConfigFixture } from "../fixtures/index.ts";
import { fallbackUpdateStatus } from "@ccr/ui/pages/home/shared/fallbacks.ts";
import { fallbackGatewayStatus, fallbackUpdateStatus } from "@ccr/ui/pages/home/shared/fallbacks.ts";
import { navigation } from "@ccr/ui/pages/home/shared/options.ts";
import { formatUpdateReleaseNotes, shouldCheckForUpdateOnOpen, UpdateDialog } from "@ccr/ui/pages/home/components/update.tsx";
@@ -32,6 +32,41 @@ test("sidebar navigation groups pages and hides networking from the sidebar", ()
]);
});
test("sidebar navigation scrolls vertically without displacing the settings footer", () => {
const html = renderToStaticMarkup(
<MainLayout
activeView="networking"
agentAnalysisEnabled={false}
compactLayout={false}
config={appConfigFixture()}
copy={appCopy.en}
gatewayActionBusy={false}
gatewayEndpoint="http://127.0.0.1:3456"
gatewayStatus={fallbackGatewayStatus}
isMac={false}
needsTrafficLightSafeArea={false}
networkCaptureEnabled={false}
onOpenServerSettings={() => undefined}
onOpenSettings={() => undefined}
onOpenUpdate={() => undefined}
onSelectNavigationItem={() => undefined}
onToggleSidebar={() => undefined}
requestLogsEnabled={false}
shouldReduceMotion={true}
sidebarOpen
toggleGatewayService={() => undefined}
updateActionBusy={false}
updateStatus={fallbackUpdateStatus}
viewProps={{} as never}
visibleNavigation={navigation}
/>
);
assert.match(html, /<nav class="[^"]*overflow-y-auto[^"]*max-\[720px\]:overflow-y-hidden[^"]*"/);
assert.match(html, /class="grid shrink-0 gap-1 border-t/);
assert.ok(html.indexOf("</nav>") < html.indexOf("Settings"));
});
test("GatewayStartupErrorBanner renders startup failure details", () => {
const html = renderToStaticMarkup(
<AppI18nContext.Provider value={appCopy.zh}>
@@ -5,7 +5,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { formatCodexResetCardExpiry, formatCodexResetCardNumber, OverviewView } from "@ccr/ui/pages/home/components/dashboard.tsx";
import { AppI18nContext, appCopy } from "@ccr/ui/pages/home/shared/i18n.tsx";
import { parseStatusBucketDate } from "@ccr/ui/pages/home/shared/controls.tsx";
import { providerAccountMeterDetailValidityProgress } from "@ccr/ui/pages/home/shared/provider-accounts.ts";
import { formatProviderAccountMeterValue, providerAccountMeterDetailValidityProgress } from "@ccr/ui/pages/home/shared/provider-accounts.ts";
import type { OverviewWidgetConfig, ProviderAccountSnapshot } from "@ccr/core/contracts/app.ts";
import { accountSnapshots, installBrowserGlobals, usageStats } from "../fixtures/index.ts";
@@ -143,6 +143,26 @@ test("OverviewView renders the empty widget layout state", () => {
assert.match(html, /aria-label="Edit widgets"/);
});
test("OverviewView keeps multi-provider account cards at their content height", () => {
const html = renderToStaticMarkup(
<OverviewView
overviewWidgets={[{ enabled: true, id: "account", size: "4:2", type: "account-balance", variant: "cards" }]}
providerAccounts={accountSnapshots()}
refreshProviderAccounts={() => undefined}
setUsageRange={() => undefined}
usageRange="30d"
usageStats={usageStats("30d")}
onWidgetsChange={() => undefined}
/>
);
assert.match(html, /data-provider-account-grid="true"/);
assert.match(html, /auto-rows-max/);
assert.match(html, /content-start/);
assert.match(html, /scrollbar-gutter:stable/);
assert.match(html, /h-fit/);
});
test("OverviewView prioritizes Codex manual resets before folded balance meters", () => {
const resetAt = new Date(Date.now() + 72 * 60 * 60 * 1000).toISOString();
const resetEffectiveAt = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
@@ -282,6 +302,21 @@ test("OverviewView does not render an outer progress bar for Codex manual resets
assert.doesNotMatch(html, /Full reset/);
});
test("provider account meter values localize textual units", () => {
const value = formatProviderAccountMeterValue(
{
id: "codex_manual_resets",
kind: "requests",
label: "Manual resets",
remaining: 0,
unit: "resets"
},
(unit) => appCopy.zh.text[unit] ?? unit
);
assert.equal(value, `0 ${appCopy.zh.text.resets}`);
});
test("provider account reset credit detail progress uses each validity window", () => {
const effectiveAt = "2026-07-01T00:00:00.000Z";
const expiresAt = "2026-07-11T00:00:00.000Z";
+32 -1
View File
@@ -81,7 +81,7 @@ test("AddProfileForm keeps profile routing inside Advanced settings", () => {
assert.doesNotMatch(html, /Enhanced route/);
});
test("AddProfileForm shows profile-level enhanced route controls when private routing is disabled", () => {
test("AddProfileForm shows enhanced route as a sibling of profile routing", () => {
const config = appConfigFixture();
const html = renderToStaticMarkup(
<AddProfileForm
@@ -96,12 +96,18 @@ test("AddProfileForm shows profile-level enhanced route controls when private ro
/>
);
const advancedSettingsIndex = html.indexOf("Advanced settings");
const enhancedRouteIndex = html.indexOf("Enhanced route");
const profileRoutingIndex = html.indexOf("Profile routing");
assert.ok(advancedSettingsIndex >= 0);
assert.ok(enhancedRouteIndex > advancedSettingsIndex);
assert.ok(profileRoutingIndex > advancedSettingsIndex);
assert.ok(enhancedRouteIndex < profileRoutingIndex);
assert.doesNotMatch(html, /Routing disabled/);
assert.match(html, /Enhanced route/);
assert.match(html, /rounded-b-none/);
assert.match(html, /rounded-b-md border border-t-0/);
assert.doesNotMatch(html, /Profile routes/);
assert.match(html, /CCR built-in Claude Code routing optimizes requests to third-party models for this profile\./);
});
@@ -123,6 +129,7 @@ test("AddProfileForm shows private profile routes when profile routing is enable
assert.match(html, /Profile routing/);
assert.match(html, /Enhanced route/);
assert.match(html, /Profile routes/);
assert.ok(html.indexOf("Enhanced route") < html.indexOf("Profile routing"));
assert.match(html, /CCR built-in Claude Code routing optimizes requests to third-party models for this profile\./);
assert.match(html, /data-ui-tooltip-trigger/);
});
@@ -146,6 +153,30 @@ test("AddProfileForm uses Codex-specific enhanced route info for Codex profiles"
assert.match(html, /CCR built-in Codex routing optimizes requests to third-party models for this profile\./);
});
test("AddProfileForm places CLAUDE_APP_PATH below Bot settings", () => {
const config = appConfigFixture();
const html = renderToStaticMarkup(
<AddProfileForm
botConfigs={config.botConfigs ?? []}
draft={{ ...createProfileDraft("claude-code"), surface: "app" }}
error=""
mode="edit"
onChange={() => undefined}
onCreateBot={() => undefined}
providers={config.Providers}
virtualModelProfiles={config.virtualModelProfiles}
/>
);
const botIndex = html.indexOf(">Bot</span>");
const appPathIndex = html.indexOf("CLAUDE_APP_PATH");
const envIndex = html.indexOf("Environment variables");
assert.ok(botIndex >= 0);
assert.ok(appPathIndex > botIndex);
assert.ok(envIndex > appPathIndex);
assert.doesNotMatch(html, /rounded-md border border-border bg-background p-1 transition-colors/);
});
test("AddProfileForm marks required and optional fields", () => {
const config = appConfigFixture();
const html = renderToStaticMarkup(
+60 -3
View File
@@ -7,7 +7,7 @@ import { geminiProviderPreset } from "@ccr/core/providers/presets/gemini/index.t
import { minimaxChinaProviderPreset } from "@ccr/core/providers/presets/minimax/index.ts";
import { moonshotGlobalProviderPreset } from "@ccr/core/providers/presets/moonshot/index.ts";
import { qiniuAiProviderPreset } from "@ccr/core/providers/presets/qiniu-ai/index.ts";
import { AddProviderDialog, AddProviderForm, ProvidersView, uniqueProviderProbeProtocolRows } from "@ccr/ui/pages/home/components/providers.tsx";
import { AddProviderDialog, AddProviderForm, ProviderConnectivityCheckDialog, ProvidersView, uniqueProviderProbeProtocolRows } from "@ccr/ui/pages/home/components/providers.tsx";
import {
applyProviderProbeResult,
createProviderConfigFromDeepLink,
@@ -627,9 +627,66 @@ test("AddProviderForm shows skeleton rows while provider models load", () => {
assert.match(html, /aria-busy="true"/);
assert.match(html, /Loading provider models/);
assert.match(html, /provider-skeleton-shimmer/);
assert.doesNotMatch(html, /Custom model/);
assert.doesNotMatch(html, /No models added/);
assert.doesNotMatch(html, /No provider models/);
// The added-models panel holds local draft state, so it keeps rendering its real contents and
// controls while the provider catalog probe is still running.
assert.match(html, /Custom model/);
assert.match(html, /No models added/);
});
test("AddProviderForm explains an empty provider catalog once the probe settles", () => {
const draft = {
...createProviderDraft([]),
apiKey: "sk-test",
baseUrl: "https://api.example/v1",
name: "Example",
presetId: customProviderPresetId
};
const html = renderToStaticMarkup(
React.createElement(AddProviderForm, {
activeStep: "models",
draft,
error: "",
mode: "add",
onChange: () => undefined,
probeLoading: false,
providers: []
})
);
assert.match(html, /No provider models/);
assert.match(html, /Add model IDs with Custom model/);
});
test("connectivity check confirmation warns about spending provider credits", () => {
const html = renderToStaticMarkup(
React.createElement(ProviderConnectivityCheckDialog, {
connectivityLoading: false,
models: ["example-model", "example-model-mini"],
onCheck: async () => ({ failed: [], passed: [], results: [] }),
onClose: () => undefined
})
);
assert.match(html, /This check sends real model requests with your provider API key and may consume account balance\./);
assert.match(html, /Models to check/);
assert.match(html, /example-model-mini/);
assert.match(html, /Start check/);
});
test("AddProviderForm marks the provider error banner as an alert", () => {
const html = renderToStaticMarkup(
React.createElement(AddProviderForm, {
draft: createProviderDraft([]),
error: "Invalid API key.",
mode: "add",
onChange: () => undefined,
probeLoading: false,
providers: []
})
);
assert.match(html, /role="alert"[^>]*>[\s\S]*Invalid API key\./);
});
test("provider connectivity API key follows selected credential mode", () => {