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 ec07434b..1b67cdba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "packages/*" ], "dependencies": { - "@the-next-ai/ai-gateway": "^1.0.15", + "@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.15", - "resolved": "https://registry.npmjs.org/@the-next-ai/ai-gateway/-/ai-gateway-1.0.15.tgz", - "integrity": "sha512-U5SnIBGXHVq0uzzljpUb/hvND5cGehzMZegtvnB8+pRsRTM19yrAsPSaUocolB7JYun7TlKhi+BdOWp5Az17gA==", + "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", @@ -9546,7 +9556,7 @@ "version": "3.0.18", "license": "MIT", "dependencies": { - "@the-next-ai/ai-gateway": "^1.0.15", + "@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", @@ -9563,7 +9573,7 @@ "name": "@claude-code-router/core", "version": "3.0.18", "dependencies": { - "@the-next-ai/ai-gateway": "^1.0.15", + "@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/package.json b/package.json index a06b6b54..b2da2f3b 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3" }, "dependencies": { - "@the-next-ai/ai-gateway": "^1.0.15", + "@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 fa12f8e1..ff9b87a5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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.15", + "@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 39c717bd..ea1129b4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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.15", + "@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/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/gateway/core-runtime/config-compiler.ts b/packages/core/src/gateway/core-runtime/config-compiler.ts index 3a176026..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[] @@ -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", 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/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/request/pipeline.ts b/packages/core/src/gateway/request/pipeline.ts index 47b44b74..29605d47 100644 --- a/packages/core/src/gateway/request/pipeline.ts +++ b/packages/core/src/gateway/request/pipeline.ts @@ -30,6 +30,7 @@ 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 { 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 +42,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 +156,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 +382,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, @@ -719,7 +712,7 @@ 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) { + if (codexApplyPatchBridgeActive || codexMultiAgentBridgeActive || appendContextArchiveFooter || transformCodexCompactResponse) { responseHeaders.delete("content-length"); } if ((appendContextArchiveFooter || transformCodexCompactResponse) && contextArchiveResponseContentType) { @@ -771,14 +764,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; + : multiAgentResponseBody; const archiveResponseProtocol = requestProtocolForPath(upstreamPath) ?? requestProtocol ?? "anthropic_messages"; const responseBody = appendContextArchiveFooter && contextArchiveRecord ? contextArchiveHandoffResponseStream( @@ -796,7 +792,7 @@ export class GatewayRequestPipeline { codexCompactCompatResponseMode ) : hostedWebSearchResponseBody; - const responseStreams = uniqueStreams([upstreamBody, patchedResponseBody, hostedWebSearchResponseBody, responseBody]); + const responseStreams = uniqueStreams([upstreamBody, patchedResponseBody, multiAgentResponseBody, hostedWebSearchResponseBody, responseBody]); const sampler = createBodySampler(); const sseErrorDetector = createSseErrorDetector(responseHeaders.get("content-type") ?? undefined); let streamDetectedError: string | undefined; 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 c85f585b..d8ca72da 100644 --- a/packages/core/src/gateway/upstream/executor.ts +++ b/packages/core/src/gateway/upstream/executor.ts @@ -265,16 +265,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 +324,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 +432,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 }); @@ -904,9 +990,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 +997,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/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/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/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/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/upstream-executor.test.mjs b/packages/core/test/unit/gateway/upstream-executor.test.mjs index edac51b8..7fc1cc00 100644 --- a/packages/core/test/unit/gateway/upstream-executor.test.mjs +++ b/packages/core/test/unit/gateway/upstream-executor.test.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { fetchUpstreamWithFallback } from "@ccr/core/gateway/upstream/executor.ts"; +import { RequestRouteTraceRecorder } from "@ccr/core/observability/route-trace.ts"; const retryConfig = { Providers: [], @@ -64,3 +65,110 @@ test("retry backoff stops after client aborts a network error", async () => { throw new Error("upstream unavailable"); }); }); + +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/ui/src/pages/home/components/network-logs.tsx b/packages/ui/src/pages/home/components/network-logs.tsx index 0cd02c20..3ad9038c 100644 --- a/packages/ui/src/pages/home/components/network-logs.tsx +++ b/packages/ui/src/pages/home/components/network-logs.tsx @@ -4,10 +4,12 @@ import type { RequestRouteTrace, RequestRouteTraceChange, RequestRouteTraceHop } import { AnimatedIconSwap, Check, ChevronDown, ChevronLeft, ChevronRight, clampNumber, clientInitial, cn, Copy, copyTextToClipboard, - Database, Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle, filterLogText, formatBytes, formatCompactNumber, formatDuration, + createLogBodyPreviewText, Database, Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle, filterLogText, formatBytes, formatCompactNumber, formatDuration, formatLogBodyView, formatLogDateTime, formatLogTokenSummary, formatNetworkRequestRaw, formatNetworkResponseRaw, formatRouteTracePath, formatUsdCost, - isJsonContainer, jsonChildPath, logRequestModel, - logResolvedRouteModel, logSelectOptions, motion, MoveRight, Network, networkCodeLabel, + FormattedLogBody, + isJsonContainer, isLargeLogBody, jsonChildPath, logRequestModel, + LogBodyFormatMode, logBodyLargeTextThreshold, logBodyPreviewTextLimit, LogBodyWorkerResponse, + logResolvedRouteModel, logResponseModel, logSelectOptions, motion, MoveRight, Network, networkCodeLabel, networkExchangeMatchesQuery, networkHeaderRows, networkLifecycleLabel, networkQueryRows, networkRowId, networkSummaryRows, Pause, Play, ProxyNetworkBody, ProxyNetworkExchange, ProxyNetworkSnapshot, ProxyStatus, ReactNode, ReactPointerEvent, RefreshCw, RequestLogBody, RequestLogEntry, RequestLogListFilter, @@ -19,11 +21,10 @@ import { TooltipPortal } from "@/components/ui/tooltip"; type NetworkRequestTab = "body" | "header" | "query" | "raw" | "summary"; type NetworkResponseTab = "body" | "header" | "raw"; -const logBodyViewCacheLimit = 12; const logJsonAutoExpandEntryLimit = 60; const logJsonContainerPreviewLimit = 80; const logJsonAutoExpandTextLimit = 160 * 1024; -const logBodyViewCache = new Map>(); +const logBodyWorkerFilterDebounceMs = 180; type LogTableColumnId = "time" | "status" | "stream" | "model" | "credential" | "tokens" | "duration"; type LogTableColumn = { id: LogTableColumnId; @@ -1496,6 +1497,247 @@ function LogStreamCell({ entry }: { entry: RequestLogEntry }) { type LogPayloadTab = "body" | "header"; +type LogBodyPanelView = FormattedLogBody & { + bodyKey: string; + error: string; + formattedTextLength: number; + large: boolean; + loading: boolean; + mode: LogBodyFormatMode; + preview: boolean; + query: string; + sourceSizeBytes: number; + visible: string; +}; + +function useLogBodyWorkerView( + body: RequestLogBody | undefined, + bodyKey: string, + mode: LogBodyFormatMode, + query: string +): LogBodyPanelView { + const debouncedQuery = useDebouncedValue(query, logBodyWorkerFilterDebounceMs); + const latestQueryRef = useRef(debouncedQuery); + const workerRef = useRef(); + const formatRequestIdRef = useRef(0); + const filterRequestIdRef = useRef(0); + const [bodyView, setBodyView] = useState(() => createInitialLogBodyPanelView(body, bodyKey, mode, query)); + + useEffect(() => { + latestQueryRef.current = debouncedQuery; + }, [debouncedQuery]); + + useEffect(() => { + const initial = createInitialLogBodyPanelView(body, bodyKey, mode, latestQueryRef.current); + setBodyView(initial); + + workerRef.current?.terminate(); + workerRef.current = undefined; + + if (isStaticLogBody(body)) { + setBodyView(createStaticLogBodyPanelView(body, bodyKey, mode, latestQueryRef.current)); + return; + } + + if (typeof Worker === "undefined") { + setBodyView({ + ...initial, + error: "Body formatter worker is unavailable.", + loading: false, + visible: initial.visible || "Body formatter worker is unavailable." + }); + return; + } + + const worker = createLogBodyFormatterWorker(); + const formatRequestId = formatRequestIdRef.current + 1; + formatRequestIdRef.current = formatRequestId; + filterRequestIdRef.current += 1; + workerRef.current = worker; + + worker.onmessage = (event: MessageEvent) => { + const response = event.data; + if (response.kind === "format-result") { + if (response.id !== formatRequestIdRef.current || response.bodyKey !== bodyKey || response.mode !== mode) { + return; + } + setBodyView(logBodyPanelViewFromWorkerResult(response)); + if (response.query !== latestQueryRef.current) { + postLogBodyFilter(worker, bodyKey, mode, latestQueryRef.current, filterRequestIdRef); + } + return; + } + + if (response.kind === "filter-result") { + if (response.id !== filterRequestIdRef.current || response.bodyKey !== bodyKey || response.mode !== mode) { + return; + } + setBodyView((current) => current.bodyKey === bodyKey && current.mode === mode + ? { ...current, query: response.query, visible: response.visible } + : current); + return; + } + + if ( + (response.operation === "format" && response.id === formatRequestIdRef.current) || + (response.operation === "filter" && response.id === filterRequestIdRef.current) + ) { + setBodyView((current) => current.bodyKey === bodyKey && current.mode === mode + ? { ...current, error: response.message, loading: false, visible: current.visible || response.message } + : current); + } + }; + + worker.onerror = (event) => { + setBodyView((current) => current.bodyKey === bodyKey && current.mode === mode + ? { ...current, error: event.message || "Body formatter worker failed.", loading: false } + : current); + }; + + worker.postMessage({ + body, + bodyKey, + id: formatRequestId, + kind: "format", + largeTextThreshold: logBodyLargeTextThreshold, + mode, + previewTextLimit: logBodyPreviewTextLimit, + query: latestQueryRef.current + }); + + return () => { + if (workerRef.current === worker) { + workerRef.current = undefined; + } + worker.terminate(); + }; + }, [body, bodyKey, mode]); + + useEffect(() => { + const worker = workerRef.current; + if (!worker || bodyView.loading || bodyView.bodyKey !== bodyKey || bodyView.mode !== mode || bodyView.query === debouncedQuery) { + return; + } + postLogBodyFilter(worker, bodyKey, mode, debouncedQuery, filterRequestIdRef); + }, [bodyKey, bodyView.bodyKey, bodyView.loading, bodyView.mode, bodyView.query, debouncedQuery, mode]); + + return bodyView; +} + +function useDebouncedValue(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = window.setTimeout(() => setDebounced(value), delayMs); + return () => window.clearTimeout(timer); + }, [delayMs, value]); + + return debounced; +} + +function createLogBodyFormatterWorker(): Worker { + return new Worker(new URL("../../assets/log-body.worker.js", window.location.href), { type: "module" }); +} + +function postLogBodyFilter( + worker: Worker, + bodyKey: string, + mode: LogBodyFormatMode, + query: string, + idRef: { current: number } +) { + const id = idRef.current + 1; + idRef.current = id; + worker.postMessage({ + bodyKey, + id, + kind: "filter", + mode, + query + }); +} + +function createInitialLogBodyPanelView( + body: RequestLogBody | undefined, + bodyKey: string, + mode: LogBodyFormatMode, + query: string +): LogBodyPanelView { + if (isStaticLogBody(body)) { + return createStaticLogBodyPanelView(body, bodyKey, mode, query); + } + + const large = isLargeLogBody(body, logBodyLargeTextThreshold); + const preview = large && mode !== "full"; + const text = preview + ? createLogBodyPreviewText(body, logBodyPreviewTextLimit) + : "Loading body..."; + return { + bodyKey, + error: "", + formattedTextLength: text.length, + large, + loading: true, + mode, + preview, + query, + sourceSizeBytes: body?.sizeBytes ?? 0, + text, + visible: text + }; +} + +function createStaticLogBodyPanelView( + body: RequestLogBody | undefined, + bodyKey: string, + mode: LogBodyFormatMode, + query: string +): LogBodyPanelView { + const text = body?.text || "No body"; + return { + bodyKey, + error: "", + formattedTextLength: text.length, + large: false, + loading: false, + mode, + preview: false, + query, + sourceSizeBytes: body?.sizeBytes ?? 0, + text, + visible: filterStaticLogBodyText(text, query) + }; +} + +function logBodyPanelViewFromWorkerResult(result: Extract): LogBodyPanelView { + return { + bodyKey: result.bodyKey, + error: "", + formattedTextLength: result.formattedTextLength, + json: result.json, + large: result.large, + loading: false, + mode: result.mode, + preview: result.preview, + query: result.query, + sourceSizeBytes: result.sourceSizeBytes, + text: result.text, + visible: result.visible + }; +} + +function isStaticLogBody(body: RequestLogBody | undefined): boolean { + return !body || (!body.text && body.sizeBytes === 0); +} + +function filterStaticLogBodyText(text: string, query: string): string { + const normalized = query.trim().toLowerCase(); + if (!normalized) { + return text; + } + return text.toLowerCase().includes(normalized) ? text : "No matching lines"; +} + function LogJsonPanel({ body, className, @@ -1514,20 +1756,25 @@ function LogJsonPanel({ const t = useAppText(); const [selectedTab, setSelectedTab] = useState("body"); const [preferTextBody, setPreferTextBody] = useState(false); + const [bodyMode, setBodyMode] = useState("preview"); const [fullscreenOpen, setFullscreenOpen] = useState(false); const [query, setQuery] = useState(""); const bodyKey = logBodyCacheKey(body); - const bodyView = useMemo(() => cachedFormatLogBodyView(bodyKey, body), [bodyKey]); + const bodyView = useLogBodyWorkerView(body, bodyKey, bodyMode, query); const formatted = bodyView.text; - const visible = useMemo(() => filterLogText(formatted, query), [formatted, query]); + const visible = bodyView.visible; const headerRows = useMemo(() => networkHeaderRows(headers ?? {}), [headers]); const [expandedJsonPaths, setExpandedJsonPaths] = useState>(() => createInitialVisibleJsonPaths(bodyView)); - const showJsonTree = bodyView.json !== undefined && query.trim() === "" && !preferTextBody; + const showJsonTree = bodyView.json !== undefined && query.trim() === "" && !preferTextBody && !bodyView.preview; + + useEffect(() => { + setPreferTextBody(false); + setBodyMode("preview"); + }, [bodyKey]); useEffect(() => { setExpandedJsonPaths(createInitialVisibleJsonPaths(bodyView)); - setPreferTextBody(false); - }, [bodyKey]); + }, [bodyView.bodyKey, bodyView.json, bodyView.text]); useEffect(() => { if (!fullscreenOpen) { @@ -1582,6 +1829,7 @@ function LogJsonPanel({ setBodyMode("full")} onQueryChange={setQuery} onToggleTextBody={() => setPreferTextBody((current) => !current)} preferTextBody={preferTextBody} @@ -1610,6 +1858,7 @@ function LogJsonPanel({ copyText={formatted} expandedJsonPaths={expandedJsonPaths} onClose={() => setFullscreenOpen(false)} + onLoadFullBody={() => setBodyMode("full")} onQueryChange={setQuery} onToggleJsonPath={toggleJsonPath} onToggleTextBody={() => setPreferTextBody((current) => !current)} @@ -1636,6 +1885,7 @@ function LogJsonPanel({ function LogJsonBodyToolbar({ body, bodyView, + onLoadFullBody, onQueryChange, onToggleTextBody, preferTextBody, @@ -1643,7 +1893,8 @@ function LogJsonBodyToolbar({ title }: { body?: RequestLogBody; - bodyView: ReturnType; + bodyView: LogBodyPanelView; + onLoadFullBody: () => void; onQueryChange: (value: string) => void; onToggleTextBody: () => void; preferTextBody: boolean; @@ -1651,6 +1902,12 @@ function LogJsonBodyToolbar({ title: string; }) { const t = useAppText(); + const canLoadFullBody = bodyView.preview && bodyView.large && query.trim() === ""; + const canToggleJsonText = bodyView.json !== undefined && query.trim() === ""; + const showToggleButton = canLoadFullBody || canToggleJsonText; + const toggleLabel = canLoadFullBody + ? bodyView.loading && bodyView.mode === "full" ? t("Loading full payload...") : t("Show full content") + : preferTextBody ? "JSON" : t("Show full content"); return (
@@ -1664,15 +1921,20 @@ function LogJsonBodyToolbar({ value={query} />
- {bodyView.json !== undefined && query.trim() === "" ? ( + {showToggleButton ? ( ) : null} + {bodyView.loading ? {t("Loading full payload...")} : null} + {bodyView.error ? {bodyView.error} : null} + {bodyView.preview ? {t("preview")} : null} + {bodyView.sourceSizeBytes > 0 ? {formatBytes(bodyView.sourceSizeBytes)} : null} {body?.contentType ? {body.contentType} : null} {body?.truncated ? {t("truncated")} : null} @@ -1706,6 +1968,7 @@ function LogJsonFullscreenViewer({ copyText, expandedJsonPaths, onClose, + onLoadFullBody, onQueryChange, onToggleJsonPath, onToggleTextBody, @@ -1718,11 +1981,12 @@ function LogJsonFullscreenViewer({ visible }: { body?: RequestLogBody; - bodyView: ReturnType; + bodyView: LogBodyPanelView; copyLabel: string; copyText: string; expandedJsonPaths: Set; onClose: () => void; + onLoadFullBody: () => void; onQueryChange: (value: string) => void; onToggleJsonPath: (path: string) => void; onToggleTextBody: () => void; @@ -1760,6 +2024,7 @@ function LogJsonFullscreenViewer({ { - const cached = logBodyViewCache.get(key); - if (cached) { - logBodyViewCache.delete(key); - logBodyViewCache.set(key, cached); - return cached; - } - - const value = formatLogBodyView(body); - logBodyViewCache.set(key, value); - while (logBodyViewCache.size > logBodyViewCacheLimit) { - const oldest = logBodyViewCache.keys().next().value; - if (!oldest) { - break; - } - logBodyViewCache.delete(oldest); - } - return value; -} - -function createInitialVisibleJsonPaths(bodyView: ReturnType): Set { +function createInitialVisibleJsonPaths(bodyView: FormattedLogBody): Set { if (!isJsonContainer(bodyView.json)) { return new Set(); } 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) + }; +}