mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-28 19:01:32 +08:00
Merge dev/3.1 into main for v3.0.19
# Conflicts: # packages/ui/src/pages/home/components/network-logs.tsx
This commit is contained in:
+2
-1
@@ -1,4 +1,4 @@
|
||||
import { buildBrowserRenderer, buildMain, buildRenderer, buildStyles, buildTrayRenderer, buildWebClientBridge, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyBundledClaudeRuntimePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml, syncUiRendererToRuntimeDists } from "./esbuild.config.mjs";
|
||||
import { buildBrowserRenderer, buildMain, buildRenderer, buildRequestLogBodyWorker, buildStyles, buildTrayRenderer, buildWebClientBridge, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyBundledClaudeRuntimePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml, syncUiRendererToRuntimeDists } from "./esbuild.config.mjs";
|
||||
|
||||
const mode = process.argv.includes("--dev") ? "development" : "production";
|
||||
|
||||
@@ -14,6 +14,7 @@ await Promise.all([
|
||||
buildMain({ mode }),
|
||||
buildBrowserRenderer({ mode }),
|
||||
buildRenderer({ mode }),
|
||||
buildRequestLogBodyWorker({ mode }),
|
||||
buildTrayRenderer({ mode }),
|
||||
buildWebClientBridge({ mode }),
|
||||
buildStyles({ minify: mode === "production" })
|
||||
|
||||
+15
-1
@@ -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);
|
||||
})
|
||||
]
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -27,6 +27,7 @@ const testProjects = {
|
||||
runtimeEntryPoints: {
|
||||
"runtime/fusion-vision-mcp": path.join(packageRoots.core, "mcp", "fusion-vision-mcp.ts"),
|
||||
"runtime/gateway-bootstrap": path.join(packageRoots.core, "gateway", "core-runtime", "gateway-bootstrap.ts"),
|
||||
"runtime/local-agent-auth-provider-hook": path.join(packageRoots.core, "gateway", "core-runtime", "local-agent-auth-provider-hook.ts"),
|
||||
"runtime/media-tools-proxy-mcp": path.join(packageRoots.core, "mcp", "media-tools-proxy-mcp.ts"),
|
||||
"runtime/request-log-worker": path.join(packageRoots.core, "observability", "request-log-worker.ts"),
|
||||
"runtime/route-script-worker": path.join(packageRoots.core, "routing", "route-script-worker.ts"),
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 319 KiB |
Generated
+16
-6
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -24,6 +24,13 @@ type BotGatewaySdkModule = {
|
||||
createBotGatewayClient: (options?: unknown) => unknown;
|
||||
};
|
||||
|
||||
type BotGatewayCommand = {
|
||||
args?: string[];
|
||||
command: string;
|
||||
cwd?: string;
|
||||
electronRunAsNode?: boolean;
|
||||
};
|
||||
|
||||
type QrSession = {
|
||||
botConfigId: string;
|
||||
client: BotGatewayClientWithRequest;
|
||||
@@ -172,22 +179,25 @@ export function cancelBotGatewayQrLogin(
|
||||
async function createQrClient(bot: BotGatewayRuntimeConfig, stateDir: string): Promise<BotGatewayClientWithRequest> {
|
||||
const sdk = await loadBotGatewaySdk();
|
||||
const command = resolveBotGatewayCommand(sdk, bot);
|
||||
const { electronRunAsNode, ...commandOptions } = command ?? {};
|
||||
const client = sdk.createBotGatewayClient({
|
||||
transport: "stdio",
|
||||
env: {
|
||||
...process.env,
|
||||
...(electronRunAsNode ? { ELECTRON_RUN_AS_NODE: "1" } : {}),
|
||||
BOT_GATEWAY_STATE_DIR: stateDir,
|
||||
CODEXL_HOME: CONFIGDIR
|
||||
},
|
||||
...command
|
||||
...commandOptions
|
||||
}) as BotGatewayClientWithRequest;
|
||||
if (!client || typeof client.request !== "function" || typeof client.health !== "function") {
|
||||
throw new Error("Bot Gateway SDK client does not expose request().");
|
||||
}
|
||||
attachBotGatewayStdioErrorHandler(client);
|
||||
return client;
|
||||
}
|
||||
|
||||
function resolveBotGatewayCommand(sdk: BotGatewaySdkModule, bot: BotGatewayRuntimeConfig): { args?: string[]; command: string; cwd?: string } | undefined {
|
||||
function resolveBotGatewayCommand(sdk: BotGatewaySdkModule, bot: BotGatewayRuntimeConfig): BotGatewayCommand | undefined {
|
||||
if (bot.command) {
|
||||
return {
|
||||
args: bot.args,
|
||||
@@ -199,21 +209,19 @@ function resolveBotGatewayCommand(sdk: BotGatewaySdkModule, bot: BotGatewayRunti
|
||||
return undefined;
|
||||
}
|
||||
const bundledPath = sdk.bundledStdioPath();
|
||||
const runnerPath = materializeBotGatewayStdioRunnerPath(bundledPath);
|
||||
return {
|
||||
args: [sanitizedBotGatewayStdioRunnerPath(bundledPath)],
|
||||
args: [runnerPath],
|
||||
command: process.execPath,
|
||||
cwd: path.dirname(bundledPath)
|
||||
cwd: path.dirname(runnerPath),
|
||||
electronRunAsNode: Boolean(process.versions.electron)
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizedBotGatewayStdioRunnerPath(sourcePath: string): string {
|
||||
export function materializeBotGatewayStdioRunnerPath(sourcePath: string, configDir = CONFIGDIR): string {
|
||||
const source = readFileSync(sourcePath, "utf8");
|
||||
const normalized = normalizeDuplicateShebangs(source);
|
||||
if (normalized === source) {
|
||||
return sourcePath;
|
||||
}
|
||||
|
||||
const targetDir = path.join(CONFIGDIR, "bot-gateway", "runners");
|
||||
const targetDir = path.join(configDir, "bot-gateway", "runners");
|
||||
const targetPath = path.join(targetDir, "bot-gateway-stdio.mjs");
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
if (!existsSync(targetPath) || readFileSync(targetPath, "utf8") !== normalized) {
|
||||
@@ -234,6 +242,44 @@ function normalizeDuplicateShebangs(source: string): string {
|
||||
return [lines[0], ...lines.slice(index)].join("\n");
|
||||
}
|
||||
|
||||
function attachBotGatewayStdioErrorHandler(client: BotGatewayClientWithRequest): void {
|
||||
const stdioClient = client as BotGatewayClientWithRequest & {
|
||||
child?: {
|
||||
listenerCount?: (eventName: string) => number;
|
||||
once: (eventName: string, listener: (error: Error) => void) => unknown;
|
||||
};
|
||||
pending?: Map<unknown, { reject?: (error: Error) => void }>;
|
||||
};
|
||||
const attach = () => {
|
||||
const child = stdioClient.child;
|
||||
if (!child || typeof child.once !== "function") {
|
||||
return;
|
||||
}
|
||||
const listenerCount = typeof child.listenerCount === "function" ? child.listenerCount("error") : 0;
|
||||
if (listenerCount > 0) {
|
||||
return;
|
||||
}
|
||||
child.once("error", (error: Error) => {
|
||||
const pending = stdioClient.pending instanceof Map ? stdioClient.pending : undefined;
|
||||
if (pending) {
|
||||
for (const request of pending.values()) {
|
||||
request.reject?.(error);
|
||||
}
|
||||
pending.clear();
|
||||
}
|
||||
});
|
||||
};
|
||||
const originalRequest = client.request.bind(client);
|
||||
client.request = <T = unknown>(method: string, params?: unknown): Promise<T> => {
|
||||
try {
|
||||
return originalRequest<T>(method, params);
|
||||
} finally {
|
||||
attach();
|
||||
}
|
||||
};
|
||||
attach();
|
||||
}
|
||||
|
||||
async function loadBotGatewaySdk(): Promise<BotGatewaySdkModule> {
|
||||
if (!sdkPromise) {
|
||||
sdkPromise = importBotGatewaySdk();
|
||||
|
||||
@@ -5498,6 +5498,7 @@ class BotGatewayBridge {
|
||||
});
|
||||
const clientOptions = botGatewaySdkClientOptions(this.config, env, sdk);
|
||||
this.client = sdk.createBotGatewayClient(clientOptions);
|
||||
attachBotGatewayStdioErrorHandler(this.client);
|
||||
await withTimeout(this.client.health(), this.config.startupTimeoutMs, "Bot Gateway health check timed out.");
|
||||
this.updateDiagnostics({ state: "connected", connectedAt: new Date().toISOString(), lastError: "" });
|
||||
await this.ensureIntegration();
|
||||
@@ -5686,10 +5687,13 @@ function botGatewaySdkImportSpecifier(value) {
|
||||
|
||||
function botGatewaySdkClientOptions(config, env, sdk) {
|
||||
const command = resolveBotGatewayCommand(config) || resolveBundledBotGatewayCommand(sdk);
|
||||
const commandOptions = Object.assign({}, command || {});
|
||||
const electronRunAsNode = Boolean(commandOptions.electronRunAsNode);
|
||||
delete commandOptions.electronRunAsNode;
|
||||
return {
|
||||
transport: "stdio",
|
||||
...(command || {}),
|
||||
env
|
||||
...commandOptions,
|
||||
env: electronRunAsNode ? { ...env, ELECTRON_RUN_AS_NODE: "1" } : env
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5709,20 +5713,18 @@ function resolveBundledBotGatewayCommand(sdk) {
|
||||
return undefined;
|
||||
}
|
||||
const bundledPath = sdk.bundledStdioPath();
|
||||
const runnerPath = materializeBotGatewayStdioRunnerPath(bundledPath);
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [sanitizedBotGatewayStdioRunnerPath(bundledPath)],
|
||||
cwd: path.dirname(bundledPath)
|
||||
args: [runnerPath],
|
||||
cwd: path.dirname(runnerPath),
|
||||
electronRunAsNode: Boolean(process.versions && process.versions.electron)
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizedBotGatewayStdioRunnerPath(sourcePath) {
|
||||
function materializeBotGatewayStdioRunnerPath(sourcePath) {
|
||||
const source = fs.readFileSync(sourcePath, "utf8");
|
||||
const normalized = normalizeDuplicateShebangs(source);
|
||||
if (normalized === source) {
|
||||
return sourcePath;
|
||||
}
|
||||
|
||||
const targetDir = path.join(CONFIG_DIR, "bot-gateway", "runners");
|
||||
const targetPath = path.join(targetDir, "bot-gateway-stdio.mjs");
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
@@ -5744,6 +5746,35 @@ function normalizeDuplicateShebangs(source) {
|
||||
return [lines[0], ...lines.slice(index)].join("\n");
|
||||
}
|
||||
|
||||
function attachBotGatewayStdioErrorHandler(client) {
|
||||
const attach = () => {
|
||||
const child = client && client.child;
|
||||
if (!child || typeof child.once !== "function") return;
|
||||
const listenerCount = typeof child.listenerCount === "function" ? child.listenerCount("error") : 0;
|
||||
if (listenerCount > 0) return;
|
||||
child.once("error", (error) => {
|
||||
const pending = client && client.pending instanceof Map ? client.pending : null;
|
||||
if (!pending) return;
|
||||
for (const request of pending.values()) {
|
||||
if (request && typeof request.reject === "function") request.reject(error);
|
||||
}
|
||||
pending.clear();
|
||||
});
|
||||
};
|
||||
if (!client || typeof client.request !== "function") {
|
||||
return;
|
||||
}
|
||||
const originalRequest = client.request.bind(client);
|
||||
client.request = (method, params) => {
|
||||
try {
|
||||
return originalRequest(method, params);
|
||||
} finally {
|
||||
attach();
|
||||
}
|
||||
};
|
||||
attach();
|
||||
}
|
||||
|
||||
function botGatewayClientRequest(client, method, params, timeoutMs) {
|
||||
if (!client || typeof client.request !== "function") {
|
||||
return Promise.reject(new Error("Bot Gateway SDK client does not expose request()."));
|
||||
|
||||
@@ -137,7 +137,7 @@ function codexModelCatalogItem(
|
||||
default_reasoning_level: profile.defaultReasoningLevel,
|
||||
default_reasoning_effort: profile.defaultReasoningLevel,
|
||||
default_reasoning_summary: profile.defaultReasoningSummary,
|
||||
description: `CCR gateway model ${model}`,
|
||||
description: profile.description ?? `CCR gateway model ${model}`,
|
||||
displayName: model,
|
||||
display_name: model,
|
||||
effective_context_window_percent: effectiveContextWindowPercent,
|
||||
@@ -179,6 +179,7 @@ type CodexCapabilityProfile = {
|
||||
applyPatchToolType: string | null;
|
||||
catalogEntry?: ModelCatalogEntry;
|
||||
contextWindow?: number;
|
||||
description?: string;
|
||||
defaultReasoningLevel: string | null;
|
||||
defaultReasoningSummary: string;
|
||||
effectiveContextWindowPercent?: number;
|
||||
@@ -250,6 +251,9 @@ function codexModelCapabilityProfile(
|
||||
applyPatchToolType,
|
||||
catalogEntry,
|
||||
contextWindow: providerModelMetadata?.contextWindow,
|
||||
description: provider
|
||||
? providerModelDescriptionFor(provider, providerModel)
|
||||
: undefined,
|
||||
defaultReasoningLevel: resolveDefaultReasoningLevel(
|
||||
providerModelMetadata?.defaultReasoningLevel !== undefined
|
||||
? providerModelMetadata.defaultReasoningLevel
|
||||
@@ -281,6 +285,17 @@ function providerModelMetadataFor(provider: GatewayProviderConfig, model: string
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
function providerModelDescriptionFor(provider: GatewayProviderConfig, model: string): string | undefined {
|
||||
const descriptions = provider.modelDescriptions ?? {};
|
||||
const direct = descriptions[model]?.trim();
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const normalized = model.trim().toLowerCase();
|
||||
const match = Object.entries(descriptions).find(([candidate]) => candidate.trim().toLowerCase() === normalized);
|
||||
return match?.[1]?.trim() || undefined;
|
||||
}
|
||||
|
||||
function codexProviderModelMetadataFor(provider: GatewayProviderConfig, model: string): ProviderModelMetadata | undefined {
|
||||
const metadata = providerModelMetadataFor(provider, model) ?? localCodexModelMetadataFor(provider, model);
|
||||
if (!isLocalCodexProvider(provider)) {
|
||||
|
||||
@@ -13,14 +13,16 @@ import { mediaToolsMcpServer } from "@ccr/core/mcp/grok-media-config";
|
||||
import { resolveGatewayPublicModelId } from "@ccr/core/gateway/features/model-discovery";
|
||||
import { activeProviderCredentials, inferProtocol, normalizedProviderCapabilities, normalizeProviderProtocol, providerCapabilityForClientProtocol, providerCapabilityInternalName, providerCapabilityNameMatches, providerCredentialInternalName, providerProtocolForClientProtocol, sortProviderCredentialsForConfig, toCoreGatewayProviders } from "@ccr/core/providers/runtime-topology";
|
||||
import { buildRawTraceConfig } from "@ccr/core/observability/raw-trace-sync";
|
||||
import { endpoint, resolveUndiciProxyAgentModule, resolveUpstreamHeaderSanitizerEntry, writeGatewayProxyPreloadFile } from "@ccr/core/gateway/core-runtime/supervisor";
|
||||
import { endpoint, resolveLocalAgentAuthProviderHookEntry, resolveUndiciProxyAgentModule, resolveUpstreamHeaderSanitizerEntry, writeGatewayProxyPreloadFile } from "@ccr/core/gateway/core-runtime/supervisor";
|
||||
import { billingUsageSyncHeader, billingUsageSyncPath, claudeCodeOauthBetaHeader, claudeCodeOauthRequiredBeta, coreGatewayAuthHeader, coreGatewayAuthTokenEnv } from "@ccr/core/gateway/internal/shared";
|
||||
import type { BrowserWebSearchMcpIntegration, CoreGatewayProvider } from "@ccr/core/gateway/internal/shared";
|
||||
import { uniqueStrings } from "@ccr/core/gateway/internal/collections";
|
||||
import { isLocalClaudeCodeOauthProviderPlugin, mergeAnthropicBetaValues } from "@ccr/core/providers/oauth-plugin";
|
||||
import { isLocalAgentOauthProviderPlugin } from "@ccr/core/gateway/core-runtime/local-agent-auth-provider-hook";
|
||||
import { resolveConfiguredProviderModelSelector, resolveUniqueConfiguredProviderModelSelector } from "@ccr/core/routing/model-resolution";
|
||||
|
||||
const upstreamHeaderSanitizerPluginKey = "ccr-upstream-header-sanitizer";
|
||||
const localAgentAuthProviderHookPluginKey = "ccr-local-agent-auth-provider-hooks";
|
||||
export const unlimitedVirtualModelToolCalls = Number.MAX_SAFE_INTEGER;
|
||||
export const unlimitedVirtualModelToolTurns = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
@@ -36,7 +38,8 @@ export async function compileCoreGatewayConfig(
|
||||
const pluginCoreGatewayConfig = pluginService.getCoreGatewayConfig();
|
||||
const configuredGatewayPlugins = Array.isArray(pluginCoreGatewayConfig.plugins)
|
||||
? pluginCoreGatewayConfig.plugins.filter((plugin) =>
|
||||
!isRecord(plugin) || stringValue(plugin.key) !== upstreamHeaderSanitizerPluginKey
|
||||
!isRecord(plugin) ||
|
||||
![localAgentAuthProviderHookPluginKey, upstreamHeaderSanitizerPluginKey].includes(stringValue(plugin.key) ?? "")
|
||||
)
|
||||
: [];
|
||||
const pluginBillingConfig = isRecord(pluginCoreGatewayConfig.billing) ? pluginCoreGatewayConfig.billing : {};
|
||||
@@ -82,6 +85,7 @@ export async function compileCoreGatewayConfig(
|
||||
.filter((provider): provider is CoreGatewayProvider => Boolean(provider)),
|
||||
...builtinToolArtifacts.providers
|
||||
];
|
||||
const localAgentAuthProviderHookPlugin = localAgentAuthProviderHookPluginConfig(providerPluginsWithCapabilityAliases);
|
||||
const pluginAgentConfig = isRecord(pluginCoreGatewayConfig.agent) ? pluginCoreGatewayConfig.agent : {};
|
||||
const pluginMcpServers = Array.isArray(pluginAgentConfig.mcpServers) ? pluginAgentConfig.mcpServers : [];
|
||||
const externalMcpServers = [
|
||||
@@ -138,6 +142,7 @@ export async function compileCoreGatewayConfig(
|
||||
port: config.gateway.corePort,
|
||||
plugins: [
|
||||
...configuredGatewayPlugins,
|
||||
...(localAgentAuthProviderHookPlugin ? [localAgentAuthProviderHookPlugin] : []),
|
||||
{
|
||||
enabled: true,
|
||||
key: upstreamHeaderSanitizerPluginKey,
|
||||
@@ -156,6 +161,17 @@ export async function compileCoreGatewayConfig(
|
||||
};
|
||||
}
|
||||
|
||||
function localAgentAuthProviderHookPluginConfig(providerPlugins: unknown[]): Record<string, unknown> | undefined {
|
||||
if (!providerPlugins.some(isLocalAgentOauthProviderPlugin)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
enabled: true,
|
||||
key: localAgentAuthProviderHookPluginKey,
|
||||
modulePath: resolveLocalAgentAuthProviderHookEntry()
|
||||
};
|
||||
}
|
||||
|
||||
function withProviderCapabilityPluginAliases(
|
||||
providerPlugins: unknown[],
|
||||
providers: GatewayProviderConfig[]
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
import { readClaudeCodeOauth, readGrokAuth, readKimiAuth, resolveGrokAuth, resolveKimiAuth } from "@ccr/core/agents/local-providers/service";
|
||||
import { grokAccessTokenExpired, grokClientVersion } from "@ccr/core/agents/local-providers/grok";
|
||||
import { kimiAccessTokenExpired, kimiIdentityHeaders } from "@ccr/core/agents/local-providers/kimi";
|
||||
import { transformCodexApplyPatchBridgeRequestBody } from "@ccr/core/gateway/features/codex-patch-bridge";
|
||||
import { claudeCodeOauthBetaHeader, claudeCodeOauthRequiredBeta } from "@ccr/core/gateway/internal/shared";
|
||||
import { isRecord, stringValue } from "@ccr/core/gateway/internal/value";
|
||||
import { mergeAnthropicBetaValues } from "@ccr/core/providers/oauth-plugin";
|
||||
|
||||
const configProviderPluginKeyPrefix = "config:";
|
||||
const localAgentProviderPluginKeyPrefix = "ccr-local-agent-";
|
||||
|
||||
type HeaderRecord = Record<string, string>;
|
||||
|
||||
type UpstreamRequest = {
|
||||
body?: unknown;
|
||||
bodyEncoding?: "bytes" | "form" | "json" | "none" | "text";
|
||||
headers?: HeaderRecord;
|
||||
method?: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type ProviderPluginInput = {
|
||||
model?: string;
|
||||
request?: {
|
||||
headers?: Record<string, string | string[] | undefined>;
|
||||
};
|
||||
upstreamRequest: UpstreamRequest;
|
||||
};
|
||||
|
||||
type ProviderHookResult = {
|
||||
ok: true;
|
||||
value: UpstreamRequest;
|
||||
} | {
|
||||
error: string;
|
||||
ok: false;
|
||||
};
|
||||
|
||||
type ProviderHook = {
|
||||
authenticate?: (input: ProviderPluginInput) => Promise<ProviderHookResult> | ProviderHookResult;
|
||||
key: string;
|
||||
provider?: string;
|
||||
providerName?: string;
|
||||
transformRequest?: (input: ProviderPluginInput) => Promise<ProviderHookResult> | ProviderHookResult;
|
||||
};
|
||||
|
||||
type LocalAgentOauthKind = "claude-code" | "grok" | "kimi";
|
||||
|
||||
export function createGatewayPlugin(input: { config?: Record<string, unknown> } = {}) {
|
||||
return {
|
||||
providerHooks: localAgentOauthProviderHooks(input.config)
|
||||
};
|
||||
}
|
||||
|
||||
export function localAgentOauthProviderHooks(config: Record<string, unknown> | undefined): ProviderHook[] {
|
||||
const providerPlugins = Array.isArray(config?.providerPlugins) ? config.providerPlugins : [];
|
||||
return providerPlugins
|
||||
.map(localAgentOauthProviderHook)
|
||||
.filter((hook): hook is ProviderHook => Boolean(hook));
|
||||
}
|
||||
|
||||
export function isLocalAgentOauthProviderPlugin(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
return localAgentOauthKind(value) !== undefined;
|
||||
}
|
||||
|
||||
function localAgentOauthProviderHook(plugin: unknown): ProviderHook | undefined {
|
||||
if (!isRecord(plugin)) {
|
||||
return undefined;
|
||||
}
|
||||
const kind = localAgentOauthKind(plugin);
|
||||
const key = stringValue(plugin.key);
|
||||
if (!kind || !key) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hook: ProviderHook = {
|
||||
key: `${configProviderPluginKeyPrefix}${key}`,
|
||||
provider: stringValue(plugin.provider),
|
||||
providerName: stringValue(plugin.providerName)
|
||||
};
|
||||
|
||||
if (kind === "grok") {
|
||||
hook.authenticate = (input) => authenticateWithBearer(input, () => resolveLiveGrokAccessToken(plugin), plugin, "Grok CLI access token was not found.");
|
||||
hook.transformRequest = (input) => transformGrokRequest(input, plugin);
|
||||
return hook;
|
||||
}
|
||||
|
||||
if (kind === "kimi") {
|
||||
hook.authenticate = (input) => authenticateWithBearer(input, () => resolveLiveKimiAccessToken(plugin), plugin, "Kimi CLI access token was not found.");
|
||||
hook.transformRequest = (input) => transformWithHeaders(input, kimiIdentityHeaders());
|
||||
return hook;
|
||||
}
|
||||
|
||||
hook.authenticate = (input) => authenticateClaudeCode(input, plugin);
|
||||
return hook;
|
||||
}
|
||||
|
||||
function localAgentOauthKind(plugin: Record<string, unknown>): LocalAgentOauthKind | undefined {
|
||||
const key = stringValue(plugin.key)?.toLowerCase();
|
||||
if (!key?.startsWith(localAgentProviderPluginKeyPrefix)) {
|
||||
return undefined;
|
||||
}
|
||||
if (key.includes("grok-cli-oauth")) {
|
||||
return "grok";
|
||||
}
|
||||
if (key.includes("kimi-cli-oauth")) {
|
||||
return "kimi";
|
||||
}
|
||||
if (key.includes("claude-code-oauth")) {
|
||||
return "claude-code";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function authenticateWithBearer(
|
||||
input: ProviderPluginInput,
|
||||
tokenResolver: () => Promise<string | undefined> | string | undefined,
|
||||
plugin: Record<string, unknown>,
|
||||
missingTokenError: string
|
||||
): Promise<ProviderHookResult> {
|
||||
const token = await tokenResolver() || originalBearerToken(plugin);
|
||||
if (!token) {
|
||||
return { error: missingTokenError, ok: false };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
...input.upstreamRequest,
|
||||
headers: withBearerAuth(input.upstreamRequest.headers, token, originalRemoveHeaders(plugin))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function authenticateClaudeCode(
|
||||
input: ProviderPluginInput,
|
||||
plugin: Record<string, unknown>
|
||||
): Promise<ProviderHookResult> {
|
||||
const token = readClaudeCodeOauth()?.accessToken || originalBearerToken(plugin);
|
||||
if (!token) {
|
||||
return { error: "Claude Code access token was not found.", ok: false };
|
||||
}
|
||||
const headers = withBearerAuth(input.upstreamRequest.headers, token, originalRemoveHeaders(plugin));
|
||||
headers[claudeCodeOauthBetaHeader] = mergeAnthropicBetaValues(
|
||||
requestHeader(input.request?.headers, claudeCodeOauthBetaHeader),
|
||||
originalAnthropicBetaDefault(plugin),
|
||||
claudeCodeOauthRequiredBeta
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
...input.upstreamRequest,
|
||||
headers
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveLiveGrokAccessToken(plugin: Record<string, unknown>): Promise<string | undefined> {
|
||||
const auth = await resolveGrokAuth().catch(() => readGrokAuth());
|
||||
if (auth?.accessToken && !grokAccessTokenExpired(auth)) {
|
||||
return auth.accessToken;
|
||||
}
|
||||
return originalBearerToken(plugin);
|
||||
}
|
||||
|
||||
async function resolveLiveKimiAccessToken(plugin: Record<string, unknown>): Promise<string | undefined> {
|
||||
const reference = kimiOauthReference(plugin);
|
||||
const auth = await resolveKimiAuth(reference).catch(() => readKimiAuth(reference));
|
||||
if (auth?.accessToken && !kimiAccessTokenExpired(auth)) {
|
||||
return auth.accessToken;
|
||||
}
|
||||
return originalBearerToken(plugin);
|
||||
}
|
||||
|
||||
function transformWithHeaders(input: ProviderPluginInput, headers: HeaderRecord): ProviderHookResult {
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
...input.upstreamRequest,
|
||||
headers: {
|
||||
...(input.upstreamRequest.headers ?? {}),
|
||||
...headers
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function transformGrokRequest(input: ProviderPluginInput, plugin: Record<string, unknown>): ProviderHookResult {
|
||||
const headers = {
|
||||
...(input.upstreamRequest.headers ?? {}),
|
||||
"x-grok-client-identifier": "xai-grok-cli",
|
||||
"x-grok-client-version": grokClientVersion(),
|
||||
"x-grok-model-override": input.model || originalRequestHeader(plugin, "x-grok-model-override") || ""
|
||||
};
|
||||
const body = transformGrokResponsesBody(input.upstreamRequest.body);
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
...input.upstreamRequest,
|
||||
body: body.changed ? body.value : input.upstreamRequest.body,
|
||||
headers
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function transformGrokResponsesBody(value: unknown): { value: unknown; changed: boolean } {
|
||||
if (!isRecord(value)) {
|
||||
return { value, changed: false };
|
||||
}
|
||||
const patched = transformCodexApplyPatchBridgeRequestBody(value);
|
||||
const sanitizedOptions = sanitizeGrokUnsupportedResponsesOptions(patched.body);
|
||||
const sanitized = sanitizeGrokResponsesTools(sanitizedOptions.value);
|
||||
return {
|
||||
value: sanitized.value,
|
||||
changed: patched.changed || sanitizedOptions.changed || sanitized.changed
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeGrokUnsupportedResponsesOptions(body: Record<string, unknown>): {
|
||||
value: Record<string, unknown>;
|
||||
changed: boolean;
|
||||
} {
|
||||
let next: Record<string, unknown> | undefined;
|
||||
for (const key of grokUnsupportedResponsesOptions) {
|
||||
if (Object.hasOwn(body, key)) {
|
||||
next ??= { ...body };
|
||||
delete next[key];
|
||||
}
|
||||
}
|
||||
return next ? { value: next, changed: true } : { value: body, changed: false };
|
||||
}
|
||||
|
||||
function sanitizeGrokResponsesTools(body: Record<string, unknown>): { value: Record<string, unknown>; changed: boolean } {
|
||||
if (!Array.isArray(body.tools)) {
|
||||
return { value: body, changed: false };
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const removedToolNames = new Set<string>();
|
||||
const tools = body.tools.filter((tool) => {
|
||||
const supported = isGrokSupportedResponsesTool(tool);
|
||||
if (!supported) {
|
||||
changed = true;
|
||||
const name = responseToolName(tool);
|
||||
if (name) {
|
||||
removedToolNames.add(name);
|
||||
}
|
||||
}
|
||||
return supported;
|
||||
});
|
||||
|
||||
if (!changed) {
|
||||
return { value: body, changed: false };
|
||||
}
|
||||
|
||||
const next = { ...body };
|
||||
if (tools.length > 0) {
|
||||
next.tools = tools;
|
||||
} else {
|
||||
delete next.tools;
|
||||
delete next.parallel_tool_calls;
|
||||
}
|
||||
if (tools.length === 0 || grokToolChoiceNamesRemovedTool(next.tool_choice, removedToolNames)) {
|
||||
delete next.tool_choice;
|
||||
}
|
||||
return { value: next, changed: true };
|
||||
}
|
||||
|
||||
function isGrokSupportedResponsesTool(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
return grokSupportedResponsesToolTypes.has(stringValue(value.type) ?? "");
|
||||
}
|
||||
|
||||
function responseToolName(value: unknown): string | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return stringValue(value.name) || stringValue(value.server_label);
|
||||
}
|
||||
|
||||
function grokToolChoiceNamesRemovedTool(value: unknown, removedToolNames: Set<string>): boolean {
|
||||
if (removedToolNames.size === 0) {
|
||||
return false;
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
const name = stringValue(value.name) || stringValue(value.server_label);
|
||||
return name ? removedToolNames.has(name) : false;
|
||||
}
|
||||
|
||||
function withBearerAuth(
|
||||
headers: HeaderRecord | undefined,
|
||||
token: string,
|
||||
removeHeaders: string[]
|
||||
): HeaderRecord {
|
||||
const next = { ...(headers ?? {}) };
|
||||
for (const name of removeHeaders) {
|
||||
deleteHeader(next, name);
|
||||
}
|
||||
setHeader(next, "authorization", `Bearer ${token}`);
|
||||
return next;
|
||||
}
|
||||
|
||||
function originalRemoveHeaders(plugin: Record<string, unknown>): string[] {
|
||||
const auth = isRecord(plugin.auth) ? plugin.auth : undefined;
|
||||
const removeHeaders = Array.isArray(auth?.removeHeaders) ? auth.removeHeaders : [];
|
||||
return uniqueStrings([
|
||||
...removeHeaders
|
||||
.map((value) => stringValue(value))
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
"x-api-key"
|
||||
]);
|
||||
}
|
||||
|
||||
function originalBearerToken(plugin: Record<string, unknown>): string | undefined {
|
||||
const authorization = originalAuthHeader(plugin, "authorization");
|
||||
const prefix = "Bearer ";
|
||||
return authorization?.startsWith(prefix) ? authorization.slice(prefix.length).trim() || undefined : undefined;
|
||||
}
|
||||
|
||||
function originalAuthHeader(plugin: Record<string, unknown>, name: string): string | undefined {
|
||||
const auth = isRecord(plugin.auth) ? plugin.auth : undefined;
|
||||
const headers = isRecord(auth?.headers) ? auth.headers : undefined;
|
||||
return recordHeader(headers, name);
|
||||
}
|
||||
|
||||
function originalRequestHeader(plugin: Record<string, unknown>, name: string): string | undefined {
|
||||
const request = isRecord(plugin.request) ? plugin.request : undefined;
|
||||
const headers = isRecord(request?.headers) ? request.headers : undefined;
|
||||
return recordHeader(headers, name);
|
||||
}
|
||||
|
||||
function originalAnthropicBetaDefault(plugin: Record<string, unknown>): string | undefined {
|
||||
const value = originalAuthHeaderValue(plugin, claudeCodeOauthBetaHeader);
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return stringValue(value.default);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function originalAuthHeaderValue(plugin: Record<string, unknown>, name: string): unknown {
|
||||
const auth = isRecord(plugin.auth) ? plugin.auth : undefined;
|
||||
const headers = isRecord(auth?.headers) ? auth.headers : undefined;
|
||||
if (!headers) {
|
||||
return undefined;
|
||||
}
|
||||
const normalizedName = name.toLowerCase();
|
||||
return Object.entries(headers).find(([key]) => key.trim().toLowerCase() === normalizedName)?.[1];
|
||||
}
|
||||
|
||||
function recordHeader(headers: Record<string, unknown> | undefined, name: string): string | undefined {
|
||||
const value = recordHeaderValue(headers, name);
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function recordHeaderValue(headers: Record<string, unknown> | undefined, name: string): unknown {
|
||||
if (!headers) {
|
||||
return undefined;
|
||||
}
|
||||
const normalizedName = name.toLowerCase();
|
||||
return Object.entries(headers).find(([key]) => key.trim().toLowerCase() === normalizedName)?.[1];
|
||||
}
|
||||
|
||||
function requestHeader(
|
||||
headers: Record<string, string | string[] | undefined> | undefined,
|
||||
name: string
|
||||
): string | undefined {
|
||||
if (!headers) {
|
||||
return undefined;
|
||||
}
|
||||
const normalizedName = name.toLowerCase();
|
||||
const value = Object.entries(headers).find(([key]) => key.trim().toLowerCase() === normalizedName)?.[1];
|
||||
if (Array.isArray(value)) {
|
||||
return value.join(",");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function setHeader(headers: HeaderRecord, name: string, value: string): void {
|
||||
deleteHeader(headers, name);
|
||||
headers[name] = value;
|
||||
}
|
||||
|
||||
function deleteHeader(headers: HeaderRecord, name: string): void {
|
||||
const normalizedName = name.toLowerCase();
|
||||
for (const key of Object.keys(headers)) {
|
||||
if (key.trim().toLowerCase() === normalizedName) {
|
||||
delete headers[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function kimiOauthReference(plugin: Record<string, unknown>): { key?: string; oauthHost?: string } | undefined {
|
||||
const oauth = isRecord(plugin.kimiOauth) ? plugin.kimiOauth : undefined;
|
||||
if (!oauth) {
|
||||
return undefined;
|
||||
}
|
||||
const key = stringValue(oauth.key);
|
||||
const oauthHost = stringValue(oauth.oauthHost) || stringValue(oauth.oauth_host);
|
||||
return key || oauthHost ? { key, oauthHost } : undefined;
|
||||
}
|
||||
|
||||
const grokSupportedResponsesToolTypes = new Set([
|
||||
"function",
|
||||
"x_search",
|
||||
"image_generation",
|
||||
"collections_search",
|
||||
"file_search",
|
||||
"code_execution",
|
||||
"code_interpreter",
|
||||
"mcp",
|
||||
"shell"
|
||||
]);
|
||||
|
||||
const grokUnsupportedResponsesOptions = new Set([
|
||||
"external_web_access"
|
||||
]);
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const unique: string[] = [];
|
||||
for (const value of values) {
|
||||
const key = value.trim().toLowerCase();
|
||||
if (!key || seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
unique.push(value);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { networkInterfaces } from "node:os";
|
||||
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { delimiter as pathDelimiter, join as pathJoin, resolve as pathResolve } from "node:path";
|
||||
import { CONFIGDIR } from "@ccr/core/config/constants";
|
||||
import {
|
||||
@@ -46,11 +46,11 @@ export function spawnGatewayProcess(
|
||||
coreAuthToken: string
|
||||
): SpawnedGatewayProcess {
|
||||
const gatewayEntry = resolveGatewayEntry();
|
||||
const proxyPreloadFile = upstreamProxyUrl ? writeGatewayProxyPreloadFile() : undefined;
|
||||
const fetchPreloadFile = writeGatewayFetchPreloadFile();
|
||||
const nodeRuntime = resolveGatewayNodeRuntime();
|
||||
const env = createGatewayProcessEnv(config, upstreamProxyUrl, runtimeId, coreAuthToken, nodeRuntime.electronRunAsNode);
|
||||
const gatewayBootstrapEntry = resolveGatewayBootstrapEntry();
|
||||
const args = proxyPreloadFile ? ["--require", proxyPreloadFile, gatewayBootstrapEntry] : [gatewayBootstrapEntry];
|
||||
const args = ["--require", fetchPreloadFile, gatewayBootstrapEntry];
|
||||
const child = spawn(nodeRuntime.command, args, {
|
||||
cwd: CONFIGDIR,
|
||||
env,
|
||||
@@ -184,6 +184,20 @@ export function resolveUpstreamHeaderSanitizerEntry(): string {
|
||||
].find((candidate) => existsSync(candidate)) ?? pathJoin(__dirname, "upstream-header-sanitizer.js");
|
||||
}
|
||||
|
||||
export function resolveLocalAgentAuthProviderHookEntry(): string {
|
||||
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
|
||||
return [
|
||||
pathJoin(__dirname, "local-agent-auth-provider-hook.js"),
|
||||
pathJoin(process.cwd(), ".test-dist", "core", "runtime", "local-agent-auth-provider-hook.js"),
|
||||
...(resourcesPath
|
||||
? [
|
||||
pathJoin(resourcesPath, "app.asar", "dist", "main", "local-agent-auth-provider-hook.js"),
|
||||
pathJoin(resourcesPath, "app", "dist", "main", "local-agent-auth-provider-hook.js")
|
||||
]
|
||||
: [])
|
||||
].find((candidate) => existsSync(candidate)) ?? pathJoin(__dirname, "local-agent-auth-provider-hook.js");
|
||||
}
|
||||
|
||||
function resolveGatewayEntry(): string {
|
||||
const override = process.env[gatewayEntryOverrideEnv]?.trim();
|
||||
if (override) {
|
||||
@@ -264,6 +278,8 @@ function createGatewayProcessEnv(
|
||||
AUTH_STATIC_API_KEY_ENV: coreGatewayAuthTokenEnv,
|
||||
AUTH_STATIC_API_KEY_HEADER: coreGatewayAuthHeader,
|
||||
CCR_GATEWAY_RUNTIME_ID: runtimeId,
|
||||
CCR_UNDICI_MODULE: resolveUndiciProxyAgentModule(),
|
||||
CCR_UPSTREAM_TIMEOUT_MS: String(gatewayUpstreamTimeoutMs(config)),
|
||||
[coreGatewayAuthTokenEnv]: coreAuthToken,
|
||||
HOST: config.gateway.coreHost,
|
||||
PORT: String(config.gateway.corePort)
|
||||
@@ -302,10 +318,14 @@ function createGatewayProcessEnv(
|
||||
env.https_proxy = upstreamProxyUrl;
|
||||
env.all_proxy = upstreamProxyUrl;
|
||||
env.CCR_UPSTREAM_PROXY_URL = upstreamProxyUrl;
|
||||
env.CCR_UNDICI_MODULE = resolveUndiciProxyAgentModule();
|
||||
return env;
|
||||
}
|
||||
|
||||
function gatewayUpstreamTimeoutMs(config: AppConfig): number {
|
||||
const value = Number(config.API_TIMEOUT_MS);
|
||||
return Number.isFinite(value) && value >= 0 ? Math.trunc(value) : 0;
|
||||
}
|
||||
|
||||
function resolveGatewayNodeRuntime(): GatewayNodeRuntime {
|
||||
const candidates = uniqueGatewayNodeRuntimeCandidates([
|
||||
...configuredGatewayNodeRuntimeCandidates(),
|
||||
@@ -419,58 +439,77 @@ function appendGatewayChildOutput(child: ChildProcess, message: string): string
|
||||
return details ? `${message}\n${details}` : message;
|
||||
}
|
||||
|
||||
export function writeGatewayProxyPreloadFile(): string {
|
||||
export function writeGatewayFetchPreloadFile(): string {
|
||||
const file = pathJoin(CONFIGDIR, "gateway-proxy-preload.cjs");
|
||||
writeFileSync(
|
||||
file,
|
||||
[
|
||||
"\"use strict\";",
|
||||
"const up = process.env.CCR_UPSTREAM_PROXY_URL;",
|
||||
"const um = process.env.CCR_UNDICI_MODULE;",
|
||||
"if (up && um) {",
|
||||
" const { ProxyAgent } = require(um);",
|
||||
" const agent = new ProxyAgent(up);",
|
||||
" const realFetch = globalThis.fetch.bind(globalThis);",
|
||||
" const raw = (process.env.NO_PROXY || process.env.no_proxy || '').toLowerCase();",
|
||||
" const byp = raw.split(',').map((s) => s.trim()).filter(Boolean);",
|
||||
" const norm = (h) => h.replace(/^\\[/, '').replace(/\\]$/, '').replace(/\\.$/, '');",
|
||||
" const isLP = (h) => h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '0:0:0:0:0:0:0:1' || h === '0.0.0.0' || h.startsWith('127.');",
|
||||
" const shouldBypass = (input) => {",
|
||||
" let h;",
|
||||
" try {",
|
||||
" const u = typeof input === 'string' ? new URL(input) : input instanceof URL ? input : new URL(input && input.url ? input.url : String(input));",
|
||||
" h = norm(u.hostname);",
|
||||
" } catch { return true; }",
|
||||
" if (!h) return false;",
|
||||
" if (isLP(h)) return true;",
|
||||
" return byp.some((p) => {",
|
||||
" if (p === '*') return true;",
|
||||
" const s = p.split(':');",
|
||||
" const ph = norm(s[0]);",
|
||||
" if (s.length === 2 && s[1]) {",
|
||||
" if (h !== ph) return false;",
|
||||
" try { return new URL(input).port === s[1]; } catch { return false; }",
|
||||
" }",
|
||||
" if (ph.startsWith('*.')) return h.endsWith(ph.slice(1));",
|
||||
" if (ph.startsWith('.')) return h.endsWith(ph) || h === ph.slice(1);",
|
||||
" return h === ph;",
|
||||
" });",
|
||||
" };",
|
||||
" const patched = function(input, init) {",
|
||||
" if (init && init.dispatcher) return realFetch(input, init);",
|
||||
" if (shouldBypass(input)) return realFetch(input, init);",
|
||||
" return realFetch(input, Object.assign({}, init, { dispatcher: agent }));",
|
||||
" };",
|
||||
" if (Object.getOwnPropertyDescriptor(globalThis, 'fetch')?.writable) {",
|
||||
" globalThis.fetch = patched;",
|
||||
" }",
|
||||
"}"
|
||||
].join("\n"),
|
||||
"utf8"
|
||||
);
|
||||
mkdirSync(CONFIGDIR, { recursive: true });
|
||||
writeFileSync(file, gatewayFetchPreloadScript(), "utf8");
|
||||
return file;
|
||||
}
|
||||
|
||||
export function writeGatewayProxyPreloadFile(): string {
|
||||
return writeGatewayFetchPreloadFile();
|
||||
}
|
||||
|
||||
function gatewayFetchPreloadScript(): string {
|
||||
return [
|
||||
"\"use strict\";",
|
||||
"const up = process.env.CCR_UPSTREAM_PROXY_URL;",
|
||||
"const um = process.env.CCR_UNDICI_MODULE;",
|
||||
"const rawTimeout = process.env.CCR_UPSTREAM_TIMEOUT_MS;",
|
||||
"const parsedTimeout = rawTimeout === undefined || rawTimeout === '' ? NaN : Number(rawTimeout);",
|
||||
"const hasTimeout = Number.isFinite(parsedTimeout) && parsedTimeout >= 0;",
|
||||
"if ((up || hasTimeout) && um) {",
|
||||
" const { Agent, ProxyAgent } = require(um);",
|
||||
" const timeoutOptions = hasTimeout ? { headersTimeout: Math.trunc(parsedTimeout), bodyTimeout: Math.trunc(parsedTimeout) } : {};",
|
||||
" const directAgent = hasTimeout ? new Agent(timeoutOptions) : undefined;",
|
||||
" const proxyAgent = up ? new ProxyAgent(Object.assign({ uri: up }, timeoutOptions)) : undefined;",
|
||||
" const realFetch = globalThis.fetch.bind(globalThis);",
|
||||
" const raw = (process.env.NO_PROXY || process.env.no_proxy || '').toLowerCase();",
|
||||
" const byp = raw.split(',').map((s) => s.trim()).filter(Boolean);",
|
||||
" const norm = (h) => h.replace(/^\\[/, '').replace(/\\]$/, '').replace(/\\.$/, '');",
|
||||
" const isLP = (h) => h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '0:0:0:0:0:0:0:1' || h === '0.0.0.0' || h.startsWith('127.');",
|
||||
" const requestUrl = (input) => {",
|
||||
" try {",
|
||||
" return typeof input === 'string' ? new URL(input) : input instanceof URL ? input : new URL(input && input.url ? input.url : String(input));",
|
||||
" } catch { return undefined; }",
|
||||
" };",
|
||||
" const shouldBypass = (input) => {",
|
||||
" const u = requestUrl(input);",
|
||||
" if (!u) return true;",
|
||||
" const h = norm(u.hostname);",
|
||||
" if (!h) return false;",
|
||||
" if (isLP(h)) return true;",
|
||||
" return byp.some((p) => {",
|
||||
" if (p === '*') return true;",
|
||||
" const s = p.split(':');",
|
||||
" const ph = norm(s[0]);",
|
||||
" if (s.length === 2 && s[1]) return h === ph && u.port === s[1];",
|
||||
" if (ph.startsWith('*.')) return h.endsWith(ph.slice(1));",
|
||||
" if (ph.startsWith('.')) return h.endsWith(ph) || h === ph.slice(1);",
|
||||
" return h === ph;",
|
||||
" });",
|
||||
" };",
|
||||
" const dispatcherFor = (input) => {",
|
||||
" if (!proxyAgent) return directAgent;",
|
||||
" return shouldBypass(input) ? directAgent : proxyAgent;",
|
||||
" };",
|
||||
" const patched = function(input, init) {",
|
||||
" if (init && init.dispatcher) return realFetch(input, init);",
|
||||
" const dispatcher = dispatcherFor(input);",
|
||||
" if (!dispatcher) return realFetch(input, init);",
|
||||
" return realFetch(input, Object.assign({}, init, { dispatcher }));",
|
||||
" };",
|
||||
" if (Object.getOwnPropertyDescriptor(globalThis, 'fetch')?.writable) {",
|
||||
" globalThis.fetch = patched;",
|
||||
" }",
|
||||
"}"
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function gatewayFetchPreloadScriptForTest(): string {
|
||||
return gatewayFetchPreloadScript();
|
||||
}
|
||||
|
||||
function mergeNoProxy(current: string | undefined, values: string[]): string {
|
||||
const merged = new Set<string>();
|
||||
for (const value of [...(current || "").split(","), ...values]) {
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
import type { IncomingHttpHeaders } from "node:http";
|
||||
import { Readable, Transform } from "node:stream";
|
||||
import type { AppConfig } from "@ccr/core/contracts/app";
|
||||
import { normalizeRouteSelector } from "@ccr/core/routing/model-registry";
|
||||
import { isRecord, rawStringValue, stringValue } from "@ccr/core/gateway/internal/value";
|
||||
import { readHeader } from "@ccr/core/gateway/http/io";
|
||||
import { parseJsonObjectSafe, serializeJsonBody } from "@ccr/core/gateway/http/body";
|
||||
import { requestProtocolForPath } from "@ccr/core/routing/protocol-endpoints";
|
||||
import { resolveUsageModelAttribution } from "@ccr/core/usage/model-attribution";
|
||||
|
||||
const multiAgentNamespaceName = "multi_agent_v1";
|
||||
const multiAgentFunctionPrefix = `${multiAgentNamespaceName}_`;
|
||||
const multiAgentToolNames = new Set(["close_agent", "resume_agent", "send_input", "spawn_agent", "wait_agent"]);
|
||||
|
||||
export function prepareCodexMultiAgentBridgeRequest(input: {
|
||||
body?: Buffer;
|
||||
config: AppConfig;
|
||||
headers: IncomingHttpHeaders;
|
||||
method: string;
|
||||
path: string;
|
||||
routedModel?: string;
|
||||
}): { body: Buffer; diagnostic: string } | undefined {
|
||||
if (!codexMultiAgentBridgeEnabled(input.headers, input.method, input.path)) {
|
||||
return undefined;
|
||||
}
|
||||
const parsedBody = parseJsonObjectSafe(input.body);
|
||||
if (!parsedBody) {
|
||||
return undefined;
|
||||
}
|
||||
const model = input.routedModel || stringValue(parsedBody.model);
|
||||
if (!codexMultiAgentBridgeModelEligible(model, input.config)) {
|
||||
return undefined;
|
||||
}
|
||||
const transformed = transformCodexMultiAgentBridgeRequestBody(parsedBody);
|
||||
if (!transformed.changed) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
body: serializeJsonBody(transformed.body),
|
||||
diagnostic: `${model ?? "unknown"}:${transformed.changedParts.join(",")}`
|
||||
};
|
||||
}
|
||||
|
||||
export function transformCodexMultiAgentBridgeRequestBody(body: Record<string, unknown>): {
|
||||
body: Record<string, unknown>;
|
||||
changed: boolean;
|
||||
changedParts: string[];
|
||||
} {
|
||||
const next = { ...body };
|
||||
const changedParts: string[] = [];
|
||||
const tools = transformCodexMultiAgentBridgeTools(body.tools);
|
||||
if (tools.changed) {
|
||||
next.tools = tools.value;
|
||||
changedParts.push("tools");
|
||||
const toolChoice = transformCodexMultiAgentBridgeToolChoice(body.tool_choice);
|
||||
if (toolChoice.changed) {
|
||||
if (toolChoice.value === undefined) {
|
||||
delete next.tool_choice;
|
||||
} else {
|
||||
next.tool_choice = toolChoice.value;
|
||||
}
|
||||
changedParts.push("tool_choice");
|
||||
}
|
||||
const input = transformCodexMultiAgentBridgeInput(body.input);
|
||||
if (input.changed) {
|
||||
next.input = input.value;
|
||||
changedParts.push("input");
|
||||
}
|
||||
}
|
||||
return {
|
||||
body: next,
|
||||
changed: changedParts.length > 0,
|
||||
changedParts
|
||||
};
|
||||
}
|
||||
|
||||
function transformCodexMultiAgentBridgeTools(value: unknown): { value: unknown; changed: boolean } {
|
||||
if (!Array.isArray(value)) {
|
||||
return { value, changed: false };
|
||||
}
|
||||
let changed = false;
|
||||
const tools = value.flatMap((tool) => {
|
||||
if (!isRecord(tool) || tool.type !== "namespace" || tool.name !== multiAgentNamespaceName) {
|
||||
return [tool];
|
||||
}
|
||||
const namespaceTools = Array.isArray(tool.tools) ? tool.tools : [];
|
||||
const flattened = namespaceTools
|
||||
.filter((item) => isRecord(item) && item.type === "function" && multiAgentToolNames.has(stringValue(item.name) ?? ""))
|
||||
.map((item) => codexMultiAgentFunctionTool(item as Record<string, unknown>));
|
||||
if (flattened.length === 0) {
|
||||
return [tool];
|
||||
}
|
||||
changed = true;
|
||||
return flattened;
|
||||
});
|
||||
return { value: tools, changed };
|
||||
}
|
||||
|
||||
function codexMultiAgentFunctionTool(tool: Record<string, unknown>): Record<string, unknown> {
|
||||
const name = stringValue(tool.name) ?? "";
|
||||
const description = rawStringValue(tool.description) ?? "";
|
||||
return {
|
||||
...tool,
|
||||
name: codexMultiAgentFunctionName(name),
|
||||
description: description
|
||||
? `Namespaced ${multiAgentNamespaceName}.${name} tool.\n\n${description}`
|
||||
: `Namespaced ${multiAgentNamespaceName}.${name} tool.`
|
||||
};
|
||||
}
|
||||
|
||||
function transformCodexMultiAgentBridgeToolChoice(value: unknown): { value: unknown; changed: boolean } {
|
||||
const name = toolChoiceName(value);
|
||||
if (!name) {
|
||||
return { value, changed: false };
|
||||
}
|
||||
if (normalizeMultiAgentToolName(name) === undefined && name !== multiAgentNamespaceName) {
|
||||
return { value, changed: false };
|
||||
}
|
||||
if (name === multiAgentNamespaceName) {
|
||||
return { value: undefined, changed: true };
|
||||
}
|
||||
const mappedName = normalizeMultiAgentToolName(name);
|
||||
if (!mappedName || mappedName === name) {
|
||||
return { value, changed: false };
|
||||
}
|
||||
if (isRecord(value) && isRecord(value.function)) {
|
||||
return {
|
||||
value: {
|
||||
...value,
|
||||
function: {
|
||||
...value.function,
|
||||
name: mappedName
|
||||
}
|
||||
},
|
||||
changed: true
|
||||
};
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return {
|
||||
value: {
|
||||
...value,
|
||||
name: mappedName,
|
||||
type: value.type === "tool" ? "function" : value.type
|
||||
},
|
||||
changed: true
|
||||
};
|
||||
}
|
||||
return { value, changed: false };
|
||||
}
|
||||
|
||||
function toolChoiceName(value: unknown): string | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return stringValue(value.name) ?? (isRecord(value.function) ? stringValue(value.function.name) : undefined);
|
||||
}
|
||||
|
||||
function transformCodexMultiAgentBridgeInput(value: unknown): { value: unknown; changed: boolean } {
|
||||
if (!Array.isArray(value)) {
|
||||
return { value, changed: false };
|
||||
}
|
||||
let changed = false;
|
||||
const items = value.map((item) => {
|
||||
const transformed = transformCodexMultiAgentBridgeRequestItem(item);
|
||||
changed ||= transformed.changed;
|
||||
return transformed.value;
|
||||
});
|
||||
return { value: items, changed };
|
||||
}
|
||||
|
||||
function transformCodexMultiAgentBridgeRequestItem(value: unknown): { value: unknown; changed: boolean } {
|
||||
if (!isRecord(value) || value.type !== "function_call") {
|
||||
return { value, changed: false };
|
||||
}
|
||||
const mappedName = normalizeMultiAgentToolName(stringValue(value.name) ?? "");
|
||||
if (!mappedName || mappedName === value.name) {
|
||||
return { value, changed: false };
|
||||
}
|
||||
const { namespace: _namespace, ...rest } = value;
|
||||
return {
|
||||
value: {
|
||||
...rest,
|
||||
name: mappedName
|
||||
},
|
||||
changed: true
|
||||
};
|
||||
}
|
||||
|
||||
function codexMultiAgentBridgeEnabled(headers: IncomingHttpHeaders, method: string, path: string): boolean {
|
||||
return (method || "GET").toUpperCase() === "POST" &&
|
||||
requestProtocolForPath(path) === "openai_responses" &&
|
||||
isCodexUserAgent(headers);
|
||||
}
|
||||
|
||||
function isCodexUserAgent(headers: IncomingHttpHeaders): boolean {
|
||||
return readHeader(headers["user-agent"])?.toLowerCase().includes("codex") ?? false;
|
||||
}
|
||||
|
||||
function codexMultiAgentBridgeModelEligible(model: string | undefined, config: AppConfig): boolean {
|
||||
const modelName = modelNameForMultiAgentBridge(model);
|
||||
if (!modelName || modelName.toLowerCase().includes("gpt")) {
|
||||
return false;
|
||||
}
|
||||
const baseModelName = modelNameForMultiAgentBridge(resolveUsageModelAttribution(config, model).model);
|
||||
return !baseModelName.toLowerCase().includes("gpt");
|
||||
}
|
||||
|
||||
function modelNameForMultiAgentBridge(model: string | undefined): string {
|
||||
const normalized = normalizeRouteSelector(model) ?? "";
|
||||
const slashIndex = normalized.lastIndexOf("/");
|
||||
return slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized;
|
||||
}
|
||||
|
||||
function codexMultiAgentFunctionName(name: string): string {
|
||||
return `${multiAgentFunctionPrefix}${name}`;
|
||||
}
|
||||
|
||||
function normalizeMultiAgentToolName(name: string): string | undefined {
|
||||
const normalized = name.trim();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (normalized.startsWith(multiAgentFunctionPrefix)) {
|
||||
const inner = normalized.slice(multiAgentFunctionPrefix.length);
|
||||
return multiAgentToolNames.has(inner) ? codexMultiAgentFunctionName(inner) : undefined;
|
||||
}
|
||||
const dottedPrefix = `${multiAgentNamespaceName}.`;
|
||||
if (normalized.startsWith(dottedPrefix)) {
|
||||
const inner = normalized.slice(dottedPrefix.length);
|
||||
return multiAgentToolNames.has(inner) ? codexMultiAgentFunctionName(inner) : undefined;
|
||||
}
|
||||
return multiAgentToolNames.has(normalized) ? codexMultiAgentFunctionName(normalized) : undefined;
|
||||
}
|
||||
|
||||
function nativeMultiAgentToolName(name: string): string | undefined {
|
||||
const normalized = name.trim();
|
||||
if (!normalized.startsWith(multiAgentFunctionPrefix)) {
|
||||
return undefined;
|
||||
}
|
||||
const inner = normalized.slice(multiAgentFunctionPrefix.length);
|
||||
return multiAgentToolNames.has(inner) ? inner : undefined;
|
||||
}
|
||||
|
||||
export function codexMultiAgentBridgeResponseStream(input: Readable, headers: Headers): Readable {
|
||||
const contentType = headers.get("content-type")?.toLowerCase() ?? "";
|
||||
if (contentType.includes("text/event-stream")) {
|
||||
return input.pipe(new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
transformSseChunk(this, chunk);
|
||||
callback();
|
||||
},
|
||||
flush(callback) {
|
||||
flushSseTransform(this);
|
||||
callback();
|
||||
}
|
||||
}));
|
||||
}
|
||||
if (contentType.includes("application/json")) {
|
||||
const chunks: Buffer[] = [];
|
||||
return input.pipe(new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
callback();
|
||||
},
|
||||
flush(callback) {
|
||||
const raw = Buffer.concat(chunks).toString("utf8");
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const transformed = transformCodexMultiAgentBridgeResponseValue(parsed);
|
||||
this.push(Buffer.from(`${JSON.stringify(transformed.value)}\n`, "utf8"));
|
||||
} catch {
|
||||
this.push(Buffer.from(raw, "utf8"));
|
||||
}
|
||||
callback();
|
||||
}
|
||||
}));
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
export function transformCodexMultiAgentBridgeResponseValue(value: unknown): { value: unknown; changed: boolean } {
|
||||
if (!isRecord(value)) {
|
||||
return { value, changed: false };
|
||||
}
|
||||
let changed = false;
|
||||
const next = { ...value };
|
||||
if (isRecord(value.item)) {
|
||||
const item = transformMultiAgentFunctionCall(value.item);
|
||||
if (item.changed) {
|
||||
next.item = item.value;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(value.output)) {
|
||||
const output = transformCodexMultiAgentBridgeResponseItems(value.output);
|
||||
if (output.changed) {
|
||||
next.output = output.value;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (isRecord(value.response) && Array.isArray(value.response.output)) {
|
||||
const output = transformCodexMultiAgentBridgeResponseItems(value.response.output);
|
||||
if (output.changed) {
|
||||
next.response = {
|
||||
...value.response,
|
||||
output: output.value
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
const item = transformMultiAgentFunctionCall(next);
|
||||
if (item.changed) {
|
||||
return item;
|
||||
}
|
||||
return { value: next, changed };
|
||||
}
|
||||
|
||||
function transformCodexMultiAgentBridgeResponseItems(items: unknown[]): { value: unknown[]; changed: boolean } {
|
||||
let changed = false;
|
||||
const value = items.map((item) => {
|
||||
const transformed = isRecord(item)
|
||||
? transformMultiAgentFunctionCall(item)
|
||||
: { value: item, changed: false };
|
||||
changed ||= transformed.changed;
|
||||
return transformed.value;
|
||||
});
|
||||
return { value, changed };
|
||||
}
|
||||
|
||||
function transformMultiAgentFunctionCall(item: Record<string, unknown>): { value: unknown; changed: boolean } {
|
||||
if (item.type !== "function_call") {
|
||||
return { value: item, changed: false };
|
||||
}
|
||||
const nativeName = nativeMultiAgentToolName(stringValue(item.name) ?? "");
|
||||
if (!nativeName) {
|
||||
return { value: item, changed: false };
|
||||
}
|
||||
return {
|
||||
value: {
|
||||
...item,
|
||||
name: nativeName,
|
||||
namespace: multiAgentNamespaceName
|
||||
},
|
||||
changed: true
|
||||
};
|
||||
}
|
||||
|
||||
function transformSseChunk(stream: Transform, chunk: Buffer | string): void {
|
||||
const state = stream as Transform & { __ccrCodexMultiAgentBridgeSsePending?: string };
|
||||
state.__ccrCodexMultiAgentBridgeSsePending = (state.__ccrCodexMultiAgentBridgeSsePending ?? "") + chunk.toString();
|
||||
while (state.__ccrCodexMultiAgentBridgeSsePending) {
|
||||
const match = /\r?\n\r?\n/.exec(state.__ccrCodexMultiAgentBridgeSsePending);
|
||||
if (!match || match.index === undefined) {
|
||||
break;
|
||||
}
|
||||
const block = state.__ccrCodexMultiAgentBridgeSsePending.slice(0, match.index);
|
||||
const delimiter = match[0];
|
||||
state.__ccrCodexMultiAgentBridgeSsePending = state.__ccrCodexMultiAgentBridgeSsePending.slice(match.index + delimiter.length);
|
||||
stream.push(transformCodexMultiAgentBridgeSseEvent(block) + delimiter);
|
||||
}
|
||||
}
|
||||
|
||||
function flushSseTransform(stream: Transform): void {
|
||||
const state = stream as Transform & { __ccrCodexMultiAgentBridgeSsePending?: string };
|
||||
if (state.__ccrCodexMultiAgentBridgeSsePending) {
|
||||
stream.push(transformCodexMultiAgentBridgeSseEvent(state.__ccrCodexMultiAgentBridgeSsePending));
|
||||
state.__ccrCodexMultiAgentBridgeSsePending = "";
|
||||
}
|
||||
}
|
||||
|
||||
export function transformCodexMultiAgentBridgeSseEvent(block: string): string {
|
||||
const lines = block.split(/\r?\n/);
|
||||
const dataIndex = lines.findIndex((line) => line.startsWith("data: "));
|
||||
if (dataIndex < 0) {
|
||||
return block;
|
||||
}
|
||||
const raw = lines[dataIndex].slice("data: ".length);
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const transformed = transformCodexMultiAgentBridgeResponseValue(parsed);
|
||||
if (!transformed.changed) {
|
||||
return block;
|
||||
}
|
||||
lines[dataIndex] = `data: ${JSON.stringify(transformed.value)}`;
|
||||
return lines.join("\n");
|
||||
} catch {
|
||||
return block;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { AppConfig } from "@ccr/core/contracts/app";
|
||||
import { isRecord, rawStringValue, stringValue } from "@ccr/core/gateway/internal/value";
|
||||
import type { AnthropicWebSearchProtocolContext, BrowserWebSearchProtocolRecord, ClaudeCodeWebSearchContinuationContext, HostedWebSearchProtocolContext } from "@ccr/core/gateway/internal/shared";
|
||||
import { parseJsonObjectSafe, serializeJsonBody } from "@ccr/core/gateway/http/body";
|
||||
import { resolveGatewayPublicModelId } from "@ccr/core/gateway/features/model-discovery";
|
||||
import { uniqueStrings } from "@ccr/core/gateway/internal/collections";
|
||||
import { requestProtocolForPath } from "@ccr/core/routing/protocol-endpoints";
|
||||
import { claudeCodeWebSearchToolResultTexts, extractAnthropicWebSearchQueryHint, extractClaudeCodeWebSearchToolResultQuery, extractHostedWebSearchQueryHint, fusionWebSearchToolNameForRequest, hasHostedWebSearchDeclaration, isAnthropicHostedWebSearchTool, isOpenAiHostedWebSearchTool, openAiToolChoiceNamesWebSearch, readHostedWebSearchMaxUses } from "@ccr/core/gateway/features/hosted-web-search/discovery";
|
||||
@@ -24,7 +25,10 @@ export function createHostedWebSearchProtocolContext(input: {
|
||||
if (!body || !hasHostedWebSearchDeclaration(body, protocol)) {
|
||||
return undefined;
|
||||
}
|
||||
const toolName = fusionWebSearchToolNameForRequest(input.config, stringValue(body.model) || input.routedModel);
|
||||
const toolName = fusionWebSearchToolNameForRequest(
|
||||
input.config,
|
||||
hostedWebSearchModelForRequest(input.config, stringValue(body.model) || input.routedModel)
|
||||
);
|
||||
if (!toolName) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -53,7 +57,10 @@ export function createClaudeCodeWebSearchContinuationContext(input: {
|
||||
if (!body || claudeCodeWebSearchToolResultTexts(body).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const toolName = fusionWebSearchToolNameForRequest(input.config, stringValue(body.model) || input.routedModel);
|
||||
const toolName = fusionWebSearchToolNameForRequest(
|
||||
input.config,
|
||||
hostedWebSearchModelForRequest(input.config, stringValue(body.model) || input.routedModel)
|
||||
);
|
||||
if (!toolName) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -64,6 +71,11 @@ export function createClaudeCodeWebSearchContinuationContext(input: {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function hostedWebSearchModelForRequest(config: AppConfig, model: string | undefined): string | undefined {
|
||||
return resolveGatewayPublicModelId(model, config) ?? model;
|
||||
}
|
||||
|
||||
export function prepareHostedWebSearchProtocolRequestBody(
|
||||
body: Buffer | undefined,
|
||||
records: BrowserWebSearchProtocolRecord[],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -265,16 +265,50 @@ export async function fetchUpstreamWithFallback(input: {
|
||||
upstreamUrl: string;
|
||||
}): Promise<UpstreamFetchResult> {
|
||||
const fallbackMode = input.fallback.mode;
|
||||
const planningHeaders = { ...input.headers };
|
||||
const planningRouting = applyProviderCapabilityRouting({
|
||||
body: input.body,
|
||||
config: input.config,
|
||||
fallback: input.fallback,
|
||||
headers: planningHeaders,
|
||||
path: input.path,
|
||||
routedModel: input.routedModel
|
||||
});
|
||||
const attempts = buildUpstreamAttempts(
|
||||
input.config,
|
||||
input.fallback,
|
||||
planningRouting.fallback,
|
||||
input.method,
|
||||
input.path,
|
||||
input.body,
|
||||
input.routedModel
|
||||
planningRouting.body,
|
||||
planningRouting.routedModel
|
||||
);
|
||||
const failedAttempts: UpstreamFailedAttempt[] = [];
|
||||
const attemptRoutingCache = new Map<string | undefined, {
|
||||
body?: Buffer;
|
||||
headers: Record<string, string>;
|
||||
routedModel?: string;
|
||||
sourceBody?: Buffer;
|
||||
sourceRoutedModel?: string;
|
||||
}>();
|
||||
const primaryAttempt = attempts[0];
|
||||
const parsedInputBody = parseJsonObjectSafe(input.body);
|
||||
const planningBodyCanSeedPrimary = requestProtocolForPath(input.path) === "gemini_generate_content" ||
|
||||
!parsedInputBody ||
|
||||
!primaryAttempt?.model ||
|
||||
stringValue(parsedInputBody.model) !== undefined;
|
||||
if (primaryAttempt && planningBodyCanSeedPrimary) {
|
||||
attemptRoutingCache.set(primaryAttempt.model, {
|
||||
body: planningRouting.body,
|
||||
headers: planningHeaders,
|
||||
routedModel: primaryAttempt.model,
|
||||
sourceBody: input.body,
|
||||
sourceRoutedModel: input.routedModel
|
||||
});
|
||||
}
|
||||
input.trace?.capture({
|
||||
changes: [
|
||||
routeTraceChange("routing", "/routing/fallback", input.fallback, planningRouting.fallback)
|
||||
].filter(isRouteTraceChange),
|
||||
decision: { reason: `fallback:${fallbackMode}`, source: "execution-plan" },
|
||||
kind: "decision",
|
||||
name: "fallback.execution-plan",
|
||||
@@ -290,17 +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<void> {
|
||||
try {
|
||||
await response.arrayBuffer();
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { ProxyAgent } from "undici";
|
||||
export { Agent, ProxyAgent } from "undici";
|
||||
|
||||
@@ -26,6 +26,28 @@ test("generated Codex CLI middleware converts Windows SDK paths before URL schem
|
||||
assert.equal(fn("@the-next-ai/bot-gateway-sdk"), "@the-next-ai/bot-gateway-sdk");
|
||||
});
|
||||
|
||||
test("generated Codex CLI middleware materializes bundled Bot Gateway stdio runner", () => {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-bot-runner-"));
|
||||
const source = path.join(dir, "resources", "app.asar", "dist", "main", "bot-gateway-sdk", "bin", "bot-gateway-stdio.mjs");
|
||||
const configDir = path.join(dir, "config");
|
||||
mkdirSync(path.dirname(source), { recursive: true });
|
||||
writeFileSync(source, "#!/usr/bin/env node\nconsole.log('ok');\n");
|
||||
|
||||
const resolveCommand = evaluateRuntimeFunction(
|
||||
"resolveBundledBotGatewayCommand",
|
||||
["normalizeDuplicateShebangs", "materializeBotGatewayStdioRunnerPath"],
|
||||
configDir
|
||||
);
|
||||
const command = resolveCommand({ bundledStdioPath: () => source });
|
||||
const expectedRunner = path.join(configDir, "bot-gateway", "runners", "bot-gateway-stdio.mjs");
|
||||
|
||||
assert.equal(command.command, process.execPath);
|
||||
assert.deepEqual(command.args, [expectedRunner]);
|
||||
assert.equal(command.cwd, path.dirname(expectedRunner));
|
||||
assert.notEqual(command.cwd, path.dirname(source));
|
||||
assert.equal(readFileSync(expectedRunner, "utf8"), "#!/usr/bin/env node\nconsole.log('ok');\n");
|
||||
});
|
||||
|
||||
test("Codex app-server uses ChatGPT's bundled Node as a signed supervisor", { skip: process.platform !== "darwin" }, () => {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-signed-supervisor-"));
|
||||
const runtimeFile = writeRuntimeScript(dir);
|
||||
@@ -1055,9 +1077,19 @@ function writeRuntimeScript(dir) {
|
||||
return file;
|
||||
}
|
||||
|
||||
function evaluateRuntimeFunction(name) {
|
||||
const source = extractRuntimeFunctionSource(codexCliMiddlewareRuntimeScript(), name);
|
||||
return Function("path", "pathToFileURL", `${source}; return ${name};`)(path, pathToFileURL);
|
||||
function evaluateRuntimeFunction(name, dependencies = [], configDir = "") {
|
||||
const runtime = codexCliMiddlewareRuntimeScript();
|
||||
const source = [
|
||||
...dependencies.map((dependency) => extractRuntimeFunctionSource(runtime, dependency)),
|
||||
extractRuntimeFunctionSource(runtime, name)
|
||||
].join("\n");
|
||||
const fsRuntime = { existsSync, mkdirSync, readFileSync, writeFileSync };
|
||||
return Function("path", "pathToFileURL", "fs", "CONFIG_DIR", `${source}; return ${name};`)(
|
||||
path,
|
||||
pathToFileURL,
|
||||
fsRuntime,
|
||||
configDir
|
||||
);
|
||||
}
|
||||
|
||||
function extractRuntimeFunctionSource(source, name) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { PassThrough, Readable } from "node:stream";
|
||||
import test from "node:test";
|
||||
import { createHostedWebSearchProtocolContext } from "@ccr/core/gateway/features/hosted-web-search/index.ts";
|
||||
import {
|
||||
fusionFallbackToolDefinitions,
|
||||
fusionWebSearchToolNameForRequest,
|
||||
@@ -504,6 +505,51 @@ test("gateway resolves normalized Fusion web search tool names for Anthropic pro
|
||||
assert.equal(fusionWebSearchToolNameForRequest(config, "Fusion/kimisearch"), "fusion_2_web_search");
|
||||
});
|
||||
|
||||
test("gateway resolves Claude discovery aliases before hosted web search matching", () => {
|
||||
const config = {
|
||||
Providers: [],
|
||||
Router: { fallback: { mode: "off", models: [], retryCount: 0 } },
|
||||
gateway: {},
|
||||
virtualModelProfiles: [
|
||||
{
|
||||
displayName: "Kimisearch",
|
||||
enabled: true,
|
||||
execution: {
|
||||
clientToolsPolicy: "allow",
|
||||
matchWebSearch: true,
|
||||
maxToolCalls: 8,
|
||||
maxTurns: 6,
|
||||
mode: "tool_loop",
|
||||
streamMode: "optimistic"
|
||||
},
|
||||
id: "fusion-2",
|
||||
key: "kimisearch",
|
||||
match: { exactAliases: ["kimisearch", "Fusion/kimisearch"], prefixes: [], suffixes: [] },
|
||||
materialization: { enabled: true, includeInGatewayModels: true },
|
||||
metadata: {
|
||||
fusionWebSearch: { provider: "browser", toolName: "web_search_fusion_2" }
|
||||
},
|
||||
tools: [{ name: "web_search_fusion_2", visibility: "internal" }]
|
||||
}
|
||||
]
|
||||
};
|
||||
const context = createHostedWebSearchProtocolContext({
|
||||
body: Buffer.from(JSON.stringify({
|
||||
messages: [{ content: "weather", role: "user" }],
|
||||
model: "claude-Fusion/kimisearch",
|
||||
tools: [{ name: "web_search", type: "web_search_20250305" }]
|
||||
})),
|
||||
config,
|
||||
method: "POST",
|
||||
path: "/v1/messages",
|
||||
requestId: "request-1",
|
||||
routedModel: "claude-Fusion/kimisearch",
|
||||
sinceMs: 0
|
||||
});
|
||||
|
||||
assert.equal(context?.toolName, "fusion_2_web_search");
|
||||
});
|
||||
|
||||
test("gateway does not route hosted web search through an unrelated Fusion search profile", () => {
|
||||
const config = {
|
||||
Providers: [
|
||||
|
||||
@@ -756,7 +756,14 @@ test("profile service injects ToolHub MCP into Codex config", { skip: !process.e
|
||||
separateProfileFile,
|
||||
initialSeparateProfile
|
||||
.replace('model = "Provider/model"', 'model = "User/selected-in-cli"')
|
||||
.replace(/\s*$/, '\nmodel_reasoning_effort = "ultra"\n')
|
||||
.replace(/\s*$/, [
|
||||
"",
|
||||
'model_reasoning_effort = "ultra"',
|
||||
"",
|
||||
'[mcp_servers.ccr-toolhub.tools."tool_hub.resolve"]',
|
||||
'approval_mode = "approve"',
|
||||
""
|
||||
].join("\n"))
|
||||
);
|
||||
|
||||
await applyProfileConfig(config);
|
||||
@@ -773,6 +780,7 @@ test("profile service injects ToolHub MCP into Codex config", { skip: !process.e
|
||||
const preservedSeparateProfile = readFileSync(separateProfileFile, "utf8");
|
||||
assert.match(preservedSeparateProfile, /model = "User\/selected-in-cli"/);
|
||||
assert.match(preservedSeparateProfile, /model_reasoning_effort = "ultra"/);
|
||||
assert.equal(preservedSeparateProfile.includes("[mcp_servers.ccr-toolhub"), false);
|
||||
|
||||
config.Providers[0].models.push("model-2");
|
||||
config.profile.profiles[0].model = "Provider/model-2";
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env.ts";
|
||||
import { materializeBotGatewayStdioRunnerPath } from "@ccr/core/agents/bot-gateway/qr-login-service.ts";
|
||||
import { botGatewaySdkImportSpecifier } from "@ccr/core/agents/bot-gateway/sdk-import.ts";
|
||||
|
||||
function botGateway(overrides = {}) {
|
||||
@@ -141,3 +145,17 @@ test("botGatewaySdkImportSpecifier converts Windows absolute paths before URL sc
|
||||
assert.equal(botGatewaySdkImportSpecifier("file:///tmp/sdk/index.js"), "file:///tmp/sdk/index.js");
|
||||
assert.equal(botGatewaySdkImportSpecifier("@the-next-ai/bot-gateway-sdk"), "@the-next-ai/bot-gateway-sdk");
|
||||
});
|
||||
|
||||
test("materializeBotGatewayStdioRunnerPath copies bundled runner out of app.asar paths", () => {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-bot-runner-"));
|
||||
const source = path.join(dir, "resources", "app.asar", "dist", "main", "bot-gateway-sdk", "bin", "bot-gateway-stdio.mjs");
|
||||
const configDir = path.join(dir, "config");
|
||||
mkdirSync(path.dirname(source), { recursive: true });
|
||||
writeFileSync(source, "#!/usr/bin/env node\n#!/usr/bin/env node\nconsole.log('ok');\n");
|
||||
|
||||
const materialized = materializeBotGatewayStdioRunnerPath(source, configDir);
|
||||
|
||||
assert.equal(materialized, path.join(configDir, "bot-gateway", "runners", "bot-gateway-stdio.mjs"));
|
||||
assert.notEqual(path.dirname(materialized), path.dirname(source));
|
||||
assert.equal(readFileSync(materialized, "utf8"), "#!/usr/bin/env node\nconsole.log('ok');\n");
|
||||
});
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
prepareCodexMultiAgentBridgeRequest,
|
||||
transformCodexMultiAgentBridgeResponseValue,
|
||||
transformCodexMultiAgentBridgeSseEvent
|
||||
} from "@ccr/core/gateway/service.ts";
|
||||
|
||||
const config = {
|
||||
Providers: [],
|
||||
Router: {
|
||||
builtInRules: {
|
||||
"claude-code": { enabled: true },
|
||||
codex: { enabled: true }
|
||||
},
|
||||
fallback: { mode: "off", models: [], retryCount: 1 },
|
||||
rules: []
|
||||
}
|
||||
};
|
||||
|
||||
function multiAgentNamespaceTool() {
|
||||
return {
|
||||
type: "namespace",
|
||||
name: "multi_agent_v1",
|
||||
description: "Tools for spawning and managing sub-agents.",
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
name: "spawn_agent",
|
||||
description: "Spawn a sub-agent.",
|
||||
strict: false,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: { type: "string" },
|
||||
model: { type: "string" }
|
||||
},
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
name: "wait_agent",
|
||||
description: "Wait for agents.",
|
||||
strict: false,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
targets: { type: "array", items: { type: "string" } }
|
||||
},
|
||||
required: ["targets"],
|
||||
additionalProperties: false
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
test("Codex multi-agent bridge expands namespace tools for non-GPT models", () => {
|
||||
const result = prepareCodexMultiAgentBridgeRequest({
|
||||
body: Buffer.from(JSON.stringify({
|
||||
input: [
|
||||
{
|
||||
arguments: JSON.stringify({ message: "inspect tests" }),
|
||||
call_id: "call_agent",
|
||||
name: "spawn_agent",
|
||||
namespace: "multi_agent_v1",
|
||||
type: "function_call"
|
||||
}
|
||||
],
|
||||
model: "Provider/claude-sonnet",
|
||||
parallel_tool_calls: true,
|
||||
tool_choice: { type: "tool", name: "multi_agent_v1" },
|
||||
tools: [
|
||||
{ type: "function", name: "exec_command" },
|
||||
multiAgentNamespaceTool()
|
||||
]
|
||||
})),
|
||||
config,
|
||||
headers: { "user-agent": "codex-test" },
|
||||
method: "POST",
|
||||
path: "/v1/responses"
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
const body = JSON.parse(result.body.toString("utf8"));
|
||||
assert.deepEqual(
|
||||
body.tools.map((tool) => `${tool.type}:${tool.name}`),
|
||||
["function:exec_command", "function:multi_agent_v1_spawn_agent", "function:multi_agent_v1_wait_agent"]
|
||||
);
|
||||
assert.match(body.tools[1].description, /multi_agent_v1\.spawn_agent/);
|
||||
assert.equal(body.tool_choice, undefined);
|
||||
assert.equal(body.parallel_tool_calls, true);
|
||||
assert.equal(body.input[0].name, "multi_agent_v1_spawn_agent");
|
||||
assert.equal(body.input[0].namespace, undefined);
|
||||
});
|
||||
|
||||
test("Codex multi-agent bridge leaves GPT models untouched", () => {
|
||||
const result = prepareCodexMultiAgentBridgeRequest({
|
||||
body: Buffer.from(JSON.stringify({
|
||||
model: "openai/gpt-5-codex",
|
||||
tools: [multiAgentNamespaceTool()]
|
||||
})),
|
||||
config,
|
||||
headers: { "user-agent": "codex-test" },
|
||||
method: "POST",
|
||||
path: "/v1/responses"
|
||||
});
|
||||
|
||||
assert.equal(result, undefined);
|
||||
});
|
||||
|
||||
test("Codex multi-agent bridge rewrites flattened function response items to namespace calls", () => {
|
||||
const result = transformCodexMultiAgentBridgeResponseValue({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
arguments: JSON.stringify({ message: "inspect tests" }),
|
||||
call_id: "call_agent",
|
||||
name: "multi_agent_v1_spawn_agent",
|
||||
type: "function_call"
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.deepEqual(result.value.item, {
|
||||
arguments: JSON.stringify({ message: "inspect tests" }),
|
||||
call_id: "call_agent",
|
||||
name: "spawn_agent",
|
||||
namespace: "multi_agent_v1",
|
||||
type: "function_call"
|
||||
});
|
||||
});
|
||||
|
||||
test("Codex multi-agent bridge rewrites flattened function SSE events", () => {
|
||||
const event = transformCodexMultiAgentBridgeSseEvent([
|
||||
"event: response.output_item.done",
|
||||
`data: ${JSON.stringify({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
arguments: JSON.stringify({ targets: ["agent-1"] }),
|
||||
call_id: "call_wait",
|
||||
name: "multi_agent_v1_wait_agent",
|
||||
type: "function_call"
|
||||
}
|
||||
})}`
|
||||
].join("\n"));
|
||||
|
||||
assert.match(event, /^event: response\.output_item\.done\n/);
|
||||
const data = JSON.parse(event.split("\ndata: ")[1]);
|
||||
assert.equal(data.item.type, "function_call");
|
||||
assert.equal(data.item.name, "wait_agent");
|
||||
assert.equal(data.item.namespace, "multi_agent_v1");
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
import { gatewayFetchPreloadScriptForTest } from "@ccr/core/gateway/core-runtime/supervisor.ts";
|
||||
|
||||
test("gateway fetch preload applies API_TIMEOUT_MS to direct upstream fetches", async () => {
|
||||
const harness = executePreload({
|
||||
CCR_UNDICI_MODULE: "mock-undici",
|
||||
CCR_UPSTREAM_TIMEOUT_MS: "600000"
|
||||
});
|
||||
|
||||
assert.equal(harness.agentOptions.length, 1);
|
||||
assert.equal(harness.agentOptions[0].headersTimeout, 600000);
|
||||
assert.equal(harness.agentOptions[0].bodyTimeout, 600000);
|
||||
assert.equal(harness.proxyAgentOptions.length, 0);
|
||||
|
||||
await harness.context.fetch("https://api.example.test/v1/chat/completions", { method: "POST" });
|
||||
|
||||
assert.equal(harness.fetchCalls.length, 1);
|
||||
assert.equal(harness.fetchCalls[0].init.dispatcher.kind, "direct");
|
||||
});
|
||||
|
||||
test("gateway fetch preload combines proxy routing with timeout dispatcher options", async () => {
|
||||
const harness = executePreload({
|
||||
CCR_UNDICI_MODULE: "mock-undici",
|
||||
CCR_UPSTREAM_PROXY_URL: "http://127.0.0.1:8888",
|
||||
CCR_UPSTREAM_TIMEOUT_MS: "600000",
|
||||
NO_PROXY: "api.internal.test,.bypass.test"
|
||||
});
|
||||
|
||||
assert.equal(harness.agentOptions.length, 1);
|
||||
assert.equal(harness.proxyAgentOptions.length, 1);
|
||||
assert.equal(harness.proxyAgentOptions[0].uri, "http://127.0.0.1:8888");
|
||||
assert.equal(harness.proxyAgentOptions[0].headersTimeout, 600000);
|
||||
assert.equal(harness.proxyAgentOptions[0].bodyTimeout, 600000);
|
||||
|
||||
await harness.context.fetch("https://api.external.test/v1/messages", {});
|
||||
await harness.context.fetch("https://api.internal.test/v1/messages", {});
|
||||
await harness.context.fetch("http://127.0.0.1:3456/health", {});
|
||||
await harness.context.fetch("https://service.bypass.test/v1/messages", {});
|
||||
|
||||
assert.equal(harness.fetchCalls[0].init.dispatcher.kind, "proxy");
|
||||
assert.equal(harness.fetchCalls[1].init.dispatcher.kind, "direct");
|
||||
assert.equal(harness.fetchCalls[2].init.dispatcher.kind, "direct");
|
||||
assert.equal(harness.fetchCalls[3].init.dispatcher.kind, "direct");
|
||||
});
|
||||
|
||||
test("gateway fetch preload preserves explicit fetch dispatchers", async () => {
|
||||
const harness = executePreload({
|
||||
CCR_UNDICI_MODULE: "mock-undici",
|
||||
CCR_UPSTREAM_TIMEOUT_MS: "600000"
|
||||
});
|
||||
const dispatcher = { kind: "caller" };
|
||||
|
||||
await harness.context.fetch("https://api.example.test/v1/messages", { dispatcher });
|
||||
|
||||
assert.equal(harness.fetchCalls.length, 1);
|
||||
assert.equal(harness.fetchCalls[0].init.dispatcher, dispatcher);
|
||||
});
|
||||
|
||||
function executePreload(env) {
|
||||
const agentOptions = [];
|
||||
const proxyAgentOptions = [];
|
||||
const fetchCalls = [];
|
||||
|
||||
class Agent {
|
||||
constructor(options) {
|
||||
this.kind = "direct";
|
||||
this.options = options;
|
||||
agentOptions.push(options);
|
||||
}
|
||||
}
|
||||
|
||||
class ProxyAgent {
|
||||
constructor(options) {
|
||||
this.kind = "proxy";
|
||||
this.options = options;
|
||||
proxyAgentOptions.push(options);
|
||||
}
|
||||
}
|
||||
|
||||
const context = {
|
||||
URL,
|
||||
fetch: async (input, init) => {
|
||||
fetchCalls.push({ init, input });
|
||||
return { ok: true };
|
||||
},
|
||||
process: { env },
|
||||
require: (moduleName) => {
|
||||
assert.equal(moduleName, "mock-undici");
|
||||
return { Agent, ProxyAgent };
|
||||
}
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(gatewayFetchPreloadScriptForTest(), context);
|
||||
|
||||
return {
|
||||
agentOptions,
|
||||
context,
|
||||
fetchCalls,
|
||||
proxyAgentOptions
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts";
|
||||
import { compileCoreGatewayConfig } from "@ccr/core/gateway/core-runtime/config-compiler.ts";
|
||||
import {
|
||||
createGatewayPlugin,
|
||||
isLocalAgentOauthProviderPlugin
|
||||
} from "@ccr/core/gateway/core-runtime/local-agent-auth-provider-hook.ts";
|
||||
import { virtualApplyPatchToolName } from "@ccr/core/gateway/internal/shared.ts";
|
||||
|
||||
test("Grok local agent auth hook refreshes live login state before authenticating upstream requests", async (t) => {
|
||||
await withGrokHome(t, async (grokHome) => {
|
||||
writeGrokAuth(grokHome, {
|
||||
expires_at: "2000-01-01T00:00:00Z",
|
||||
key: "expired-grok-access-token",
|
||||
oidc_client_id: "grok-client-id",
|
||||
oidc_issuer: "https://auth.x.ai",
|
||||
refresh_token: "grok-refresh-token"
|
||||
});
|
||||
|
||||
const previousFetch = globalThis.fetch;
|
||||
const previousTokenEndpoint = process.env.GROK_OIDC_TOKEN_ENDPOINT;
|
||||
process.env.GROK_OIDC_TOKEN_ENDPOINT = "http://127.0.0.1/grok/oauth/token";
|
||||
globalThis.fetch = async (input, init) => {
|
||||
assert.equal(String(input), "http://127.0.0.1/grok/oauth/token");
|
||||
assert.equal(String(init?.body ?? ""), "client_id=grok-client-id&grant_type=refresh_token&refresh_token=grok-refresh-token");
|
||||
return new Response(JSON.stringify({
|
||||
access_token: "refreshed-grok-access-token",
|
||||
expires_in: 3600,
|
||||
refresh_token: "refreshed-grok-refresh-token"
|
||||
}), { headers: { "content-type": "application/json" }, status: 200 });
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.fetch = previousFetch;
|
||||
restoreEnv("GROK_OIDC_TOKEN_ENDPOINT", previousTokenEndpoint);
|
||||
});
|
||||
|
||||
const [hook] = createGatewayPlugin({
|
||||
config: {
|
||||
providerPlugins: [grokOauthProviderPlugin()]
|
||||
}
|
||||
}).providerHooks;
|
||||
assert.equal(hook.key, "config:ccr-local-agent-grok-cli-api-grok-cli-oauth");
|
||||
|
||||
const patch = "*** Begin Patch\n*** Add File: grok.txt\n+hi\n*** End Patch\n";
|
||||
const upstreamRequest = {
|
||||
body: {
|
||||
external_web_access: true,
|
||||
input: [
|
||||
{ type: "custom_tool_call", call_id: "call_patch", name: "apply_patch", input: patch },
|
||||
{ type: "custom_tool_call_output", call_id: "call_patch", output: "Success" }
|
||||
],
|
||||
model: "wrong-model",
|
||||
parallel_tool_calls: true,
|
||||
tool_choice: "auto",
|
||||
tools: [
|
||||
{ type: "function", name: "exec_command" },
|
||||
{ type: "custom", name: "apply_patch", format: { type: "grammar", syntax: "lark", definition: "start: begin_patch" } },
|
||||
{ type: "namespace", name: "multi_agent_v1" },
|
||||
{ type: "web_search" }
|
||||
]
|
||||
},
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-api-key": "client-key"
|
||||
},
|
||||
method: "POST",
|
||||
url: "https://cli-chat-proxy.grok.com/v1/responses"
|
||||
};
|
||||
const authResult = await hook.authenticate({
|
||||
model: "grok-4.5",
|
||||
upstreamRequest
|
||||
});
|
||||
|
||||
assert.equal(authResult.ok, true);
|
||||
assert.equal(authResult.value.headers.authorization, "Bearer refreshed-grok-access-token");
|
||||
assert.equal(authResult.value.headers["x-api-key"], undefined);
|
||||
assert.equal(upstreamRequest.headers["x-api-key"], "client-key");
|
||||
|
||||
const requestResult = await hook.transformRequest({
|
||||
model: "grok-4.5",
|
||||
upstreamRequest: authResult.value
|
||||
});
|
||||
assert.equal(requestResult.ok, true);
|
||||
assert.equal(requestResult.value.headers["x-grok-client-identifier"], "xai-grok-cli");
|
||||
assert.equal(requestResult.value.headers["x-grok-client-version"], "0.2.93");
|
||||
assert.equal(requestResult.value.headers["x-grok-model-override"], "grok-4.5");
|
||||
assert.equal(requestResult.value.body.external_web_access, undefined);
|
||||
assert.deepEqual(requestResult.value.body.tools.map((tool) => tool.type), ["function", "function"]);
|
||||
assert.equal(requestResult.value.body.tools[1].name, virtualApplyPatchToolName);
|
||||
assert.equal(requestResult.value.body.tools.some((tool) => tool.type === "custom" || tool.type === "namespace"), false);
|
||||
assert.equal(requestResult.value.body.tool_choice, "auto");
|
||||
assert.equal(requestResult.value.body.parallel_tool_calls, true);
|
||||
assert.match(requestResult.value.body.instructions, /When modifying files, call virtual_apply_patch/);
|
||||
assert.equal(requestResult.value.body.input[0].type, "function_call");
|
||||
assert.equal(requestResult.value.body.input[0].name, virtualApplyPatchToolName);
|
||||
assert.deepEqual(JSON.parse(requestResult.value.body.input[0].arguments), { patch });
|
||||
assert.equal(requestResult.value.body.input[1].type, "function_call_output");
|
||||
|
||||
const persisted = JSON.parse(readFileSync(path.join(grokHome, "auth.json"), "utf8"));
|
||||
assert.equal(persisted["https://auth.x.ai::test-account"].key, "refreshed-grok-access-token");
|
||||
});
|
||||
});
|
||||
|
||||
test("Grok local agent request hook removes unsupported Responses tools and stale tool choice", () => {
|
||||
const [hook] = createGatewayPlugin({
|
||||
config: {
|
||||
providerPlugins: [grokOauthProviderPlugin()]
|
||||
}
|
||||
}).providerHooks;
|
||||
const requestResult = hook.transformRequest({
|
||||
model: "grok-4.5",
|
||||
upstreamRequest: {
|
||||
body: {
|
||||
parallel_tool_calls: true,
|
||||
tool_choice: { type: "tool", name: "multi_agent_v1" },
|
||||
tools: [
|
||||
{ type: "namespace", name: "multi_agent_v1" },
|
||||
{ type: "function", name: "exec_command" }
|
||||
]
|
||||
},
|
||||
headers: {},
|
||||
method: "POST",
|
||||
url: "https://cli-chat-proxy.grok.com/v1/responses"
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(requestResult.ok, true);
|
||||
assert.deepEqual(requestResult.value.body.tools, [{ type: "function", name: "exec_command" }]);
|
||||
assert.equal(requestResult.value.body.tool_choice, undefined);
|
||||
assert.equal(requestResult.value.body.parallel_tool_calls, true);
|
||||
});
|
||||
|
||||
test("core gateway config installs the local agent dynamic auth runtime hook when OAuth plugins are present", async () => {
|
||||
const config = createDefaultAppConfig();
|
||||
config.providerPlugins = [grokOauthProviderPlugin()];
|
||||
config.Providers = [
|
||||
{
|
||||
api_base_url: "https://cli-chat-proxy.grok.com/v1",
|
||||
api_key: "ccr-local-agent-login",
|
||||
id: "grok-cli-api",
|
||||
models: ["grok-4.5"],
|
||||
name: "Grok CLI API",
|
||||
type: "openai_responses"
|
||||
}
|
||||
];
|
||||
|
||||
const compiled = await compileCoreGatewayConfig(
|
||||
config,
|
||||
"raw-trace-token",
|
||||
"billing-usage-token",
|
||||
"core-auth-token"
|
||||
);
|
||||
const plugins = Array.isArray(compiled.plugins) ? compiled.plugins : [];
|
||||
const localAgentAuthPlugin = plugins.find((plugin) => plugin.key === "ccr-local-agent-auth-provider-hooks");
|
||||
|
||||
assert.ok(localAgentAuthPlugin);
|
||||
assert.match(localAgentAuthPlugin.modulePath, /local-agent-auth-provider-hook\.js$/);
|
||||
});
|
||||
|
||||
test("core gateway config removes Grok unsupported Responses options through declarative request transforms", async (t) => {
|
||||
await withGrokHome(t, async (grokHome) => {
|
||||
writeGrokAuth(grokHome, {
|
||||
expires_at: "2099-01-01T00:00:00Z",
|
||||
key: "live-grok-access-token",
|
||||
oidc_client_id: "grok-client-id",
|
||||
oidc_issuer: "https://auth.x.ai",
|
||||
refresh_token: "grok-refresh-token"
|
||||
});
|
||||
const plugin = grokOauthProviderPlugin();
|
||||
plugin.request.bodyRemove = ["metadata"];
|
||||
|
||||
const config = createDefaultAppConfig();
|
||||
config.providerPlugins = [plugin];
|
||||
config.Providers = [
|
||||
{
|
||||
api_base_url: "https://cli-chat-proxy.grok.com/v1",
|
||||
api_key: "ccr-local-agent-login",
|
||||
id: "grok-cli-api",
|
||||
models: ["grok-4.5"],
|
||||
name: "Grok CLI API",
|
||||
type: "openai_responses"
|
||||
}
|
||||
];
|
||||
|
||||
const compiled = await compileCoreGatewayConfig(
|
||||
config,
|
||||
"raw-trace-token",
|
||||
"billing-usage-token",
|
||||
"core-auth-token"
|
||||
);
|
||||
const providerPlugins = Array.isArray(compiled.providerPlugins) ? compiled.providerPlugins : [];
|
||||
const grokPlugin = providerPlugins.find((value) => value.key === "ccr-local-agent-grok-cli-api-grok-cli-oauth");
|
||||
|
||||
assert.ok(grokPlugin);
|
||||
assert.deepEqual(grokPlugin.request.bodyRemove, ["metadata", "external_web_access"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("local agent OAuth plugin detector only matches managed OAuth imports", () => {
|
||||
assert.equal(isLocalAgentOauthProviderPlugin(grokOauthProviderPlugin()), true);
|
||||
assert.equal(isLocalAgentOauthProviderPlugin({
|
||||
key: "ccr-local-agent-grok-cli-api-grok-cli-api-key"
|
||||
}), false);
|
||||
assert.equal(isLocalAgentOauthProviderPlugin({
|
||||
key: "external-grok-cli-oauth"
|
||||
}), false);
|
||||
});
|
||||
|
||||
function grokOauthProviderPlugin() {
|
||||
return {
|
||||
auth: {
|
||||
headers: {
|
||||
authorization: "Bearer imported-stale-token"
|
||||
},
|
||||
removeHeaders: ["x-api-key"],
|
||||
strict: true
|
||||
},
|
||||
key: "ccr-local-agent-grok-cli-api-grok-cli-oauth",
|
||||
providerName: "Grok CLI API",
|
||||
request: {
|
||||
headers: {
|
||||
"x-grok-client-identifier": "xai-grok-cli",
|
||||
"x-grok-client-version": "0.2.93",
|
||||
"x-grok-model-override": "{{ model }}"
|
||||
},
|
||||
strict: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function withGrokHome(t, run) {
|
||||
const previousGrokHome = process.env.GROK_HOME;
|
||||
const previousGrokAuthFile = process.env.GROK_AUTH_FILE;
|
||||
const previousGrokCliVersion = process.env.GROK_CLI_VERSION;
|
||||
const grokHome = mkdtempSync(path.join(os.tmpdir(), "ccr-grok-hook-test-"));
|
||||
process.env.GROK_HOME = grokHome;
|
||||
process.env.GROK_CLI_VERSION = "0.2.93";
|
||||
delete process.env.GROK_AUTH_FILE;
|
||||
try {
|
||||
await run(grokHome);
|
||||
} finally {
|
||||
restoreEnv("GROK_HOME", previousGrokHome);
|
||||
restoreEnv("GROK_AUTH_FILE", previousGrokAuthFile);
|
||||
restoreEnv("GROK_CLI_VERSION", previousGrokCliVersion);
|
||||
rmSync(grokHome, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function restoreEnv(name, value) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function writeGrokAuth(grokHome, auth) {
|
||||
writeFileSync(path.join(grokHome, "auth.json"), JSON.stringify({
|
||||
"https://auth.x.ai::test-account": auth
|
||||
}, null, 2));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<string, ReturnType<typeof formatLogBodyView>>();
|
||||
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<Worker>();
|
||||
const formatRequestIdRef = useRef(0);
|
||||
const filterRequestIdRef = useRef(0);
|
||||
const [bodyView, setBodyView] = useState<LogBodyPanelView>(() => createInitialLogBodyPanelView(body, bodyKey, mode, query));
|
||||
|
||||
useEffect(() => {
|
||||
latestQueryRef.current = debouncedQuery;
|
||||
}, [debouncedQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const initial = createInitialLogBodyPanelView(body, bodyKey, mode, latestQueryRef.current);
|
||||
setBodyView(initial);
|
||||
|
||||
workerRef.current?.terminate();
|
||||
workerRef.current = undefined;
|
||||
|
||||
if (isStaticLogBody(body)) {
|
||||
setBodyView(createStaticLogBodyPanelView(body, bodyKey, mode, latestQueryRef.current));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof Worker === "undefined") {
|
||||
setBodyView({
|
||||
...initial,
|
||||
error: "Body formatter worker is unavailable.",
|
||||
loading: false,
|
||||
visible: initial.visible || "Body formatter worker is unavailable."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const worker = createLogBodyFormatterWorker();
|
||||
const formatRequestId = formatRequestIdRef.current + 1;
|
||||
formatRequestIdRef.current = formatRequestId;
|
||||
filterRequestIdRef.current += 1;
|
||||
workerRef.current = worker;
|
||||
|
||||
worker.onmessage = (event: MessageEvent<LogBodyWorkerResponse>) => {
|
||||
const response = event.data;
|
||||
if (response.kind === "format-result") {
|
||||
if (response.id !== formatRequestIdRef.current || response.bodyKey !== bodyKey || response.mode !== mode) {
|
||||
return;
|
||||
}
|
||||
setBodyView(logBodyPanelViewFromWorkerResult(response));
|
||||
if (response.query !== latestQueryRef.current) {
|
||||
postLogBodyFilter(worker, bodyKey, mode, latestQueryRef.current, filterRequestIdRef);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.kind === "filter-result") {
|
||||
if (response.id !== filterRequestIdRef.current || response.bodyKey !== bodyKey || response.mode !== mode) {
|
||||
return;
|
||||
}
|
||||
setBodyView((current) => current.bodyKey === bodyKey && current.mode === mode
|
||||
? { ...current, query: response.query, visible: response.visible }
|
||||
: current);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(response.operation === "format" && response.id === formatRequestIdRef.current) ||
|
||||
(response.operation === "filter" && response.id === filterRequestIdRef.current)
|
||||
) {
|
||||
setBodyView((current) => current.bodyKey === bodyKey && current.mode === mode
|
||||
? { ...current, error: response.message, loading: false, visible: current.visible || response.message }
|
||||
: current);
|
||||
}
|
||||
};
|
||||
|
||||
worker.onerror = (event) => {
|
||||
setBodyView((current) => current.bodyKey === bodyKey && current.mode === mode
|
||||
? { ...current, error: event.message || "Body formatter worker failed.", loading: false }
|
||||
: current);
|
||||
};
|
||||
|
||||
worker.postMessage({
|
||||
body,
|
||||
bodyKey,
|
||||
id: formatRequestId,
|
||||
kind: "format",
|
||||
largeTextThreshold: logBodyLargeTextThreshold,
|
||||
mode,
|
||||
previewTextLimit: logBodyPreviewTextLimit,
|
||||
query: latestQueryRef.current
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (workerRef.current === worker) {
|
||||
workerRef.current = undefined;
|
||||
}
|
||||
worker.terminate();
|
||||
};
|
||||
}, [body, bodyKey, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
const worker = workerRef.current;
|
||||
if (!worker || bodyView.loading || bodyView.bodyKey !== bodyKey || bodyView.mode !== mode || bodyView.query === debouncedQuery) {
|
||||
return;
|
||||
}
|
||||
postLogBodyFilter(worker, bodyKey, mode, debouncedQuery, filterRequestIdRef);
|
||||
}, [bodyKey, bodyView.bodyKey, bodyView.loading, bodyView.mode, bodyView.query, debouncedQuery, mode]);
|
||||
|
||||
return bodyView;
|
||||
}
|
||||
|
||||
function useDebouncedValue<T>(value: T, delayMs: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setDebounced(value), delayMs);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [delayMs, value]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
||||
function createLogBodyFormatterWorker(): Worker {
|
||||
return new Worker(new URL("../../assets/log-body.worker.js", window.location.href), { type: "module" });
|
||||
}
|
||||
|
||||
function postLogBodyFilter(
|
||||
worker: Worker,
|
||||
bodyKey: string,
|
||||
mode: LogBodyFormatMode,
|
||||
query: string,
|
||||
idRef: { current: number }
|
||||
) {
|
||||
const id = idRef.current + 1;
|
||||
idRef.current = id;
|
||||
worker.postMessage({
|
||||
bodyKey,
|
||||
id,
|
||||
kind: "filter",
|
||||
mode,
|
||||
query
|
||||
});
|
||||
}
|
||||
|
||||
function createInitialLogBodyPanelView(
|
||||
body: RequestLogBody | undefined,
|
||||
bodyKey: string,
|
||||
mode: LogBodyFormatMode,
|
||||
query: string
|
||||
): LogBodyPanelView {
|
||||
if (isStaticLogBody(body)) {
|
||||
return createStaticLogBodyPanelView(body, bodyKey, mode, query);
|
||||
}
|
||||
|
||||
const large = isLargeLogBody(body, logBodyLargeTextThreshold);
|
||||
const preview = large && mode !== "full";
|
||||
const text = preview
|
||||
? createLogBodyPreviewText(body, logBodyPreviewTextLimit)
|
||||
: "Loading body...";
|
||||
return {
|
||||
bodyKey,
|
||||
error: "",
|
||||
formattedTextLength: text.length,
|
||||
large,
|
||||
loading: true,
|
||||
mode,
|
||||
preview,
|
||||
query,
|
||||
sourceSizeBytes: body?.sizeBytes ?? 0,
|
||||
text,
|
||||
visible: text
|
||||
};
|
||||
}
|
||||
|
||||
function createStaticLogBodyPanelView(
|
||||
body: RequestLogBody | undefined,
|
||||
bodyKey: string,
|
||||
mode: LogBodyFormatMode,
|
||||
query: string
|
||||
): LogBodyPanelView {
|
||||
const text = body?.text || "No body";
|
||||
return {
|
||||
bodyKey,
|
||||
error: "",
|
||||
formattedTextLength: text.length,
|
||||
large: false,
|
||||
loading: false,
|
||||
mode,
|
||||
preview: false,
|
||||
query,
|
||||
sourceSizeBytes: body?.sizeBytes ?? 0,
|
||||
text,
|
||||
visible: filterStaticLogBodyText(text, query)
|
||||
};
|
||||
}
|
||||
|
||||
function logBodyPanelViewFromWorkerResult(result: Extract<LogBodyWorkerResponse, { kind: "format-result" }>): LogBodyPanelView {
|
||||
return {
|
||||
bodyKey: result.bodyKey,
|
||||
error: "",
|
||||
formattedTextLength: result.formattedTextLength,
|
||||
json: result.json,
|
||||
large: result.large,
|
||||
loading: false,
|
||||
mode: result.mode,
|
||||
preview: result.preview,
|
||||
query: result.query,
|
||||
sourceSizeBytes: result.sourceSizeBytes,
|
||||
text: result.text,
|
||||
visible: result.visible
|
||||
};
|
||||
}
|
||||
|
||||
function isStaticLogBody(body: RequestLogBody | undefined): boolean {
|
||||
return !body || (!body.text && body.sizeBytes === 0);
|
||||
}
|
||||
|
||||
function filterStaticLogBodyText(text: string, query: string): string {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return text;
|
||||
}
|
||||
return text.toLowerCase().includes(normalized) ? text : "No matching lines";
|
||||
}
|
||||
|
||||
function LogJsonPanel({
|
||||
body,
|
||||
className,
|
||||
@@ -1514,20 +1756,25 @@ function LogJsonPanel({
|
||||
const t = useAppText();
|
||||
const [selectedTab, setSelectedTab] = useState<LogPayloadTab>("body");
|
||||
const [preferTextBody, setPreferTextBody] = useState(false);
|
||||
const [bodyMode, setBodyMode] = useState<LogBodyFormatMode>("preview");
|
||||
const [fullscreenOpen, setFullscreenOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const bodyKey = logBodyCacheKey(body);
|
||||
const bodyView = useMemo(() => cachedFormatLogBodyView(bodyKey, body), [bodyKey]);
|
||||
const bodyView = useLogBodyWorkerView(body, bodyKey, bodyMode, query);
|
||||
const formatted = bodyView.text;
|
||||
const visible = useMemo(() => filterLogText(formatted, query), [formatted, query]);
|
||||
const visible = bodyView.visible;
|
||||
const headerRows = useMemo(() => networkHeaderRows(headers ?? {}), [headers]);
|
||||
const [expandedJsonPaths, setExpandedJsonPaths] = useState<Set<string>>(() => createInitialVisibleJsonPaths(bodyView));
|
||||
const showJsonTree = bodyView.json !== undefined && query.trim() === "" && !preferTextBody;
|
||||
const showJsonTree = bodyView.json !== undefined && query.trim() === "" && !preferTextBody && !bodyView.preview;
|
||||
|
||||
useEffect(() => {
|
||||
setPreferTextBody(false);
|
||||
setBodyMode("preview");
|
||||
}, [bodyKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedJsonPaths(createInitialVisibleJsonPaths(bodyView));
|
||||
setPreferTextBody(false);
|
||||
}, [bodyKey]);
|
||||
}, [bodyView.bodyKey, bodyView.json, bodyView.text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullscreenOpen) {
|
||||
@@ -1582,6 +1829,7 @@ function LogJsonPanel({
|
||||
<LogJsonBodyToolbar
|
||||
body={body}
|
||||
bodyView={bodyView}
|
||||
onLoadFullBody={() => setBodyMode("full")}
|
||||
onQueryChange={setQuery}
|
||||
onToggleTextBody={() => setPreferTextBody((current) => !current)}
|
||||
preferTextBody={preferTextBody}
|
||||
@@ -1610,6 +1858,7 @@ function LogJsonPanel({
|
||||
copyText={formatted}
|
||||
expandedJsonPaths={expandedJsonPaths}
|
||||
onClose={() => setFullscreenOpen(false)}
|
||||
onLoadFullBody={() => setBodyMode("full")}
|
||||
onQueryChange={setQuery}
|
||||
onToggleJsonPath={toggleJsonPath}
|
||||
onToggleTextBody={() => setPreferTextBody((current) => !current)}
|
||||
@@ -1636,6 +1885,7 @@ function LogJsonPanel({
|
||||
function LogJsonBodyToolbar({
|
||||
body,
|
||||
bodyView,
|
||||
onLoadFullBody,
|
||||
onQueryChange,
|
||||
onToggleTextBody,
|
||||
preferTextBody,
|
||||
@@ -1643,7 +1893,8 @@ function LogJsonBodyToolbar({
|
||||
title
|
||||
}: {
|
||||
body?: RequestLogBody;
|
||||
bodyView: ReturnType<typeof formatLogBodyView>;
|
||||
bodyView: LogBodyPanelView;
|
||||
onLoadFullBody: () => void;
|
||||
onQueryChange: (value: string) => void;
|
||||
onToggleTextBody: () => void;
|
||||
preferTextBody: boolean;
|
||||
@@ -1651,6 +1902,12 @@ function LogJsonBodyToolbar({
|
||||
title: string;
|
||||
}) {
|
||||
const t = useAppText();
|
||||
const canLoadFullBody = bodyView.preview && bodyView.large && query.trim() === "";
|
||||
const canToggleJsonText = bodyView.json !== undefined && query.trim() === "";
|
||||
const showToggleButton = canLoadFullBody || canToggleJsonText;
|
||||
const toggleLabel = canLoadFullBody
|
||||
? bodyView.loading && bodyView.mode === "full" ? t("Loading full payload...") : t("Show full content")
|
||||
: preferTextBody ? "JSON" : t("Show full content");
|
||||
|
||||
return (
|
||||
<div className="network-body-meta flex min-h-9 shrink-0 items-center gap-2 border-b px-3 py-1.5">
|
||||
@@ -1664,15 +1921,20 @@ function LogJsonBodyToolbar({
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
{bodyView.json !== undefined && query.trim() === "" ? (
|
||||
{showToggleButton ? (
|
||||
<button
|
||||
className="network-tab shrink-0 border-0 bg-transparent p-0 text-[11px] font-semibold outline-none"
|
||||
onClick={onToggleTextBody}
|
||||
disabled={bodyView.loading && bodyView.mode === "full"}
|
||||
onClick={canLoadFullBody ? onLoadFullBody : onToggleTextBody}
|
||||
type="button"
|
||||
>
|
||||
{preferTextBody ? "JSON" : t("Show full content")}
|
||||
{toggleLabel}
|
||||
</button>
|
||||
) : null}
|
||||
{bodyView.loading ? <span className="network-muted shrink-0 text-[11px] font-semibold">{t("Loading full payload...")}</span> : null}
|
||||
{bodyView.error ? <span className="network-error-box shrink-0 rounded px-2 py-0.5 text-[11px] font-semibold">{bodyView.error}</span> : null}
|
||||
{bodyView.preview ? <span className="network-service-paused rounded-full px-2 py-0.5 text-[11px] font-semibold">{t("preview")}</span> : null}
|
||||
{bodyView.sourceSizeBytes > 0 ? <span className="network-muted hidden shrink-0 text-[11px] font-semibold sm:inline">{formatBytes(bodyView.sourceSizeBytes)}</span> : null}
|
||||
{body?.contentType ? <span className="network-muted hidden shrink-0 text-[11px] font-semibold sm:inline">{body.contentType}</span> : null}
|
||||
{body?.truncated ? <span className="network-service-paused rounded-full px-2 py-0.5 text-[11px] font-semibold">{t("truncated")}</span> : null}
|
||||
</div>
|
||||
@@ -1706,6 +1968,7 @@ function LogJsonFullscreenViewer({
|
||||
copyText,
|
||||
expandedJsonPaths,
|
||||
onClose,
|
||||
onLoadFullBody,
|
||||
onQueryChange,
|
||||
onToggleJsonPath,
|
||||
onToggleTextBody,
|
||||
@@ -1718,11 +1981,12 @@ function LogJsonFullscreenViewer({
|
||||
visible
|
||||
}: {
|
||||
body?: RequestLogBody;
|
||||
bodyView: ReturnType<typeof formatLogBodyView>;
|
||||
bodyView: LogBodyPanelView;
|
||||
copyLabel: string;
|
||||
copyText: string;
|
||||
expandedJsonPaths: Set<string>;
|
||||
onClose: () => void;
|
||||
onLoadFullBody: () => void;
|
||||
onQueryChange: (value: string) => void;
|
||||
onToggleJsonPath: (path: string) => void;
|
||||
onToggleTextBody: () => void;
|
||||
@@ -1760,6 +2024,7 @@ function LogJsonFullscreenViewer({
|
||||
<LogJsonBodyToolbar
|
||||
body={body}
|
||||
bodyView={bodyView}
|
||||
onLoadFullBody={onLoadFullBody}
|
||||
onQueryChange={onQueryChange}
|
||||
onToggleTextBody={onToggleTextBody}
|
||||
preferTextBody={preferTextBody}
|
||||
@@ -1798,27 +2063,7 @@ function logBodyCacheKey(body: RequestLogBody | undefined): string {
|
||||
].join("\u001f");
|
||||
}
|
||||
|
||||
function cachedFormatLogBodyView(key: string, body: RequestLogBody | undefined): ReturnType<typeof formatLogBodyView> {
|
||||
const cached = logBodyViewCache.get(key);
|
||||
if (cached) {
|
||||
logBodyViewCache.delete(key);
|
||||
logBodyViewCache.set(key, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const value = formatLogBodyView(body);
|
||||
logBodyViewCache.set(key, value);
|
||||
while (logBodyViewCache.size > logBodyViewCacheLimit) {
|
||||
const oldest = logBodyViewCache.keys().next().value;
|
||||
if (!oldest) {
|
||||
break;
|
||||
}
|
||||
logBodyViewCache.delete(oldest);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function createInitialVisibleJsonPaths(bodyView: ReturnType<typeof formatLogBodyView>): Set<string> {
|
||||
function createInitialVisibleJsonPaths(bodyView: FormattedLogBody): Set<string> {
|
||||
if (!isJsonContainer(bodyView.json)) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export * from "./profiles";
|
||||
export * from "./services";
|
||||
export * from "./provider-accounts";
|
||||
export * from "./logs";
|
||||
export * from "./log-body-worker-protocol";
|
||||
export * from "./common";
|
||||
export * from "./config";
|
||||
export * from "./api-keys";
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { RequestLogBody } from "@ccr/core/contracts/app";
|
||||
import type { FormattedLogBody } from "./logs";
|
||||
|
||||
export const logBodyLargeTextThreshold = 256 * 1024;
|
||||
export const logBodyPreviewTextLimit = 160 * 1024;
|
||||
|
||||
export type LogBodyFormatMode = "full" | "preview";
|
||||
|
||||
export type LogBodyFormatRequest = {
|
||||
body?: RequestLogBody;
|
||||
bodyKey: string;
|
||||
id: number;
|
||||
kind: "format";
|
||||
largeTextThreshold?: number;
|
||||
mode: LogBodyFormatMode;
|
||||
previewTextLimit?: number;
|
||||
query: string;
|
||||
};
|
||||
|
||||
export type LogBodyFilterRequest = {
|
||||
bodyKey: string;
|
||||
id: number;
|
||||
kind: "filter";
|
||||
mode: LogBodyFormatMode;
|
||||
query: string;
|
||||
};
|
||||
|
||||
export type LogBodyWorkerRequest = LogBodyFilterRequest | LogBodyFormatRequest;
|
||||
|
||||
export type LogBodyFormatResult = FormattedLogBody & {
|
||||
bodyKey: string;
|
||||
formattedTextLength: number;
|
||||
id: number;
|
||||
kind: "format-result";
|
||||
large: boolean;
|
||||
mode: LogBodyFormatMode;
|
||||
ok: true;
|
||||
preview: boolean;
|
||||
query: string;
|
||||
sourceSizeBytes: number;
|
||||
visible: string;
|
||||
};
|
||||
|
||||
export type LogBodyFilterResult = {
|
||||
bodyKey: string;
|
||||
id: number;
|
||||
kind: "filter-result";
|
||||
mode: LogBodyFormatMode;
|
||||
ok: true;
|
||||
query: string;
|
||||
visible: string;
|
||||
};
|
||||
|
||||
export type LogBodyWorkerError = {
|
||||
bodyKey?: string;
|
||||
id: number;
|
||||
kind: "error";
|
||||
message: string;
|
||||
mode?: LogBodyFormatMode;
|
||||
operation: LogBodyWorkerRequest["kind"];
|
||||
};
|
||||
|
||||
export type LogBodyWorkerResponse = LogBodyFilterResult | LogBodyFormatResult | LogBodyWorkerError;
|
||||
|
||||
export function isLargeLogBody(
|
||||
body: RequestLogBody | undefined,
|
||||
threshold = logBodyLargeTextThreshold
|
||||
): boolean {
|
||||
if (!body) {
|
||||
return false;
|
||||
}
|
||||
return Math.max(body.sizeBytes, body.text.length) > threshold;
|
||||
}
|
||||
|
||||
export function createLogBodyPreviewText(
|
||||
body: RequestLogBody | undefined,
|
||||
limit = logBodyPreviewTextLimit
|
||||
): string {
|
||||
if (!body || (!body.text && body.sizeBytes === 0)) {
|
||||
return "No body";
|
||||
}
|
||||
|
||||
const text = body.text || "";
|
||||
if (!text) {
|
||||
return "Body text is not loaded.";
|
||||
}
|
||||
if (text.length <= limit) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const headLength = Math.max(0, Math.floor(limit * 0.65));
|
||||
const tailLength = Math.max(0, limit - headLength);
|
||||
const omitted = Math.max(0, text.length - headLength - tailLength);
|
||||
const head = text.slice(0, headLength);
|
||||
const tail = tailLength > 0 ? text.slice(-tailLength) : "";
|
||||
return [
|
||||
head,
|
||||
"",
|
||||
`... ${omitted} characters omitted from preview ...`,
|
||||
"",
|
||||
tail
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
createLogBodyPreviewText,
|
||||
isLargeLogBody,
|
||||
logBodyLargeTextThreshold,
|
||||
logBodyPreviewTextLimit,
|
||||
type LogBodyFilterRequest,
|
||||
type LogBodyFormatRequest,
|
||||
type LogBodyFormatResult,
|
||||
type LogBodyWorkerRequest,
|
||||
type LogBodyWorkerResponse
|
||||
} from "./log-body-worker-protocol";
|
||||
import { filterLogText, formatLogBodyView, type FormattedLogBody } from "./logs";
|
||||
|
||||
type CachedFormattedBody = FormattedLogBody & {
|
||||
bodyKey: string;
|
||||
large: boolean;
|
||||
mode: LogBodyFormatRequest["mode"];
|
||||
preview: boolean;
|
||||
sourceSizeBytes: number;
|
||||
};
|
||||
|
||||
type LogBodyWorkerGlobal = {
|
||||
onmessage: ((event: MessageEvent<LogBodyWorkerRequest>) => void) | null;
|
||||
postMessage: (message: LogBodyWorkerResponse) => void;
|
||||
};
|
||||
|
||||
const worker = self as unknown as LogBodyWorkerGlobal;
|
||||
let cachedBody: CachedFormattedBody | undefined;
|
||||
|
||||
worker.onmessage = (event: MessageEvent<LogBodyWorkerRequest>) => {
|
||||
const request = event.data;
|
||||
try {
|
||||
if (request.kind === "format") {
|
||||
worker.postMessage(formatBody(request) satisfies LogBodyWorkerResponse);
|
||||
return;
|
||||
}
|
||||
worker.postMessage(filterBody(request) satisfies LogBodyWorkerResponse);
|
||||
} catch (error) {
|
||||
worker.postMessage({
|
||||
bodyKey: "bodyKey" in request ? request.bodyKey : undefined,
|
||||
id: request.id,
|
||||
kind: "error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
mode: "mode" in request ? request.mode : undefined,
|
||||
operation: request.kind
|
||||
} satisfies LogBodyWorkerResponse);
|
||||
}
|
||||
};
|
||||
|
||||
function formatBody(request: LogBodyFormatRequest): LogBodyFormatResult {
|
||||
const threshold = request.largeTextThreshold ?? logBodyLargeTextThreshold;
|
||||
const previewLimit = request.previewTextLimit ?? logBodyPreviewTextLimit;
|
||||
const large = isLargeLogBody(request.body, threshold);
|
||||
const preview = large && request.mode !== "full";
|
||||
const bodyView = preview
|
||||
? { text: createLogBodyPreviewText(request.body, previewLimit) }
|
||||
: formatLogBodyView(request.body);
|
||||
const sourceSizeBytes = request.body?.sizeBytes ?? 0;
|
||||
const visible = filterLogText(bodyView.text, request.query);
|
||||
|
||||
cachedBody = {
|
||||
...bodyView,
|
||||
bodyKey: request.bodyKey,
|
||||
large,
|
||||
mode: request.mode,
|
||||
preview,
|
||||
sourceSizeBytes
|
||||
};
|
||||
|
||||
return {
|
||||
...bodyView,
|
||||
bodyKey: request.bodyKey,
|
||||
formattedTextLength: bodyView.text.length,
|
||||
id: request.id,
|
||||
kind: "format-result",
|
||||
large,
|
||||
mode: request.mode,
|
||||
ok: true,
|
||||
preview,
|
||||
query: request.query,
|
||||
sourceSizeBytes,
|
||||
visible
|
||||
};
|
||||
}
|
||||
|
||||
function filterBody(request: LogBodyFilterRequest): LogBodyWorkerResponse {
|
||||
if (!cachedBody || cachedBody.bodyKey !== request.bodyKey || cachedBody.mode !== request.mode) {
|
||||
throw new Error("Formatted body cache is not available.");
|
||||
}
|
||||
|
||||
return {
|
||||
bodyKey: request.bodyKey,
|
||||
id: request.id,
|
||||
kind: "filter-result",
|
||||
mode: request.mode,
|
||||
ok: true,
|
||||
query: request.query,
|
||||
visible: filterLogText(cachedBody.text, request.query)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user