diff --git a/README.md b/README.md index 72b4c519..1558bbf0 100644 --- a/README.md +++ b/README.md @@ -162,28 +162,28 @@ CCR supports OpenAI Chat / Responses, Anthropic Messages, Gemini Generate Conten
- + Windows logo
Windows
- + Linux logo
Linux
- + macOS logo
macOS (Apple Silicon)
- + macOS logo
macOS (Intel) diff --git a/README_zh.md b/README_zh.md index 048c7506..394d7ff1 100644 --- a/README_zh.md +++ b/README_zh.md @@ -162,28 +162,28 @@ CCR 支持 OpenAI Chat / Responses、Anthropic Messages、Gemini Generate Conten
- + Windows 图标
Windows
- + Linux 图标
Linux
- + macOS 图标
macOS (Apple Silicon)
- + macOS 图标
macOS (Intel) diff --git a/build/build.mjs b/build/build.mjs index e57e6e33..afc3d10c 100644 --- a/build/build.mjs +++ b/build/build.mjs @@ -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" }) diff --git a/build/dev.mjs b/build/dev.mjs index 5063cff7..b9af45a9 100644 --- a/build/dev.mjs +++ b/build/dev.mjs @@ -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); + }) + ] + }) ) ); } diff --git a/build/esbuild.config.mjs b/build/esbuild.config.mjs index e92ec79a..c3fff761 100644 --- a/build/esbuild.config.mjs +++ b/build/esbuild.config.mjs @@ -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"); diff --git a/build/test.mjs b/build/test.mjs index 81ac359f..2402335c 100644 --- a/build/test.mjs +++ b/build/test.mjs @@ -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"), diff --git a/musk-claude-code-router-sponsor.jpg b/musk-claude-code-router-sponsor.jpg deleted file mode 100644 index 33f38d4a..00000000 Binary files a/musk-claude-code-router-sponsor.jpg and /dev/null differ diff --git a/package-lock.json b/package-lock.json index 449335cd..fd889c6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 2a22fea5..d230c8cc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/cli/package.json b/packages/cli/package.json index d74bd4a7..d79b05f0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/core/package.json b/packages/core/package.json index a12144b5..0dd520ab 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/agents/bot-gateway/qr-login-service.ts b/packages/core/src/agents/bot-gateway/qr-login-service.ts index dbb111de..d1605c59 100644 --- a/packages/core/src/agents/bot-gateway/qr-login-service.ts +++ b/packages/core/src/agents/bot-gateway/qr-login-service.ts @@ -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 { 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 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 = (method: string, params?: unknown): Promise => { + try { + return originalRequest(method, params); + } finally { + attach(); + } + }; + attach(); +} + async function loadBotGatewaySdk(): Promise { if (!sdkPromise) { sdkPromise = importBotGatewaySdk(); diff --git a/packages/core/src/agents/claude-app/gateway-routes.ts b/packages/core/src/agents/claude-app/gateway-routes.ts index 000c5839..290fd864 100644 --- a/packages/core/src/agents/claude-app/gateway-routes.ts +++ b/packages/core/src/agents/claude-app/gateway-routes.ts @@ -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 ): 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 +): string | undefined { + const resolved = claudeAppGatewayResolvedProviderModel(model, config); + return resolved ? `${resolved.provider.name}/${resolved.model}` : undefined; +} + +function claudeAppGatewayResolvedProviderModel( + model: string, + config: Pick +) { + 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) diff --git a/packages/core/src/agents/claude-app/launch.ts b/packages/core/src/agents/claude-app/launch.ts index 17094c73..5f8ae26d 100644 --- a/packages/core/src/agents/claude-app/launch.ts +++ b/packages/core/src/agents/claude-app/launch.ts @@ -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); diff --git a/packages/core/src/agents/claude-app/vm-storage.ts b/packages/core/src/agents/claude-app/vm-storage.ts new file mode 100644 index 00000000..510ca3ee --- /dev/null +++ b/packages/core/src/agents/claude-app/vm-storage.ts @@ -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(); + 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"; +} diff --git a/packages/core/src/agents/codex/cli-middleware-runtime.ts b/packages/core/src/agents/codex/cli-middleware-runtime.ts index 209c4734..7eed4f61 100644 --- a/packages/core/src/agents/codex/cli-middleware-runtime.ts +++ b/packages/core/src/agents/codex/cli-middleware-runtime.ts @@ -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().")); diff --git a/packages/core/src/agents/codex/model-catalog.ts b/packages/core/src/agents/codex/model-catalog.ts index 82c05a1a..c25cece6 100644 --- a/packages/core/src/agents/codex/model-catalog.ts +++ b/packages/core/src/agents/codex/model-catalog.ts @@ -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)) { diff --git a/packages/core/src/agents/local-providers/claude-code.ts b/packages/core/src/agents/local-providers/claude-code.ts index 95731be1..ef0e210b 100644 --- a/packages/core/src/agents/local-providers/claude-code.ts +++ b/packages/core/src/agents/local-providers/claude-code.ts @@ -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 | 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(); + const servicesRead = new Set(); + 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 = []; + 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"="(.*)"$/.exec(line); + if (serviceMatch) { + service = serviceMatch[1]; + continue; + } + const accountMatch = /^\s*"acct"="(.*)"$/.exec(line); + if (accountMatch) { + account = accountMatch[1]; + continue; + } + const modifiedMatch = /^\s*"mdat"=.*"(\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 | 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}`; +} diff --git a/packages/core/src/agents/local-providers/shared.ts b/packages/core/src/agents/local-providers/shared.ts index fbbbf044..75ad9499 100644 --- a/packages/core/src/agents/local-providers/shared.ts +++ b/packages/core/src/agents/local-providers/shared.ts @@ -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); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 2e24a56f..44c16a6b 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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): 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; diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index 15a00fbb..416c2278 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -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; + 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; }; diff --git a/packages/core/src/gateway/auth/api-key-authorizer.ts b/packages/core/src/gateway/auth/api-key-authorizer.ts index fb1d2692..df28ac7c 100644 --- a/packages/core/src/gateway/auth/api-key-authorizer.ts +++ b/packages/core/src/gateway/auth/api-key-authorizer.ts @@ -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); diff --git a/packages/core/src/gateway/core-runtime/config-compiler.ts b/packages/core/src/gateway/core-runtime/config-compiler.ts index b2c2ca77..05c5286d 100644 --- a/packages/core/src/gateway/core-runtime/config-compiler.ts +++ b/packages/core/src/gateway/core-runtime/config-compiler.ts @@ -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 | 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 = { ...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, providerName: string | unde names.add(providerName.slice(0, capabilitySeparatorIndex)); } } - - -function hasOwn(value: Record, key: string): boolean { - return Object.prototype.hasOwnProperty.call(value, key); -} diff --git a/packages/core/src/gateway/core-runtime/local-agent-auth-provider-hook.ts b/packages/core/src/gateway/core-runtime/local-agent-auth-provider-hook.ts new file mode 100644 index 00000000..e0c4d32d --- /dev/null +++ b/packages/core/src/gateway/core-runtime/local-agent-auth-provider-hook.ts @@ -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; + +type UpstreamRequest = { + body?: unknown; + bodyEncoding?: "bytes" | "form" | "json" | "none" | "text"; + headers?: HeaderRecord; + method?: string; + url: string; +}; + +type ProviderPluginInput = { + model?: string; + request?: { + headers?: Record; + }; + upstreamRequest: UpstreamRequest; +}; + +type ProviderHookResult = { + ok: true; + value: UpstreamRequest; +} | { + error: string; + ok: false; +}; + +type ProviderHook = { + authenticate?: (input: ProviderPluginInput) => Promise | ProviderHookResult; + key: string; + provider?: string; + providerName?: string; + transformRequest?: (input: ProviderPluginInput) => Promise | ProviderHookResult; +}; + +type LocalAgentOauthKind = "claude-code" | "grok" | "kimi"; + +export function createGatewayPlugin(input: { config?: Record } = {}) { + return { + providerHooks: localAgentOauthProviderHooks(input.config) + }; +} + +export function localAgentOauthProviderHooks(config: Record | 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): 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, + plugin: Record, + missingTokenError: string +): Promise { + 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 +): Promise { + 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): Promise { + const auth = await resolveGrokAuth().catch(() => readGrokAuth()); + if (auth?.accessToken && !grokAccessTokenExpired(auth)) { + return auth.accessToken; + } + return originalBearerToken(plugin); +} + +async function resolveLiveKimiAccessToken(plugin: Record): Promise { + 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): 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): { + value: Record; + changed: boolean; +} { + let next: Record | 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): { value: Record; changed: boolean } { + if (!Array.isArray(body.tools)) { + return { value: body, changed: false }; + } + + let changed = false; + const removedToolNames = new Set(); + 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): 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[] { + 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 | undefined { + const authorization = originalAuthHeader(plugin, "authorization"); + const prefix = "Bearer "; + return authorization?.startsWith(prefix) ? authorization.slice(prefix.length).trim() || undefined : undefined; +} + +function originalAuthHeader(plugin: Record, 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, 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 | 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, 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 | undefined, name: string): string | undefined { + const value = recordHeaderValue(headers, name); + return typeof value === "string" ? value : undefined; +} + +function recordHeaderValue(headers: Record | 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 | 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): { 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(); + 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; +} diff --git a/packages/core/src/gateway/core-runtime/supervisor.ts b/packages/core/src/gateway/core-runtime/supervisor.ts index 89cf2433..3cb63e26 100644 --- a/packages/core/src/gateway/core-runtime/supervisor.ts +++ b/packages/core/src/gateway/core-runtime/supervisor.ts @@ -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(); for (const value of [...(current || "").split(","), ...values]) { diff --git a/packages/core/src/gateway/features/anthropic-response-model.ts b/packages/core/src/gateway/features/anthropic-response-model.ts new file mode 100644 index 00000000..32348465 --- /dev/null +++ b/packages/core/src/gateway/features/anthropic-response-model.ts @@ -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); +} diff --git a/packages/core/src/gateway/features/codex-multi-agent-bridge.ts b/packages/core/src/gateway/features/codex-multi-agent-bridge.ts new file mode 100644 index 00000000..57f15fa3 --- /dev/null +++ b/packages/core/src/gateway/features/codex-multi-agent-bridge.ts @@ -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): { + body: Record; + 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)); + if (flattened.length === 0) { + return [tool]; + } + changed = true; + return flattened; + }); + return { value: tools, changed }; +} + +function codexMultiAgentFunctionTool(tool: Record): Record { + 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): { 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; + } +} diff --git a/packages/core/src/gateway/features/hosted-web-search/request-transform.ts b/packages/core/src/gateway/features/hosted-web-search/request-transform.ts index b4992001..2e6238e7 100644 --- a/packages/core/src/gateway/features/hosted-web-search/request-transform.ts +++ b/packages/core/src/gateway/features/hosted-web-search/request-transform.ts @@ -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[], diff --git a/packages/core/src/gateway/features/model-discovery.ts b/packages/core/src/gateway/features/model-discovery.ts index d12df914..8a9e4eff 100644 --- a/packages/core/src/gateway/features/model-discovery.ts +++ b/packages/core/src/gateway/features/model-discovery.ts @@ -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> +): 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); } } diff --git a/packages/core/src/gateway/internal/clock.ts b/packages/core/src/gateway/internal/clock.ts index 59d80191..3884875f 100644 --- a/packages/core/src/gateway/internal/clock.ts +++ b/packages/core/src/gateway/internal/clock.ts @@ -1,3 +1,21 @@ -export function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); +export const DEFAULT_RETRY_AFTER_MS = 1000; + +export function delay(ms: number, signal?: AbortSignal): Promise { + if (signal?.aborted) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + let timer: ReturnType | undefined; + const complete = () => { + if (timer !== undefined) { + clearTimeout(timer); + } + signal?.removeEventListener("abort", complete); + resolve(); + }; + + signal?.addEventListener("abort", complete, { once: true }); + timer = setTimeout(complete, ms); + }); } diff --git a/packages/core/src/gateway/request/pipeline.ts b/packages/core/src/gateway/request/pipeline.ts index 47b44b74..9f3828b4 100644 --- a/packages/core/src/gateway/request/pipeline.ts +++ b/packages/core/src/gateway/request/pipeline.ts @@ -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 { diff --git a/packages/core/src/gateway/service.ts b/packages/core/src/gateway/service.ts index fcaee9a4..23be32bc 100644 --- a/packages/core/src/gateway/service.ts +++ b/packages/core/src/gateway/service.ts @@ -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"; diff --git a/packages/core/src/gateway/upstream/executor.ts b/packages/core/src/gateway/upstream/executor.ts index 6fd0e919..fa5bd0cd 100644 --- a/packages/core/src/gateway/upstream/executor.ts +++ b/packages/core/src/gateway/upstream/executor.ts @@ -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 { 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; + 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 { try { await response.arrayBuffer(); diff --git a/packages/core/src/profiles/service.ts b/packages/core/src/profiles/service.ts index aacb7322..dbf627fa 100644 --- a/packages/core/src/profiles/service.ts +++ b/packages/core/src/profiles/service.ts @@ -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([ diff --git a/packages/core/src/providers/account-service.ts b/packages/core/src/providers/account-service.ts index 10d682fb..106d95fc 100644 --- a/packages/core/src/providers/account-service.ts +++ b/packages/core/src/providers/account-service.ts @@ -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; + loginUrl?: string; + method: "GET" | "POST"; + provider: GatewayProviderConfig; + requestOrigin: string; + timeoutMs?: number; +}; + +export type ProviderAccountWebContentFetchResponse = { + payload: unknown; +}; + +export type ProviderAccountWebContentFetchHandler = ( + request: ProviderAccountWebContentFetchRequest +) => Promise; + const defaultRefreshIntervalMs = 5 * 60 * 1000; const minRefreshIntervalMs = 30 * 1000; const maxErrorRefreshIntervalMs = 60 * 1000; @@ -109,6 +129,11 @@ const cache = new Map(); const codexOauthCache = new Map(); const inFlightRefreshes = new Map>(); 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 { + 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[] { 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 | 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, id: string, defaultLabel: string, + source: ProviderAccountConnectorSource, fallbackData?: Record ): 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 { + 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"; } diff --git a/packages/core/src/providers/probe.ts b/packages/core/src/providers/probe.ts index 8a66544f..c9872d8c 100644 --- a/packages/core/src/providers/probe.ts +++ b/packages/core/src/providers/probe.ts @@ -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 { 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 { - return apiKey - ? { - authorization: `Bearer ${apiKey}` - } - : {}; + return authorizationHeaders(apiKey); } function anthropicHeaders(apiKey: string | undefined): Record { + 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 { - 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 { + 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 { diff --git a/packages/core/src/proxy/undici-proxy-agent.ts b/packages/core/src/proxy/undici-proxy-agent.ts index e94e364f..97c9a7b6 100644 --- a/packages/core/src/proxy/undici-proxy-agent.ts +++ b/packages/core/src/proxy/undici-proxy-agent.ts @@ -1 +1 @@ -export { ProxyAgent } from "undici"; +export { Agent, ProxyAgent } from "undici"; diff --git a/packages/core/src/routing/model-registry.ts b/packages/core/src/routing/model-registry.ts index 881f02f9..0427301e 100644 --- a/packages/core/src/routing/model-registry.ts +++ b/packages/core/src/routing/model-registry.ts @@ -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); diff --git a/packages/core/test/integration/agents/codex-cli-middleware-runtime.test.mjs b/packages/core/test/integration/agents/codex-cli-middleware-runtime.test.mjs index 593f8f9d..57a1ce3c 100644 --- a/packages/core/test/integration/agents/codex-cli-middleware-runtime.test.mjs +++ b/packages/core/test/integration/agents/codex-cli-middleware-runtime.test.mjs @@ -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) { diff --git a/packages/core/test/integration/gateway/gateway-virtual-models.test.mjs b/packages/core/test/integration/gateway/gateway-virtual-models.test.mjs index 4db9c649..56a49ff6 100644 --- a/packages/core/test/integration/gateway/gateway-virtual-models.test.mjs +++ b/packages/core/test/integration/gateway/gateway-virtual-models.test.mjs @@ -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: [ diff --git a/packages/core/test/integration/profiles/profile-service.test.mjs b/packages/core/test/integration/profiles/profile-service.test.mjs index 2f9f0c84..6143137a 100644 --- a/packages/core/test/integration/profiles/profile-service.test.mjs +++ b/packages/core/test/integration/profiles/profile-service.test.mjs @@ -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"; diff --git a/packages/core/test/unit/agents/bot-gateway-env.test.mjs b/packages/core/test/unit/agents/bot-gateway-env.test.mjs index 9229067e..46a20eab 100644 --- a/packages/core/test/unit/agents/bot-gateway-env.test.mjs +++ b/packages/core/test/unit/agents/bot-gateway-env.test.mjs @@ -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"); +}); diff --git a/packages/core/test/unit/agents/claude-app-gateway-models.test.mjs b/packages/core/test/unit/agents/claude-app-gateway-models.test.mjs index 89d9b777..10d5308f 100644 --- a/packages/core/test/unit/agents/claude-app-gateway-models.test.mjs +++ b/packages/core/test/unit/agents/claude-app-gateway-models.test.mjs @@ -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: [ diff --git a/packages/core/test/unit/agents/claude-app-vm-storage.test.mjs b/packages/core/test/unit/agents/claude-app-vm-storage.test.mjs new file mode 100644 index 00000000..7dcf4561 --- /dev/null +++ b/packages/core/test/unit/agents/claude-app-vm-storage.test.mjs @@ -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; +} diff --git a/packages/core/test/unit/agents/codex-model-catalog.test.mjs b/packages/core/test/unit/agents/codex-model-catalog.test.mjs index 9432cc82..94786ca7 100644 --- a/packages/core/test/unit/agents/codex-model-catalog.test.mjs +++ b/packages/core/test/unit/agents/codex-model-catalog.test.mjs @@ -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: [ diff --git a/packages/core/test/unit/agents/local-agent-provider-claude-code.test.mjs b/packages/core/test/unit/agents/local-agent-provider-claude-code.test.mjs index cc28f151..d0f6d3cc 100644 --- a/packages/core/test/unit/agents/local-agent-provider-claude-code.test.mjs +++ b/packages/core/test/unit/agents/local-agent-provider-claude-code.test.mjs @@ -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"="${item.account}"`, + ` "mdat"=0x00 "${item.modified}Z\\000"`, + ` "svce"="${item.service}"` + ].join("\n")), + [ + `keychain: "/tmp/login.keychain-db"`, + "attributes:", + ` "acct"="unrelated"`, + ` "svce"="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"); diff --git a/packages/core/test/unit/agents/provider-plugin-runtime-identity.test.mjs b/packages/core/test/unit/agents/provider-plugin-runtime-identity.test.mjs index f4178d45..c38c2c7b 100644 --- a/packages/core/test/unit/agents/provider-plugin-runtime-identity.test.mjs +++ b/packages/core/test/unit/agents/provider-plugin-runtime-identity.test.mjs @@ -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 }); diff --git a/packages/core/test/unit/config/provider-protocol-capability.test.mjs b/packages/core/test/unit/config/provider-protocol-capability.test.mjs new file mode 100644 index 00000000..753dcfbe --- /dev/null +++ b/packages/core/test/unit/config/provider-protocol-capability.test.mjs @@ -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); +}); diff --git a/packages/core/test/unit/gateway/anthropic-response-model.test.mjs b/packages/core/test/unit/gateway/anthropic-response-model.test.mjs new file mode 100644 index 00000000..0d29e876 --- /dev/null +++ b/packages/core/test/unit/gateway/anthropic-response-model.test.mjs @@ -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"); + } +} diff --git a/packages/core/test/unit/gateway/api-key-authorizer.test.mjs b/packages/core/test/unit/gateway/api-key-authorizer.test.mjs new file mode 100644 index 00000000..8addd447 --- /dev/null +++ b/packages/core/test/unit/gateway/api-key-authorizer.test.mjs @@ -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*===/); +}); diff --git a/packages/core/test/unit/gateway/codex-multi-agent-bridge.test.mjs b/packages/core/test/unit/gateway/codex-multi-agent-bridge.test.mjs new file mode 100644 index 00000000..6737b2ae --- /dev/null +++ b/packages/core/test/unit/gateway/codex-multi-agent-bridge.test.mjs @@ -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"); +}); diff --git a/packages/core/test/unit/gateway/gateway-fetch-preload.test.mjs b/packages/core/test/unit/gateway/gateway-fetch-preload.test.mjs new file mode 100644 index 00000000..97f2f216 --- /dev/null +++ b/packages/core/test/unit/gateway/gateway-fetch-preload.test.mjs @@ -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 + }; +} diff --git a/packages/core/test/unit/gateway/local-agent-auth-provider-hook.test.mjs b/packages/core/test/unit/gateway/local-agent-auth-provider-hook.test.mjs new file mode 100644 index 00000000..2ece12b8 --- /dev/null +++ b/packages/core/test/unit/gateway/local-agent-auth-provider-hook.test.mjs @@ -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)); +} diff --git a/packages/core/test/unit/gateway/routing-architecture.test.mjs b/packages/core/test/unit/gateway/routing-architecture.test.mjs index 96e6848f..1c5364db 100644 --- a/packages/core/test/unit/gateway/routing-architecture.test.mjs +++ b/packages/core/test/unit/gateway/routing-architecture.test.mjs @@ -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 = [ diff --git a/packages/core/test/unit/gateway/upstream-executor.test.mjs b/packages/core/test/unit/gateway/upstream-executor.test.mjs new file mode 100644 index 00000000..a7e17807 --- /dev/null +++ b/packages/core/test/unit/gateway/upstream-executor.test.mjs @@ -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; + } +}); diff --git a/packages/core/test/unit/providers/provider-account-service.test.mjs b/packages/core/test/unit/providers/provider-account-service.test.mjs index b1ab8821..1722bef1 100644 --- a/packages/core/test/unit/providers/provider-account-service.test.mjs +++ b/packages/core/test/unit/providers/provider-account-service.test.mjs @@ -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-")); diff --git a/packages/core/test/unit/providers/provider-probe.test.mjs b/packages/core/test/unit/providers/provider-probe.test.mjs index 4585f54e..38b38c23 100644 --- a/packages/core/test/unit/providers/provider-probe.test.mjs +++ b/packages/core/test/unit/providers/provider-probe.test.mjs @@ -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; diff --git a/packages/electron/package.json b/packages/electron/package.json index 3af55fe8..8e0a17ff 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -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", diff --git a/packages/electron/src/main/ipc.ts b/packages/electron/src/main/ipc.ts index 476add08..83d5ef4c 100644 --- a/packages/electron/src/main/ipc.ts +++ b/packages/electron/src/main/ipc.ts @@ -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(); const gatewayPluginPermissionIdSet = new Set(GATEWAY_PLUGIN_PERMISSION_IDS); const gatewayPluginSurfaceIdSet = new Set(GATEWAY_PLUGIN_SURFACE_IDS); +registerProviderAccountWebContentFetchHandler({ BrowserWindow, WebContentsView, session }); + function applyAppThemePreference(theme: AppConfig["theme"]): void { applyNativeThemePreference(theme); trayController.refreshTheme(theme); diff --git a/packages/electron/src/main/provider-account-webcontent.ts b/packages/electron/src/main/provider-account-webcontent.ts new file mode 100644 index 00000000..9cfafc88 --- /dev/null +++ b/packages/electron/src/main/provider-account-webcontent.ts @@ -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; + loadURL: (url: string) => Promise; + }; +}; + +type WebContentSession = { + forceReloadProxyConfig?: () => Promise; +}; + +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 | undefined): Record { + const output: Record = { + 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(promise: Promise, timeoutMs: number, message: string): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise, + new Promise((_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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export const providerAccountWebContentInternalsForTest = { + maxResponseBytes, + normalizeRequestHeaders, + parseWebContentFetchResult, + webContentFetchScript +}; diff --git a/packages/electron/test/unit/provider-account-webcontent.test.ts b/packages/electron/test/unit/provider-account-webcontent.test.ts new file mode 100644 index 00000000..66e46883 --- /dev/null +++ b/packages/electron/test/unit/provider-account-webcontent.test.ts @@ -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]); +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 893dab5f..25be5c1c 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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": { diff --git a/packages/ui/src/pages/home/App.tsx b/packages/ui/src/pages/home/App.tsx index 69479c34..2ed32c7a 100644 --- a/packages/ui/src/pages/home/App.tsx +++ b/packages/ui/src/pages/home/App.tsx @@ -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)); } diff --git a/packages/ui/src/pages/home/components/dashboard.tsx b/packages/ui/src/pages/home/components/dashboard.tsx index c6852087..db87b8ec 100644 --- a/packages/ui/src/pages/home/components/dashboard.tsx +++ b/packages/ui/src/pages/home/components/dashboard.tsx @@ -2374,7 +2374,14 @@ function ProviderAccountsOverview({ })} ) : ( -
+
{visibleAccounts.map((account) => { return ; })} @@ -2406,7 +2413,7 @@ function ProviderAccountSinglePanel({ return (
-
+
{providerAccountSnapshotLabel(account)}
{providerAccountShowRefreshTime(dimensions) ?
{formatProviderAccountRefreshTime(account, t)}
: null} @@ -2453,7 +2460,7 @@ function ProviderAccountSummaryCard({ const showQuotaVisual = providerAccountUsesQuotaVisual(variant) && quotaMeters.length > 0; return ( -
+
{providerAccountSnapshotLabel(account)}
@@ -2470,7 +2477,7 @@ function ProviderAccountSummaryCard({
) : meters.length > 0 ? ( -
+
{meters.map((meter) => ( ))} @@ -2563,7 +2570,7 @@ function ProviderAccountMeterLine({ ) : null}
{title}
-
{formatProviderAccountMeterValue(meter)}
+
{formatProviderAccountMeterValue(meter, t)}
); diff --git a/packages/ui/src/pages/home/components/layout.tsx b/packages/ui/src/pages/home/components/layout.tsx index 7e4b9f0b..8dd8d690 100644 --- a/packages/ui/src/pages/home/components/layout.tsx +++ b/packages/ui/src/pages/home/components/layout.tsx @@ -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({
-
+ + {providerCheckOpen ? ( + setProviderCheckOpen(false)} + /> + ) : null} ); } diff --git a/packages/ui/src/pages/home/components/profiles.tsx b/packages/ui/src/pages/home/components/profiles.tsx index 5c75457b..36eb03f5 100644 --- a/packages/ui/src/pages/home/components/profiles.tsx +++ b/packages/ui/src/pages/home/components/profiles.tsx @@ -954,7 +954,10 @@ export function AddProfileForm({ {showAdvancedSettings ? (
- - - onChange({ routingEnhancedRoute })} - /> -
- ) : null} - {draft.routingEnabled ? ( -
+
{t("Profile routes")}
- ) : null}
) : null} {ruleDialog ? ( @@ -1235,6 +1203,49 @@ function ProfileRoutingSettings({ ); } +function ProfileEnhancedRouteSetting({ + draft, + onChange +}: { + draft: AddProfileDraft; + onChange: (patch: Partial) => void; +}) { + const t = useAppText(); + const enhancedRouteDescription = draft.agent === "codex" + ? t("Enhanced route description Codex") + : t("Enhanced route description Claude Code"); + + return ( +
+
+ + {t("Enhanced route")} + + + + + onChange({ routingEnhancedRoute })} + /> +
+
+ ); +} + function createProfileRoutingRuleDraftFromRule(rule: RouterRule): AddRoutingRuleDraft { const draft = createRoutingRuleDraftFromRule(rule); return draft.type === "condition" diff --git a/packages/ui/src/pages/home/components/providers.tsx b/packages/ui/src/pages/home/components/providers.tsx index 725878dd..51df3486 100644 --- a/packages/ui/src/pages/home/components/providers.tsx +++ b/packages/ui/src/pages/home/components/providers.tsx @@ -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 (
{loading ? : state === "success" ? : } @@ -2532,7 +2532,7 @@ export function AddProviderForm({
- {error ?
{error}
: null} + {error ?
{error}
: 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 })} />
- {t("Supports standard, http-json, plugin, and local-estimate connectors.")} + {t("Supports standard, http-json, webcontent-json, plugin, and local-estimate connectors.")}
) : null} +
+ + {testResult ? 0 ? "success" : "outline"}>{testResult.meters.length} {t("meters")} : null} +
+ {testResult ? ( + undefined} /> + ) : null}
) : null}
@@ -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([]); - const [checkResult, setCheckResult] = useState(); const [activeStep, setActiveStep] = useState("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({ - {checkConfirmOpen ? ( - !open && !checkConfirmBusy && setCheckConfirmOpen(false)}> - - -
- {t("Check Connection")} -
- -
- -
-
-
- - {t("This check sends real model requests with your provider API key and may consume account balance.")} -
-
- {t("Generated output is limited to 1 token for connectivity checks.")} -
-
- -
-
-
{t("Models to check")}
-
- - -
-
-
-
- {checkModels.map((model) => { - const checked = checkModelSelection.includes(model); - return ( - - ); - })} -
-
-
- - {checkResult ? : null} -
-
- - - - -
-
+ {checkConfirmOpen && onCheck ? ( + 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; + onClose: () => void; +}) { + const t = useAppText(); + const [busy, setBusy] = useState(false); + const [selection, setSelection] = useState(() => mergeProviderModelLists(models)); + const [result, setResult] = useState(); + 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 ( + !open && !busy && onClose()}> + + +
+ {t("Check Connection")} +
+ +
+ +
+
+
+ + {t("This check sends real model requests with your provider API key and may consume account balance.")} +
+
+ {t("Generated output is limited to 1 token for connectivity checks.")} +
+
+ +
+
+
{t("Models to check")}
+
+ + +
+
+
+
+ {models.map((model) => { + const checked = selection.includes(model); + return ( + + ); + })} +
+
+
+ + {result ? : null} +
+
+ + + + +
+
+ ); +} + 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({
{loading ? : catalog.length}
- {!loading ? ( -
-
- - onQueryChange(event.target.value)} - placeholder={t("Search provider models")} - value={query} - /> -
+
+
+ + onQueryChange(event.target.value)} + placeholder={t("Search provider models")} + value={query} + />
- ) : null} +
{loading ? ( ) : visibleCatalogModels.length === 0 ? (
- {catalog.length === 0 ? t("No provider models") : t("No matching models")} +
{catalog.length === 0 ? t("No provider models") : t("No matching models")}
+ {catalog.length === 0 ? ( +
{t("This provider did not return a model list. Add model IDs with Custom model.")}
+ ) : null}
) : (
@@ -3738,10 +3807,9 @@ function ProviderModelPicker({
0 ? "secondary" : "outline"}>{selectedModels.length}
- {!loading ? ( -
-
- +
+
+ {customModelEditing ? ( )} - -
+
- ) : null} +
- {loading ? ( - - ) : ( - - )} +
); } -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 ?
: null} + {index % 2 === 0 ?
: null}
diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index 61ae46f4..1ac931d4 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -359,6 +359,7 @@ export const appCopy: Record = { "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 = { "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 = { "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.": "网关已经配置好。准备测试请求时,请从工具栏启动服务。", diff --git a/packages/ui/src/pages/home/shared/index.tsx b/packages/ui/src/pages/home/shared/index.tsx index 0e782e7f..d2db4533 100644 --- a/packages/ui/src/pages/home/shared/index.tsx +++ b/packages/ui/src/pages/home/shared/index.tsx @@ -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"; diff --git a/packages/ui/src/pages/home/shared/log-body-worker-protocol.ts b/packages/ui/src/pages/home/shared/log-body-worker-protocol.ts new file mode 100644 index 00000000..0c905ed9 --- /dev/null +++ b/packages/ui/src/pages/home/shared/log-body-worker-protocol.ts @@ -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"); +} diff --git a/packages/ui/src/pages/home/shared/log-body.worker.ts b/packages/ui/src/pages/home/shared/log-body.worker.ts new file mode 100644 index 00000000..3dace470 --- /dev/null +++ b/packages/ui/src/pages/home/shared/log-body.worker.ts @@ -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) => void) | null; + postMessage: (message: LogBodyWorkerResponse) => void; +}; + +const worker = self as unknown as LogBodyWorkerGlobal; +let cachedBody: CachedFormattedBody | undefined; + +worker.onmessage = (event: MessageEvent) => { + 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) + }; +} diff --git a/packages/ui/src/pages/home/shared/logs.ts b/packages/ui/src/pages/home/shared/logs.ts index 630f957f..56e9574f 100644 --- a/packages/ui/src/pages/home/shared/logs.ts +++ b/packages/ui/src/pages/home/shared/logs.ts @@ -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; diff --git a/packages/ui/src/pages/home/shared/provider-accounts.ts b/packages/ui/src/pages/home/shared/provider-accounts.ts index ff7b5335..4b187830 100644 --- a/packages/ui/src/pages/home/shared/provider-accounts.ts +++ b/packages/ui/src/pages/home/shared/provider-accounts.ts @@ -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 { diff --git a/packages/ui/src/pages/home/shared/providers.ts b/packages/ui/src/pages/home/shared/providers.ts index 95898d79..458a3364 100644 --- a/packages/ui/src/pages/home/shared/providers.ts +++ b/packages/ui/src/pages/home/shared/providers.ts @@ -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 { 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 : [], diff --git a/packages/ui/test/component/layout.test.tsx b/packages/ui/test/component/layout.test.tsx index 54841a7d..5eb0aa51 100644 --- a/packages/ui/test/component/layout.test.tsx +++ b/packages/ui/test/component/layout.test.tsx @@ -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( + 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, /