Implement comprehensive application updates

This commit is contained in:
musistudio 2026-09-02 18:37:11 +08:00
parent 829298cf8b
commit 4afd350eef
64 changed files with 18217 additions and 259 deletions

1
.gitignore vendored
View file

@ -20,3 +20,4 @@ playwright-report
blob-report
.tmp
.test-dist
docker/local-ai-gateway/*.tgz

View file

@ -1,4 +1,4 @@
ARG NODE_IMAGE=node:22-bookworm
ARG NODE_IMAGE=node:22-bookworm-slim
ARG RUNTIME_NODE_IMAGE=node:22-bookworm-slim
FROM ${NODE_IMAGE} AS build
@ -12,6 +12,9 @@ COPY packages/ui/package.json packages/ui/package.json
RUN npm ci
COPY . .
RUN if ls docker/local-ai-gateway/*.tgz >/dev/null 2>&1; then \
npm install --no-save docker/local-ai-gateway/*.tgz; \
fi
RUN npm run build:docker
FROM ${NODE_IMAGE} AS production-deps
@ -31,7 +34,6 @@ ENV NODE_ENV=production \
CCR_NGINX_PORT=8080 \
CCR_GATEWAY_HOST=127.0.0.1 \
CCR_GATEWAY_PORT=3456 \
CCR_GATEWAY_CORE_PORT=3457 \
CCR_PUBLIC_HOST=127.0.0.1 \
CCR_PUBLIC_PORT=3458

View file

@ -224,10 +224,10 @@ Open `http://127.0.0.1:3458`, then follow the same **Providers → Server → Ag
### Docker
```sh
docker compose up -d --build
npm run docker:compose:up
```
Docker exposes the management UI and gateway routes through `http://127.0.0.1:3458` by default. Read the [Docker deployment guide](https://ccrdesk.top/en/guides/docker/) before exposing CCR remotely.
Docker exposes the management UI and gateway routes through `http://127.0.0.1:3458` by default. The npm script prepares the local plugin-capable ai-gateway runtime before building the image when `../../next-ai/gateway` exists. Read the [Docker deployment guide](https://ccrdesk.top/en/guides/docker/) before exposing CCR remotely.
## Build desktop apps

View file

@ -224,10 +224,10 @@ ccr ui
### Docker
```sh
docker compose up -d --build
npm run docker:compose:up
```
Docker 默认通过 `http://127.0.0.1:3458` 提供管理界面与网关路由。远程暴露 CCR 前,请先阅读 [Docker 部署指南](https://ccrdesk.top/guides/docker/)。
Docker 默认通过 `http://127.0.0.1:3458` 提供管理界面与网关路由。这个 npm 脚本会在存在 `../../next-ai/gateway` 时先准备本地支持插件的 ai-gateway runtime,再构建镜像。远程暴露 CCR 前,请先阅读 [Docker 部署指南](https://ccrdesk.top/guides/docker/)。
## 构建桌面应用

View file

@ -1,3 +1,4 @@
import "./../scripts/generate-claude-code-config-options.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
build/docker-compose.mjs Normal file
View file

@ -0,0 +1,14 @@
import { spawnSync } from "node:child_process";
import { prepareLocalGatewayPackage, projectRoot } from "./docker-local-gateway.mjs";
const args = process.argv.slice(2);
const composeArgs = args.length > 0 ? args : ["up", "-d", "--build"];
prepareLocalGatewayPackage();
const result = spawnSync("docker", ["compose", ...composeArgs], {
cwd: projectRoot,
stdio: "inherit"
});
process.exit(result.status ?? 1);

15
build/docker-image.mjs Normal file
View file

@ -0,0 +1,15 @@
import { spawnSync } from "node:child_process";
import { prepareLocalGatewayPackage, projectRoot } from "./docker-local-gateway.mjs";
const imageName = process.env.CCR_DOCKER_IMAGE || "claude-code-router:local";
const args = process.argv.slice(2);
prepareLocalGatewayPackage();
const dockerArgs = ["build", "-t", imageName, ".", ...args];
const result = spawnSync("docker", dockerArgs, {
cwd: projectRoot,
stdio: "inherit"
});
process.exit(result.status ?? 1);

View file

@ -0,0 +1,108 @@
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const projectRoot = path.resolve(__dirname, "..");
export const dockerLocalGatewayDir = path.join(projectRoot, "docker", "local-ai-gateway");
const routerPluginSupportMarkers = [
"requestTransforms",
"routeResolvers",
"httpRoutes"
];
export function resolveLocalGatewayRoot() {
for (const envName of ["CCR_DOCKER_GATEWAY_SOURCE_DIR", "CCR_GATEWAY_SOURCE_DIR", "CCR_LOCAL_AI_GATEWAY_DIR"]) {
const value = process.env[envName]?.trim();
if (!value) continue;
return path.resolve(projectRoot, value);
}
return path.resolve(projectRoot, "..", "..", "next-ai", "gateway");
}
export function hasLocalGatewaySource(gatewayRoot = resolveLocalGatewayRoot()) {
return existsSync(path.join(gatewayRoot, "package.json")) &&
existsSync(path.join(gatewayRoot, "bin", "next-ai-gateway.js"));
}
export function prepareLocalGatewayPackage(options = {}) {
const gatewayRoot = options.gatewayRoot ?? resolveLocalGatewayRoot();
if (!hasLocalGatewaySource(gatewayRoot)) {
cleanLocalGatewayPackage();
if (options.required) {
throw new Error(`Local ai-gateway source was not found: ${gatewayRoot}`);
}
console.log(`[docker] Local ai-gateway source not found at ${gatewayRoot}; using package-lock dependency.`);
return undefined;
}
run("npm", ["run", "build"], { cwd: gatewayRoot });
assertGatewaySupportsRouterPlugin(gatewayRoot);
cleanLocalGatewayPackage();
mkdirSync(dockerLocalGatewayDir, { recursive: true });
const result = run("npm", ["pack", "--ignore-scripts", "--pack-destination", dockerLocalGatewayDir], {
cwd: gatewayRoot,
capture: true
});
const fileName = result.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).pop();
if (!fileName) {
throw new Error("npm pack did not report a package file.");
}
const tarball = path.join(dockerLocalGatewayDir, fileName);
if (!existsSync(tarball)) {
throw new Error(`Packed ai-gateway tarball was not created: ${tarball}`);
}
console.log(`[docker] Packed local ai-gateway for Docker: ${path.relative(projectRoot, tarball)}`);
return tarball;
}
export function cleanLocalGatewayPackage() {
mkdirSync(dockerLocalGatewayDir, { recursive: true });
for (const entry of readdirSync(dockerLocalGatewayDir, { withFileTypes: true })) {
if (entry.isFile() && entry.name.endsWith(".tgz")) {
unlinkSync(path.join(dockerLocalGatewayDir, entry.name));
}
}
}
function assertGatewaySupportsRouterPlugin(gatewayRoot) {
const gatewayEntry = path.join(gatewayRoot, "dist", "index.js");
if (!existsSync(gatewayEntry)) {
throw new Error(`Local ai-gateway build output was not found: ${gatewayEntry}`);
}
const source = readFileSync(gatewayEntry, "utf8");
const missing = routerPluginSupportMarkers.filter((marker) => !source.includes(marker));
if (missing.length > 0) {
throw new Error(`Local ai-gateway is missing router plugin support markers: ${missing.join(", ")}`);
}
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd ?? projectRoot,
encoding: "utf8",
stdio: options.capture ? "pipe" : "inherit"
});
if (result.status !== 0) {
throw new Error([
`${command} ${args.join(" ")} failed with code ${result.status ?? "unknown"}`,
result.stdout,
result.stderr
].filter(Boolean).join("\n"));
}
return result.stdout?.trim() ?? "";
}
if (import.meta.url === `file://${process.argv[1]}`) {
const command = process.argv[2] ?? "prepare";
if (command === "prepare") {
prepareLocalGatewayPackage({ required: process.argv.includes("--required") });
} else if (command === "clean") {
cleanLocalGatewayPackage();
} else {
throw new Error(`Unknown docker local gateway command: ${command}`);
}
}

View file

@ -28,8 +28,8 @@ export const cliMainOutDir = path.join(cliDistDir, "main");
export const coreMainOutDir = path.join(coreDistDir, "main");
export const electronMainOutDir = path.join(electronDistDir, "main");
export const mainOutDir = electronMainOutDir;
export const gatewayPackageRoot = path.dirname(requireFromHere.resolve("@the-next-ai/ai-gateway/package.json"));
export const gatewayRuntimeInput = path.join(gatewayPackageRoot, "bin", "next-ai-gateway.js");
export const gatewayPackageRoot = resolveGatewayPackageRoot();
export const gatewayRuntimeInput = resolveGatewayRuntimeInput(gatewayPackageRoot);
export const electronGatewayRuntimeOutput = path.join(electronMainOutDir, "next-ai-gateway.js");
export const botGatewaySdkPackageRoot = path.dirname(requireFromHere.resolve("@the-next-ai/bot-gateway-sdk/package.json"));
export const botGatewaySdkEntryInput = path.join(botGatewaySdkPackageRoot, "dist", "index.js");
@ -70,6 +70,7 @@ export const requestLogBodyWorkerOutput = path.join(rendererAssetsDir, "log-body
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 routerPluginInput = path.join(coreSourceRoot, "gateway", "core-runtime", "router-plugin.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;
@ -90,6 +91,91 @@ const nodeExternals = [
...builtinModules.map((moduleName) => `node:${moduleName}`)
];
function resolveGatewayPackageRoot() {
for (const envName of ["CCR_GATEWAY_SOURCE_DIR", "CCR_GATEWAY_PACKAGE_DIR", "CCR_LOCAL_AI_GATEWAY_DIR"]) {
const value = process.env[envName]?.trim();
if (!value) continue;
const root = path.resolve(projectRoot, value);
if (!isGatewayPackageRoot(root)) {
throw new Error(`${envName} must point to an ai-gateway package root: ${root}`);
}
return root;
}
const gatewayEntry = process.env.CCR_GATEWAY_ENTRY?.trim();
if (gatewayEntry) {
const entry = path.resolve(projectRoot, gatewayEntry);
const root = nearestPackageRoot(entry);
if (!root || !isGatewayPackageRoot(root)) {
throw new Error(`CCR_GATEWAY_ENTRY must point inside an ai-gateway package: ${entry}`);
}
return root;
}
const siblingGatewayRoot = path.resolve(projectRoot, "..", "..", "next-ai", "gateway");
if (isGatewayPackageRoot(siblingGatewayRoot)) {
return siblingGatewayRoot;
}
for (const packageName of ["@the-next-ai/ai-gateway", "gateway"]) {
try {
const root = nearestPackageRoot(requireFromHere.resolve(packageName));
if (root && isGatewayPackageRoot(root)) {
return root;
}
} catch {
// Try the next known package name.
}
}
throw new Error("Unable to resolve an installed ai-gateway package.");
}
function resolveGatewayRuntimeInput(packageRoot) {
const manifestFile = path.join(packageRoot, "package.json");
const manifest = JSON.parse(readFileSync(manifestFile, "utf8"));
const binEntry = typeof manifest.bin === "string"
? manifest.bin
: manifest.bin?.["next-ai-gateway"];
const candidate = binEntry
? path.join(packageRoot, binEntry)
: path.join(packageRoot, "bin", "next-ai-gateway.js");
if (!existsSync(candidate)) {
throw new Error(`ai-gateway runtime bin entry was not found: ${candidate}`);
}
return candidate;
}
function nearestPackageRoot(fileOrDirectory) {
let current = fileOrDirectory;
if (!existsSync(path.join(current, "package.json"))) {
current = path.dirname(current);
}
for (let depth = 0; depth < 8; depth += 1) {
if (existsSync(path.join(current, "package.json"))) {
return current;
}
const parent = path.dirname(current);
if (parent === current) {
return undefined;
}
current = parent;
}
return undefined;
}
function isGatewayPackageRoot(candidate) {
const manifestFile = path.join(candidate, "package.json");
if (!existsSync(manifestFile)) {
return false;
}
try {
const manifest = JSON.parse(readFileSync(manifestFile, "utf8"));
return manifest.name === "@the-next-ai/ai-gateway" || manifest.name === "gateway";
} catch {
return false;
}
}
export function cleanDist() {
rmSync(legacyDistDir, { force: true, recursive: true });
rmSync(cliDistDir, { force: true, recursive: true });
@ -238,6 +324,7 @@ export function createMainBuildOptions({ mode = "production", plugins = [] } = {
path.join(coreSourceRoot, "observability", "request-log-worker.ts"),
path.join(coreSourceRoot, "routing", "route-script-worker.ts"),
localAgentAuthProviderHookInput,
routerPluginInput,
upstreamHeaderSanitizerInput,
electronUndiciProxyAgentInput,
path.join(electronSourceRoot, "main", "preload.ts")
@ -263,6 +350,7 @@ export function createCliBuildOptions({ mode = "production", plugins = [] } = {}
entryNames: "[name]",
entryPoints: [
path.join(cliSourceRoot, "cli.ts"),
gatewayRuntimeInput,
path.join(coreSourceRoot, "gateway", "core-runtime", "gateway-bootstrap.ts"),
path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"),
path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"),
@ -271,6 +359,7 @@ export function createCliBuildOptions({ mode = "production", plugins = [] } = {}
path.join(coreSourceRoot, "observability", "request-log-worker.ts"),
path.join(coreSourceRoot, "routing", "route-script-worker.ts"),
localAgentAuthProviderHookInput,
routerPluginInput,
upstreamHeaderSanitizerInput
],
external: nodeExternals.filter((moduleName) => moduleName !== "electron"),
@ -293,6 +382,7 @@ export function createCoreServerBuildOptions({ mode = "production", plugins = []
entryNames: "[name]",
entryPoints: [
path.join(coreSourceRoot, "entrypoints", "server.ts"),
gatewayRuntimeInput,
path.join(coreSourceRoot, "gateway", "core-runtime", "gateway-bootstrap.ts"),
path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"),
path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"),
@ -301,6 +391,7 @@ export function createCoreServerBuildOptions({ mode = "production", plugins = []
path.join(coreSourceRoot, "observability", "request-log-worker.ts"),
path.join(coreSourceRoot, "routing", "route-script-worker.ts"),
localAgentAuthProviderHookInput,
routerPluginInput,
upstreamHeaderSanitizerInput
],
external: nodeExternals.filter((moduleName) => moduleName !== "electron"),

View file

@ -30,6 +30,7 @@ const testProjects = {
"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/router-plugin": path.join(packageRoots.core, "gateway", "core-runtime", "router-plugin.ts"),
"runtime/route-script-worker": path.join(packageRoots.core, "routing", "route-script-worker.ts"),
"runtime/upstream-header-sanitizer": path.join(packageRoots.core, "gateway", "core-runtime", "upstream-header-sanitizer.ts"),
"runtime/toolhub-mcp": path.join(packageRoots.core, "mcp", "toolhub-mcp.ts")

View file

@ -12,11 +12,10 @@ The image is intended for a persistent gateway and browser-based administration.
host:3458 -> container Nginx:8080
|-> static management UI
|-> management RPC: 127.0.0.1:3459
|-> gateway: 127.0.0.1:3456
`-> gateway core: 127.0.0.1:3457
`-> gateway: 127.0.0.1:3456
```
Only Nginx port `8080` should be published. The three internal ports are container implementation details and should not be exposed individually.
Only Nginx port `8080` should be published. The internal management and gateway ports are container implementation details and should not be exposed individually.
Nginx routes:
@ -32,12 +31,14 @@ Nginx routes:
From the repository root:
```sh
docker compose up -d --build
npm run docker:compose:up
docker compose logs -f ccr
```
Open <http://127.0.0.1:3458>. On a new volume, the management UI is immediately available. Add a provider and model, create a CCR client key under **API Keys**, and start the gateway from **Server**.
The npm Compose script prepares a local `../../next-ai/gateway` checkout before building so the image uses that plugin-capable ai-gateway runtime. If you run `docker compose up -d --build` directly, run `npm run docker:prepare-gateway` first. `npm run docker:build` performs the same preparation automatically.
The repository Compose file publishes `3458:8080`, stores data in the `ccr-data` named volume, and restarts the service unless explicitly stopped. A mapping without a host IP binds on every host interface. For local-only access, change it to:
```yaml
@ -59,7 +60,7 @@ Do not add `--volumes` to `docker compose down` unless you intentionally want to
Build and run without Compose:
```sh
docker build -t claude-code-router:local .
npm run docker:build
docker run -d \
--name claude-code-router \
--restart unless-stopped \
@ -69,10 +70,9 @@ docker run -d \
claude-code-router:local
```
Equivalent repository scripts are available:
The temporary run script is also available:
```sh
npm run docker:build
npm run docker:run
```
@ -199,7 +199,6 @@ Most deployments should set only `CCR_WEB_AUTH_TOKEN`, `CCR_PUBLIC_BASE_URL`, an
| `CCR_WEB_PORT` | `3459` | Container-private management server port. |
| `CCR_GATEWAY_HOST` | `127.0.0.1` | Container-private gateway listener host. |
| `CCR_GATEWAY_PORT` | `3456` | Container-private gateway listener port used by Nginx. |
| `CCR_GATEWAY_CORE_PORT` | `3457` | Container-private core gateway runtime port. |
| `CCR_NO_GATEWAY` | `0` | Set to `1`, `true`, or `yes` to run the management UI without starting the gateway at boot. |
| `CCR_DOCKER_INIT_CONFIG` | `1` | Set to `0` to disable minimal first-run `config.json` bootstrap. |
| `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT` | `1` | Set to `0` to stop startup from syncing existing JSON/SQLite listener and public endpoint fields to Docker values. |
@ -242,7 +241,7 @@ This is expected. Nginx redirects the root URL to the management page and URL-en
### `/health` returns `502`
`/health` checks the model gateway, not Nginx or the management UI. On a fresh volume it returns `502` until a provider/model exists and the gateway has started. Use `docker compose ps` for container health and open the UI to configure/start the gateway.
`/health` checks the model gateway, not Nginx or the management UI. On a fresh volume it returns `502` until a provider/model exists and the gateway has started; after startup, the plugin-capable ai-gateway runtime returns `200` with status `ok`. Use `docker compose ps` for container health and open the UI to configure/start the gateway.
### The UI returns `401` after a token change
@ -268,19 +267,21 @@ Container health only verifies Nginx/UI reachability. Check **Server** status, p
## 中文说明
Docker 镜像通过 PM2 运行 CCR Core,并由 Nginx 同时提供管理 UI、管理 RPC、模型网关和健康检查。对外只应发布 Nginx 的容器端口 `8080`;`3459`、`3456`、`3457` 都是容器内部实现端口,不应单独暴露。
Docker 镜像通过 PM2 运行 CCR Core,并由 Nginx 同时提供管理 UI、管理 RPC、模型网关和健康检查。对外只应发布 Nginx 的容器端口 `8080`;`3459` 和 `3456` 是容器内部实现端口,不应单独暴露。
这个镜像面向常驻网关和浏览器管理,不包含 Electron、npm 的 `ccr` 命令、系统托盘、桌面 Agent/App 启动、桌面自动更新和桌面专属浏览器集成。
### 快速启动
```sh
docker compose up -d --build
npm run docker:compose:up
docker compose logs -f ccr
```
打开 <http://127.0.0.1:3458>。首次启动时管理 UI 可以立即访问;添加供应商和模型、在 **API 密钥** 页面创建 CCR 客户端 Key,然后从 **服务** 页面启动网关。
这个 npm Compose 脚本会在构建前准备本机的 `../../next-ai/gateway` checkout,让镜像使用支持插件的 ai-gateway runtime。如果直接运行 `docker compose up -d --build`,请先执行 `npm run docker:prepare-gateway`。`npm run docker:build` 也会自动打包这份本地 ai-gateway。
仓库默认映射是 `3458:8080`,会监听宿主机所有网卡。如果只允许本机访问,请改为:
```yaml

View file

@ -7,7 +7,6 @@ CCR_WEB_PORT="${CCR_WEB_PORT:-3459}"
CCR_NGINX_PORT="${CCR_NGINX_PORT:-8080}"
CCR_GATEWAY_HOST="${CCR_GATEWAY_HOST:-127.0.0.1}"
CCR_GATEWAY_PORT="${CCR_GATEWAY_PORT:-3456}"
CCR_GATEWAY_CORE_PORT="${CCR_GATEWAY_CORE_PORT:-3457}"
CCR_PUBLIC_HOST="${CCR_PUBLIC_HOST:-127.0.0.1}"
CCR_PUBLIC_PORT="${CCR_PUBLIC_PORT:-3458}"
CCR_PUBLIC_BASE_URL="${CCR_PUBLIC_BASE_URL:-http://${CCR_PUBLIC_HOST}:${CCR_PUBLIC_PORT}}"
@ -20,7 +19,6 @@ CCR_WEB_AUTH_TOKEN_QUERY="$(node -e "process.stdout.write(encodeURIComponent(pro
export HOME="${CCR_DATA_DIR}"
export CCR_DATA_DIR
export CCR_GATEWAY_CORE_PORT
export CCR_GATEWAY_HOST
export CCR_GATEWAY_PORT
export CCR_NGINX_PORT
@ -48,7 +46,6 @@ const configDir = path.join(process.env.HOME, ".claude-code-router");
const configFile = path.join(configDir, "config.json");
const gatewayHost = process.env.CCR_GATEWAY_HOST || "0.0.0.0";
const gatewayPort = Number(process.env.CCR_GATEWAY_PORT || "3456");
const gatewayCorePort = Number(process.env.CCR_GATEWAY_CORE_PORT || "3457");
const publicBaseUrl = (process.env.CCR_PUBLIC_BASE_URL || `http://127.0.0.1:${process.env.CCR_PUBLIC_PORT || "3458"}`).replace(/\/+$/, "");
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
@ -56,8 +53,6 @@ fs.writeFileSync(configFile, `${JSON.stringify({
HOST: gatewayHost,
PORT: gatewayPort,
gateway: {
coreHost: "127.0.0.1",
corePort: gatewayCorePort,
enabled: true,
host: gatewayHost,
port: gatewayPort
@ -77,7 +72,6 @@ const configFile = path.join(configDir, "config.json");
const appConfigDbFile = path.join(configDir, "config.sqlite");
const gatewayHost = process.env.CCR_GATEWAY_HOST || "127.0.0.1";
const gatewayPort = Number(process.env.CCR_GATEWAY_PORT || "3456");
const gatewayCorePort = Number(process.env.CCR_GATEWAY_CORE_PORT || "3457");
const publicBaseUrl = (process.env.CCR_PUBLIC_BASE_URL || `http://127.0.0.1:${process.env.CCR_PUBLIC_PORT || "3458"}`).replace(/\/+$/, "");
function syncConfig(value) {
@ -86,10 +80,12 @@ function syncConfig(value) {
}
value.HOST = gatewayHost;
value.PORT = gatewayPort;
const existingGateway = value.gateway && typeof value.gateway === "object" && !Array.isArray(value.gateway)
? value.gateway
: {};
const { coreHost: _coreHost, corePort: _corePort, ...publicGateway } = existingGateway;
value.gateway = {
...(value.gateway && typeof value.gateway === "object" && !Array.isArray(value.gateway) ? value.gateway : {}),
coreHost: "127.0.0.1",
corePort: gatewayCorePort,
...publicGateway,
enabled: true,
host: gatewayHost,
port: gatewayPort

View file

@ -0,0 +1,7 @@
This directory is used by `npm run docker:prepare-gateway`,
`npm run docker:build`, `npm run docker:compose:build`, and
`npm run docker:compose:up`.
Generated `.tgz` files are ignored by Git. They are copied into the Docker
build context so the image can bundle a local plugin-capable ai-gateway runtime
instead of the package-lock dependency.

View file

@ -14,7 +14,7 @@ if (noGateway) {
module.exports = {
apps: [
{
name: "ccr-core-server",
name: "ccr-server",
script: "/app/packages/core/dist/main/server.js",
args: serverArgs,
cwd: "/app",

View file

@ -22,11 +22,10 @@ If the main need is local Agent multi-instance, tray, or desktop app launching,
host 3458 -> container Nginx 8080
|-> static management UI
|-> management RPC: 127.0.0.1:3459
|-> model gateway: 127.0.0.1:3456
`-> core runtime: 127.0.0.1:3457
`-> model gateway: 127.0.0.1:3456
```
Only the Nginx container port `8080` should be published. `3459`, `3456`, and `3457` are container-internal implementation ports; do not map them to the host individually.
Only the Nginx container port `8080` should be published. `3459` and `3456` are container-internal implementation ports; do not map them to the host individually.
Nginx exposes:
@ -42,18 +41,20 @@ Nginx exposes:
From the repository root:
```sh
docker compose up -d --build
npm run docker:compose:up
docker compose logs -f ccr
```
Open <http://127.0.0.1:3458>. On a fresh volume the management UI is available immediately; the model gateway only starts working after a provider and a model have been added.
The npm Compose script prepares a local `../../next-ai/gateway` checkout before building so the image uses that plugin-capable ai-gateway runtime. If you run `docker compose up -d --build` directly, run `npm run docker:prepare-gateway` first. `npm run docker:build` performs the same preparation automatically.
Complete the first-time configuration in this order:
1. Add a provider and at least one model.
2. Create a CCR client key on the **API Keys** page.
3. Start the gateway on the **Server** page.
4. Request `/health` and confirm it returns `200` with a running status.
4. Request `/health` and confirm it returns `200` with status `ok`.
5. Point the client Base URL at `http://127.0.0.1:3458` and use the CCR client key you just created.
Stopping or removing the container does not delete the named volume:
@ -83,7 +84,7 @@ The left side of the port mapping is the host address and port; the right side i
Without Compose:
```sh
docker build -t claude-code-router:local .
npm run docker:build
docker run -d \
--name claude-code-router \
--restart unless-stopped \
@ -93,7 +94,7 @@ docker run -d \
claude-code-router:local
```
The repository also provides `npm run docker:build` and `npm run docker:run`. The latter uses `3458` and `ccr-data`, but the container runs with `--rm` and has no fixed name or restart policy, so it is better suited to temporary verification.
The repository also provides `npm run docker:run`. It uses `3458` and `ccr-data`, but the container runs with `--rm` and has no fixed name or restart policy, so it is better suited to temporary verification.
## Do not mix up the three credentials
@ -217,7 +218,6 @@ A typical deployment only needs `CCR_WEB_AUTH_TOKEN`, `CCR_PUBLIC_BASE_URL`, and
| `CCR_WEB_PORT` | `3459` | Management service port inside the container. |
| `CCR_GATEWAY_HOST` | `127.0.0.1` | Model gateway listen address inside the container. |
| `CCR_GATEWAY_PORT` | `3456` | Model gateway port inside the container that Nginx proxies to. |
| `CCR_GATEWAY_CORE_PORT` | `3457` | Core Gateway Runtime port inside the container. |
| `CCR_NO_GATEWAY` | `0` | When set to `1`, `true`, or `yes`, only the management UI runs during startup. |
| `CCR_DOCKER_INIT_CONFIG` | `1` | Set to `0` to disable the first-boot minimal `config.json` bootstrap. |
| `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT` | `1` | Set to `0` to stop synchronizing existing JSON / SQLite listener and public address fields at startup. |

View file

@ -22,11 +22,10 @@ Docker 镜像适合常驻模型网关和浏览器管理。它包含 CCR Core、
宿主机 3458 -> 容器 Nginx 8080
|-> 静态管理 UI
|-> 管理 RPC:127.0.0.1:3459
|-> 模型网关:127.0.0.1:3456
`-> Core Runtime:127.0.0.1:3457
`-> 模型网关:127.0.0.1:3456
```
只应发布 Nginx 的容器端口 `8080`。`3459`、`3456`、`3457` 都是容器内部实现端口,不要分别映射到宿主机。
只应发布 Nginx 的容器端口 `8080`。`3459` 和 `3456` 是容器内部实现端口,不要分别映射到宿主机。
Nginx 对外提供:
@ -42,18 +41,20 @@ Nginx 对外提供:
在仓库根目录执行:
```sh
docker compose up -d --build
npm run docker:compose:up
docker compose logs -f ccr
```
打开 <http://127.0.0.1:3458>。新数据卷上管理 UI 会立即可用;模型网关要在添加供应商和模型后才能正常启动。
这个 npm Compose 脚本会在构建前准备本机的 `../../next-ai/gateway` checkout,让镜像使用支持插件的 ai-gateway runtime。如果直接运行 `docker compose up -d --build`,请先执行 `npm run docker:prepare-gateway`。`npm run docker:build` 也会自动完成这一步。
首次配置顺序:
1. 添加供应商和至少一个模型。
2. 在 **API 密钥** 页面创建 CCR 客户端 Key。
3. 在 **服务** 页面启动网关。
4. 请求 `/health`,确认返回 `200` 和运行状态。
4. 请求 `/health`,确认返回 `200` 和 `ok` 状态。
5. 把客户端 Base URL 指向 `http://127.0.0.1:3458`,并使用刚创建的 CCR 客户端 Key。
停止或移除容器不会自动删除命名卷:
@ -83,7 +84,7 @@ services:
不使用 Compose 时:
```sh
docker build -t claude-code-router:local .
npm run docker:build
docker run -d \
--name claude-code-router \
--restart unless-stopped \
@ -93,7 +94,7 @@ docker run -d \
claude-code-router:local
```
仓库也提供 `npm run docker:build` 和 `npm run docker:run`。后者使用 `3458` 和 `ccr-data`,但容器带 `--rm`,没有固定名称和自动重启策略,更适合临时验证。
仓库也提供 `npm run docker:run`。它使用 `3458` 和 `ccr-data`,但容器带 `--rm`,没有固定名称和自动重启策略,更适合临时验证。
## 三类凭据不要混用
@ -217,7 +218,6 @@ docker compose logs --tail=200 ccr
| `CCR_WEB_PORT` | `3459` | 管理服务容器内端口。 |
| `CCR_GATEWAY_HOST` | `127.0.0.1` | 模型网关容器内监听地址。 |
| `CCR_GATEWAY_PORT` | `3456` | Nginx 转发到的模型网关容器内端口。 |
| `CCR_GATEWAY_CORE_PORT` | `3457` | Core Gateway Runtime 容器内端口。 |
| `CCR_NO_GATEWAY` | `0` | 设为 `1`、`true` 或 `yes` 时,启动阶段只运行管理 UI。 |
| `CCR_DOCKER_INIT_CONFIG` | `1` | 设为 `0` 时禁用首次最小 `config.json` 引导。 |
| `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT` | `1` | 设为 `0` 时不再在启动时同步已有 JSON / SQLite 的监听和公开地址字段。 |

6
package-lock.json generated
View file

@ -12,7 +12,7 @@
"packages/*"
],
"dependencies": {
"@the-next-ai/ai-gateway": "^1.0.18",
"@the-next-ai/ai-gateway": "^1.0.19",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"electron-updater": "^6.8.9",
@ -9001,7 +9001,7 @@
"version": "3.0.22",
"license": "MIT",
"dependencies": {
"@the-next-ai/ai-gateway": "^1.0.18",
"@the-next-ai/ai-gateway": "^1.0.19",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"node-forge": "^1.4.0",
@ -9018,7 +9018,7 @@
"name": "@claude-code-router/core",
"version": "3.0.22",
"dependencies": {
"@the-next-ai/ai-gateway": "^1.0.18",
"@the-next-ai/ai-gateway": "^1.0.19",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"node-forge": "^1.4.0",

View file

@ -52,7 +52,11 @@
"bench:context-archive-agent": "node benchmarks/run-context-archive-real-agent-benchmark.mjs",
"bench:context-archive-real": "node benchmarks/run-context-archive-real-agent-benchmark.mjs",
"bench:claude-compact-real": "node benchmarks/claude-code-real-compact.mjs",
"docker:build": "docker build -t claude-code-router:local .",
"docker:prepare-gateway": "node build/docker-local-gateway.mjs prepare",
"docker:clean-gateway": "node build/docker-local-gateway.mjs clean",
"docker:build": "node build/docker-image.mjs",
"docker:compose:build": "node build/docker-compose.mjs build",
"docker:compose:up": "node build/docker-compose.mjs up -d --build",
"docker:run": "docker run --rm -p 3458:8080 -v ccr-data:/data claude-code-router:local",
"test": "npm run test:packages && npm run test:architecture",
"test:packages": "npm run test --workspaces --if-present",
@ -72,7 +76,7 @@
"rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3"
},
"dependencies": {
"@the-next-ai/ai-gateway": "^1.0.18",
"@the-next-ai/ai-gateway": "^1.0.19",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"electron-updater": "^6.8.9",

View file

@ -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.18",
"@the-next-ai/ai-gateway": "^1.0.19",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"node-forge": "^1.4.0",

View file

@ -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.18",
"@the-next-ai/ai-gateway": "^1.0.19",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"node-forge": "^1.4.0",

View file

@ -717,15 +717,13 @@ async function writeSanitizedConfig(config: AppConfig): Promise<void> {
await replacePersistedAppConfig(sanitizeConfigForDisk(config));
}
function sanitizeConfigForDisk(config: AppConfig): AppConfig {
function sanitizeConfigForDisk(config: AppConfig): Record<string, unknown> {
const { coreHost: _coreHost, corePort: _corePort, ...gateway } = config.gateway;
return {
...config,
APIKEY: "",
APIKEYS: [],
gateway: {
...config.gateway,
coreHost: INTERNAL_GATEWAY_CORE_HOST
},
gateway,
Providers: withProviderIds(config.Providers),
profile: sanitizeProfileConfigForDisk(config.profile)
};
@ -3435,17 +3433,19 @@ function parseGatewayPluginCoreGateway(value: unknown): GatewayPluginConfig["cor
return undefined;
}
const providerPlugins = Array.isArray(value.providerPlugins) ? value.providerPlugins : undefined;
const plugins = Array.isArray(value.plugins) ? value.plugins : undefined;
const virtualModelProfiles = Array.isArray(value.virtualModelProfiles)
? value.virtualModelProfiles as NonNullable<GatewayPluginConfig["coreGateway"]>["virtualModelProfiles"]
: undefined;
const config = isObject(value.config) ? { ...(value.config as Record<string, unknown>) } : undefined;
if (!providerPlugins && !virtualModelProfiles && !config) {
if (!providerPlugins && !plugins && !virtualModelProfiles && !config) {
return undefined;
}
return {
...(config ? { config } : {}),
...(plugins ? { plugins } : {}),
...(providerPlugins ? { providerPlugins } : {}),
...(virtualModelProfiles ? { virtualModelProfiles } : {})
};
@ -3467,6 +3467,10 @@ function parseProfile(value: unknown): LoadedProfileConfig | undefined {
if (typeof claudeCode.enabled === "boolean") {
profile.claudeCode.enabled = claudeCode.enabled;
}
const claudeSettings = parseUnknownRecord(claudeCode.claudeSettings ?? claudeCode.claude_settings ?? claudeCode.settings);
if (claudeSettings) {
profile.claudeCode.claudeSettings = claudeSettings;
}
const managedCompact = readManagedCompact(claudeCode);
if (managedCompact !== undefined) {
profile.claudeCode.managedCompact = managedCompact;
@ -3622,11 +3626,13 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined {
if (agent === "claude-code") {
const appPath = readProfileAppPath(item, agent);
const claudeSettings = parseUnknownRecord(item.claudeSettings ?? item.claude_settings);
return {
agent,
...(appPath ? { appPath } : {}),
...(botConfigId ? { botConfigId } : {}),
...(botGateway ? { botGateway } : {}),
...(claudeSettings ? { claudeSettings } : {}),
enabled,
env: claudeCodeProfileEnv(env),
fableModel: readString(item.fableModel) || readString(item.defaultFableModel) || "",
@ -3872,6 +3878,7 @@ function normalizeCodexConfigFileForAgent(agent: ProfileConfig["agent"], value:
function profileFromClaudeCodeConfig(config: ClaudeCodeProfileConfig): ProfileConfig {
return {
agent: "claude-code",
...(config.claudeSettings ? { claudeSettings: { ...config.claudeSettings } } : {}),
enabled: config.enabled,
env: claudeCodeProfileEnv(),
fableModel: config.fableModel,

View file

@ -874,6 +874,7 @@ export const GATEWAY_PLUGIN_PERMISSION_IDS = [
"provider-account-connectors",
"gateway-request-transforms",
"core-gateway-config",
"core-gateway-plugins",
"core-provider-plugins",
"virtual-model-profiles",
"sqlite-store",
@ -1205,6 +1206,7 @@ export type GatewayPluginConfig = {
config?: unknown;
coreGateway?: {
config?: Record<string, unknown>;
plugins?: unknown[];
providerPlugins?: unknown[];
virtualModelProfiles?: VirtualModelProfileConfig[];
};
@ -1455,6 +1457,7 @@ export type ProfileSurface = "auto" | "cli" | "app";
export type ProfileOpenSurface = "cli" | "app";
export type ClaudeCodeProfileConfig = {
claudeSettings?: Record<string, unknown>;
enabled: boolean;
fableModel: string;
haikuModel: string;
@ -1489,6 +1492,7 @@ export type ProfileConfig = {
botGateway?: BotGatewayRuntimeConfig;
configFile?: string;
cliMiddleware?: boolean;
claudeSettings?: Record<string, unknown>;
codexCliPath?: string;
codexHome?: string;
configFormat?: CodexProfileConfigFormat;

View file

@ -17,9 +17,10 @@ import { isAddressInUseMessage, probeExistingCcrGateway, reloadExistingCcrGatewa
import { closeServer, formatError } from "@ccr/core/gateway/http/io";
import { RawTraceSynchronizer } from "@ccr/core/observability/raw-trace-sync";
import { GatewayBillingSynchronizer } from "@ccr/core/usage/billing-sync";
import { assertLoopbackCoreHost, endpoint, formatCoreGatewayChildExit, gatewayNetworkEndpoints, generateCoreGatewayAuthToken, isCoreGatewayHealthy, loopbackCoreHostError, removeManagedCoreGatewayMarker, shouldRunGatewayRuntime, shouldRunUnifiedServer, spawnGatewayProcess, stopPreviousManagedCoreGateway, waitForCoreGatewayStop, waitForManagedCoreGatewayReady, writeManagedCoreGatewayMarker } from "@ccr/core/gateway/core-runtime/supervisor";
import { assertLoopbackCoreHost, endpoint, formatCoreGatewayChildExit, gatewayNetworkEndpoints, gatewayRuntimeSupportsRouterPlugin, generateCoreGatewayAuthToken, isCoreGatewayHealthy, loopbackCoreHostError, removeManagedCoreGatewayMarker, shouldRunGatewayRuntime, shouldRunUnifiedServer, spawnGatewayProcess, stopPreviousManagedCoreGateway, waitForCoreGatewayStop, waitForManagedCoreGatewayReady, writeManagedCoreGatewayMarker } from "@ccr/core/gateway/core-runtime/supervisor";
import { coreGatewayAuthHeader } from "@ccr/core/gateway/internal/shared";
import type { BrowserAutomationMcpIntegration, BrowserWebSearchMcpIntegration, GatewayStopOptions } from "@ccr/core/gateway/internal/shared";
import { ccrRuntimeConfigReloadMessageType } from "@ccr/core/gateway/core-runtime/router-plugin-contract";
import { GatewayRequestPipeline } from "@ccr/core/gateway/request/pipeline";
import { GatewayHttpRequestHandler } from "@ccr/core/gateway/http/request-handler";
import { gatewayRuntimeConfigRevision } from "@ccr/core/gateway/runtime-config-control";
@ -30,9 +31,18 @@ import { compileRouterConfig } from "@ccr/core/routing/config-compiler";
import { normalizeRouteScriptResult, scriptResultPreview } from "@ccr/core/routing/route-script-result";
import { mediaService } from "@ccr/core/media/service";
import { mediaToolsGatewayEndpoint } from "@ccr/core/mcp/grok-media-config";
import { browserAutomationMcpEnabled } from "@ccr/core/mcp/toolhub-config";
import { installSocketTypeOfServiceCompat } from "@ccr/core/platform/socket-compat";
import { profileApiKeyId } from "@ccr/core/profiles/api-key";
installSocketTypeOfServiceCompat();
type RouteScriptTestHeaders = Record<string, string | string[] | undefined>;
type SingleGatewayRuntimeBlockerOptions = {
gatewayRuntimeSupportsRouterPlugin?: () => boolean;
hasGatewayRequestTransforms?: () => boolean;
hasGatewayRoutes?: () => boolean;
};
function routeScriptTestProfileId(
config: AppConfig,
@ -103,6 +113,10 @@ class GatewayService {
private externalGatewayApiKey?: string;
private plugin?: ClaudeCodeRouterPlugin;
private readonly rawTraceSynchronizer = new RawTraceSynchronizer({
allowStandaloneRequestLogs: () => {
const config = this.config;
return Boolean(config && shouldUseSingleGatewayRuntime(config));
},
getConfig: () => this.config
});
private readonly routeScriptRuntime = new RouteScriptRuntime();
@ -150,18 +164,29 @@ class GatewayService {
};
try {
await pluginService.start(config);
const singleGatewayRuntime = shouldUseSingleGatewayRuntime(config);
if (!singleGatewayRuntime && shouldRunGatewayRuntime(config) && config.gateway.enabled) {
console.info(`[gateway] Using compatibility gateway server: ${singleGatewayRuntimeBlockers(config).join(", ") || "unknown blocker"}`);
}
const runtimeHost = singleGatewayRuntime ? config.gateway.host : config.gateway.coreHost;
const runtimePort = singleGatewayRuntime ? config.gateway.port : config.gateway.corePort;
this.status = {
...this.status,
coreEndpoint: endpoint(runtimeHost, runtimePort)
};
mediaService.start(config, mediaToolsGatewayEndpoint(config), {
authHeader: coreGatewayAuthHeader,
authToken: coreAuthToken,
baseUrl: endpoint(config.gateway.coreHost, config.gateway.corePort)
baseUrl: this.status.coreEndpoint
});
await pluginService.start(config);
const shouldRunServer = shouldRunUnifiedServer(config) || pluginService.hasGatewayRoutes() || config.mediaTools.enabled;
const shouldRunServer = !singleGatewayRuntime &&
(shouldRunUnifiedServer(config) || pluginService.hasGatewayRoutes() || config.mediaTools.enabled);
const shouldRunGateway = shouldRunGatewayRuntime(config);
if (shouldRunGateway && !hasAvailableGatewayModels(config)) {
throw new Error(NO_AVAILABLE_GATEWAY_MODELS_MESSAGE);
}
if (!shouldRunServer) {
if (!shouldRunServer && !shouldRunGateway) {
await mediaService.stop();
await pluginService.stop();
await backendService.stopAll();
@ -174,7 +199,9 @@ class GatewayService {
}
await this.rawTraceSynchronizer.start();
await this.listen(config);
if (shouldRunServer) {
await this.listen(config);
}
if (this.server) {
const proxyStatus = await proxyService.attach(config, this.server);
if (proxyStatus.state === "error" && !config.gateway.enabled) {
@ -191,19 +218,32 @@ class GatewayService {
this.billingSynchronizer.token,
coreAuthToken,
this.browserWebSearchMcpIntegration,
upstreamProxyUrl
upstreamProxyUrl,
{
publicGatewayMode: singleGatewayRuntime,
runtimeHost,
runtimePort
}
);
await stopPreviousManagedCoreGateway(this.status.coreEndpoint);
if (await isCoreGatewayHealthy(this.status.coreEndpoint)) {
throw new Error(`Core gateway endpoint is already in use: ${this.status.coreEndpoint}`);
}
const runtimeId = randomUUID();
const spawnedGateway = spawnGatewayProcess(config, coreGatewayConfig, upstreamProxyUrl, runtimeId, coreAuthToken);
const spawnedGateway = spawnGatewayProcess(
withCoreRuntimeEndpoint(config, runtimeHost, runtimePort),
coreGatewayConfig,
upstreamProxyUrl,
runtimeId,
coreAuthToken
);
this.child = spawnedGateway.child;
this.coreAuthToken = coreAuthToken;
const managedChild = this.child;
const onChildMessage = (message: unknown) => this.handleCoreGatewayMessage(managedChild, message);
let markerWritePromise: Promise<void> | undefined;
let startupFailure: Error | undefined;
this.child.on("message", onChildMessage);
this.child.stdout?.on("data", (chunk) => console.info(`[gateway] ${chunk.toString().trimEnd()}`));
this.child.stderr?.on("data", (chunk) => console.warn(`[gateway] ${chunk.toString().trimEnd()}`));
this.child.once("error", (error) => {
@ -211,6 +251,7 @@ class GatewayService {
void this.handleCoreGatewayTermination(managedChild, markerWritePromise, startupFailure.message);
});
this.child.once("exit", (code, signal) => {
managedChild.off("message", onChildMessage);
startupFailure ??= new Error(formatCoreGatewayChildExit(managedChild, code, signal));
void this.handleCoreGatewayTermination(managedChild, markerWritePromise, startupFailure.message);
});
@ -349,6 +390,11 @@ class GatewayService {
await this.reloadExternalGatewayConfig(config, false);
return;
}
if (this.config && this.status.state === "running" &&
shouldRestartGatewayForRuntimeConfigChange(this.config, config)) {
await this.start(config);
return;
}
const scriptValidationErrors = await this.routeScriptRuntime.prepare(config.Router.rules);
const nextPlugin = new ClaudeCodeRouterPlugin(config, {
@ -357,15 +403,19 @@ class GatewayService {
});
this.config = config;
this.plugin = nextPlugin;
const singleGatewayRuntime = Boolean(this.child && !this.server && shouldRunGatewayRuntime(config));
const runtimeCoreEndpoint = singleGatewayRuntime
? endpoint(config.gateway.host, config.gateway.port)
: endpoint(config.gateway.coreHost, config.gateway.corePort);
mediaService.updateConfig(config, mediaToolsGatewayEndpoint(config), {
authHeader: coreGatewayAuthHeader,
authToken: this.coreAuthToken,
baseUrl: endpoint(config.gateway.coreHost, config.gateway.corePort)
baseUrl: runtimeCoreEndpoint
});
proxyService.updateConfig(config);
this.status = {
...this.status,
coreEndpoint: endpoint(config.gateway.coreHost, config.gateway.corePort),
coreEndpoint: runtimeCoreEndpoint,
endpoint: endpoint(config.gateway.host, config.gateway.port),
gatewayManagedExternally: undefined,
networkEndpoints: gatewayNetworkEndpoints(config.gateway.host, config.gateway.port)
@ -479,6 +529,13 @@ class GatewayService {
};
}
private handleCoreGatewayMessage(child: ChildProcess, message: unknown): void {
if (this.child !== child || !isRuntimeConfigReloadMessage(message)) {
return;
}
this.schedulePersistedRuntimeConfigReload(message.configRevision, message.forceRestart === true);
}
private async handleRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
return this.requestHandler.handleRequest(request, response);
}
@ -569,5 +626,102 @@ async function removeManagedCoreGatewayMarkerBestEffort(): Promise<void> {
});
}
function shouldUseSingleGatewayRuntime(config: AppConfig): boolean {
return singleGatewayRuntimeBlockers(config).length === 0;
}
function singleGatewayRuntimeBlockers(config: AppConfig, options: SingleGatewayRuntimeBlockerOptions = {}): string[] {
const blockers: string[] = [];
if (!shouldRunGatewayRuntime(config)) blockers.push("gateway-runtime-disabled");
if (!config.gateway.enabled) blockers.push("public-gateway-disabled");
if (!(options.gatewayRuntimeSupportsRouterPlugin ?? gatewayRuntimeSupportsRouterPlugin)()) {
blockers.push("core-runtime-missing-router-plugin-support");
}
blockers.push(...unifiedServerFallbackFeatureBlockers(config));
if (hasActiveRouterFallback(config)) blockers.push("router-fallback");
if (config.mediaTools.enabled) blockers.push("media-tools");
if (browserAutomationMcpEnabled(config)) blockers.push("browser-automation-mcp");
if ((options.hasGatewayRoutes ?? (() => pluginService.hasGatewayRoutes()))()) blockers.push("plugin-gateway-routes");
if ((options.hasGatewayRequestTransforms ?? (() => pluginService.hasGatewayRequestTransforms({ includeBuiltIns: false })))()) {
blockers.push("plugin-gateway-request-transforms");
}
if (hasApiKeyLimits(config)) blockers.push("api-key-limits");
return blockers;
}
function unifiedServerFallbackFeatureBlockers(config: AppConfig): string[] {
return [
config.proxy.enabled ? "proxy" : undefined,
config.proxy.captureNetwork ? "network-capture" : undefined,
config.contextArchive.enabled ? "context-archive" : undefined
].filter((blocker): blocker is string => Boolean(blocker));
}
function hasActiveRouterFallback(config: AppConfig): boolean {
return routerFallbackRequiresWrapper(config.Router.fallback) ||
(config.Router.rules ?? []).some((rule) =>
rule.enabled && routerFallbackRequiresWrapper(rule.fallback)
) ||
(config.profile?.enabled !== false && (config.profile?.profiles ?? []).some((profile) =>
profile.enabled &&
profile.routing?.enabled !== false &&
(profile.routing?.rules ?? []).some((rule) =>
rule.enabled && routerFallbackRequiresWrapper(rule.fallback)
)
));
}
function routerFallbackRequiresWrapper(fallback: AppConfig["Router"]["fallback"] | undefined): boolean {
if (!fallback || fallback.mode === "off") {
return false;
}
if (fallback.mode === "retry") {
return fallback.retryCount > 0;
}
return fallback.models.some((model) => model.trim());
}
function hasApiKeyLimits(config: AppConfig): boolean {
return configuredApiKeysFromConfig(config).some((apiKey) =>
apiKey.limits && Object.keys(apiKey.limits).length > 0
);
}
function configuredApiKeysFromConfig(config: AppConfig): ApiKeyConfig[] {
return [
...(Array.isArray(config.APIKEYS) ? config.APIKEYS : []),
...(config.APIKEY ? [{ createdAt: new Date(0).toISOString(), id: "legacy", key: config.APIKEY }] : [])
];
}
function isRuntimeConfigReloadMessage(message: unknown): message is { configRevision: string; forceRestart?: boolean } {
if (!message || typeof message !== "object" || Array.isArray(message)) {
return false;
}
const record = message as Record<string, unknown>;
return record.type === ccrRuntimeConfigReloadMessageType &&
record.protocolVersion === 1 &&
typeof record.configRevision === "string" &&
/^[a-f0-9]{64}$/i.test(record.configRevision) &&
(record.forceRestart === undefined || typeof record.forceRestart === "boolean");
}
function withCoreRuntimeEndpoint(config: AppConfig, host: string, port: number): AppConfig {
return {
...config,
gateway: {
...config.gateway,
coreHost: host,
corePort: port
}
};
}
export const gatewayService = new GatewayService();
export function singleGatewayRuntimeBlockersForTest(
config: AppConfig,
options: SingleGatewayRuntimeBlockerOptions = {}
): string[] {
return singleGatewayRuntimeBlockers(config, options);
}

View file

@ -1,5 +1,5 @@
import { timingSafeEqual } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from "node:http";
import type { ApiKeyConfig, AppConfig } from "@ccr/core/contracts/app";
import { loadPersistedApiKeys } from "@ccr/core/config/config-repository";
import { formatError, readAuthToken, readRemoteControlQueryAuthToken, readRequestBody, sendJson } from "@ccr/core/gateway/http/io";
@ -12,6 +12,11 @@ const claudeCodeWifTokenExpiresInSeconds = 3600;
const persistedApiKeyCacheTtlMs = 1000;
let persistedApiKeyCache: { loadedAt: number; values: ApiKeyConfig[] } | undefined;
type ConfiguredApiKeyOptions = {
includePersisted?: boolean;
refresh?: boolean;
};
export async function authorize(
request: IncomingMessage,
response: ServerResponse,
@ -45,6 +50,25 @@ export async function authorize(
return { ok: false };
}
export async function resolveApiKeyFromHeaders(
headers: IncomingHttpHeaders,
config: AppConfig,
options: ConfiguredApiKeyOptions = {}
): Promise<ApiKeyConfig | undefined> {
const token = readAuthToken(headers);
if (!token) {
return undefined;
}
let apiKeys = await configuredApiKeys(config, options);
let apiKey = findApiKeyByToken(apiKeys, token);
if (!apiKey && !options.refresh) {
apiKeys = await configuredApiKeys(config, { ...options, refresh: true });
apiKey = findApiKeyByToken(apiKeys, token);
}
return apiKey && !isApiKeyExpired(apiKey) ? apiKey : undefined;
}
export async function handleClaudeCodeWifTokenRequest(
request: IncomingMessage,
response: ServerResponse,
@ -56,7 +80,8 @@ export async function handleClaudeCodeWifTokenRequest(
export async function exchangeClaudeCodeWifToken(
config: AppConfig,
requestBody: Buffer
requestBody: Buffer,
options: ConfiguredApiKeyOptions = {}
): Promise<{ payload: Record<string, unknown>; statusCode: number }> {
const body = parseClaudeCodeWifTokenRequestBody(requestBody);
const grantType = stringField(body.grant_type);
@ -69,10 +94,10 @@ export async function exchangeClaudeCodeWifToken(
return oauthTokenError(400, "invalid_request", "Claude Code WIF assertion is missing.");
}
let apiKeys = await configuredApiKeys(config);
let apiKeys = await configuredApiKeys(config, options);
let apiKey = findApiKeyByToken(apiKeys, assertion);
if (!apiKey) {
apiKeys = await configuredApiKeys(config, { refresh: true });
apiKeys = await configuredApiKeys(config, { ...options, refresh: true });
apiKey = findApiKeyByToken(apiKeys, assertion);
}
if (!apiKey || isApiKeyExpired(apiKey)) {
@ -136,8 +161,10 @@ export function reserveApiKeyLimits(
return true;
}
async function configuredApiKeys(config: AppConfig, options: { refresh?: boolean } = {}): Promise<ApiKeyConfig[]> {
const persistedApiKeys = await loadPersistedApiKeysCached(options);
export async function configuredApiKeys(config: AppConfig, options: ConfiguredApiKeyOptions = {}): Promise<ApiKeyConfig[]> {
const persistedApiKeys = options.includePersisted === false
? []
: await loadPersistedApiKeysCached(options);
const values = [
...persistedApiKeys,
...(Array.isArray(config.APIKEYS) ? config.APIKEYS : []),

View file

@ -13,19 +13,32 @@ 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, resolveLocalAgentAuthProviderHookEntry, resolveUndiciProxyAgentModule, resolveUpstreamHeaderSanitizerEntry, writeGatewayProxyPreloadFile } from "@ccr/core/gateway/core-runtime/supervisor";
import { endpoint, gatewayRuntimeSupportsRouterPlugin, resolveLocalAgentAuthProviderHookEntry, resolveRouterPluginEntry, 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 { ccrRouterPluginKey } from "@ccr/core/gateway/core-runtime/router-plugin-contract";
import { resolveConfiguredProviderModelSelector, resolveUniqueConfiguredProviderModelSelector } from "@ccr/core/routing/model-resolution";
const upstreamHeaderSanitizerPluginKey = "ccr-upstream-header-sanitizer";
const localAgentAuthProviderHookPluginKey = "ccr-local-agent-auth-provider-hooks";
const internalGatewayPluginKeys = [
ccrRouterPluginKey,
localAgentAuthProviderHookPluginKey,
upstreamHeaderSanitizerPluginKey
];
export const unlimitedVirtualModelToolCalls = Number.MAX_SAFE_INTEGER;
export const unlimitedVirtualModelToolTurns = Number.MAX_SAFE_INTEGER;
export type CoreGatewayCompileOptions = {
publicAuthKeys?: string[];
publicGatewayMode?: boolean;
runtimeHost?: string;
runtimePort?: number;
};
export async function compileCoreGatewayConfig(
config: AppConfig,
@ -33,13 +46,14 @@ export async function compileCoreGatewayConfig(
billingUsageSyncToken: string,
coreAuthToken: string,
browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration,
upstreamProxyUrl?: string
upstreamProxyUrl?: string,
options: CoreGatewayCompileOptions = {}
): Promise<Record<string, unknown>> {
const pluginCoreGatewayConfig = pluginService.getCoreGatewayConfig();
const configuredGatewayPlugins = Array.isArray(pluginCoreGatewayConfig.plugins)
? pluginCoreGatewayConfig.plugins.filter((plugin) =>
!isRecord(plugin) ||
![localAgentAuthProviderHookPluginKey, upstreamHeaderSanitizerPluginKey].includes(stringValue(plugin.key) ?? "")
!internalGatewayPluginKeys.includes(stringValue(plugin.key) ?? "")
)
: [];
const pluginBillingConfig = isRecord(pluginCoreGatewayConfig.billing) ? pluginCoreGatewayConfig.billing : {};
@ -61,7 +75,9 @@ export async function compileCoreGatewayConfig(
const providerPlugins = normalizeCoreProviderPluginNames(providerPluginsWithRuntimeDefaults, enabledProviders);
const providerPluginsWithCapabilityAliases = withProviderCapabilityPluginAliases(providerPlugins, enabledProviders);
const virtualModelProfiles = coreGatewayVirtualModelProfiles(config);
const coreEndpoint = endpoint(config.gateway.coreHost, config.gateway.corePort);
const runtimeHost = options.runtimeHost ?? config.gateway.coreHost;
const runtimePort = options.runtimePort ?? config.gateway.corePort;
const coreEndpoint = endpoint(runtimeHost, runtimePort);
const proxyPreloadFile = upstreamProxyUrl ? writeGatewayProxyPreloadFile() : undefined;
const proxyEnv = upstreamProxyUrl
? { CCR_UPSTREAM_PROXY_URL: upstreamProxyUrl, CCR_UNDICI_MODULE: resolveUndiciProxyAgentModule() }
@ -85,6 +101,7 @@ export async function compileCoreGatewayConfig(
.filter((provider): provider is CoreGatewayProvider => Boolean(provider)),
...builtinToolArtifacts.providers
];
const routerPlugin = routerPluginConfig(config, options, coreAuthToken);
const localAgentAuthProviderHookPlugin = localAgentAuthProviderHookPluginConfig(providerPluginsWithCapabilityAliases);
const pluginAgentConfig = isRecord(pluginCoreGatewayConfig.agent) ? pluginCoreGatewayConfig.agent : {};
const pluginMcpServers = Array.isArray(pluginAgentConfig.mcpServers) ? pluginAgentConfig.mcpServers : [];
@ -117,8 +134,8 @@ export async function compileCoreGatewayConfig(
staticApiKeys: {
keyBearerOnly: false,
keyEnv: coreGatewayAuthTokenEnv,
keyHeader: coreGatewayAuthHeader,
keys: [coreAuthToken]
keyHeader: options.publicGatewayMode ? "authorization" : coreGatewayAuthHeader,
keys: coreGatewayStaticAuthKeys(config, coreAuthToken, options)
}
},
billing: {
@ -135,12 +152,13 @@ export async function compileCoreGatewayConfig(
// JSON data URLs, so the internal gateway must accept the complete media
// request even though normal chat payloads are much smaller.
bodyLimitBytes: 256 * 1024 * 1024,
host: config.gateway.coreHost,
host: runtimeHost,
mcpGateway: {
enabled: false
},
port: config.gateway.corePort,
port: runtimePort,
plugins: [
...(routerPlugin ? [routerPlugin] : []),
...configuredGatewayPlugins,
...(localAgentAuthProviderHookPlugin ? [localAgentAuthProviderHookPlugin] : []),
{
@ -154,13 +172,52 @@ export async function compileCoreGatewayConfig(
...pluginAgentConfig,
mcpServers
},
rawTrace: buildRawTraceConfig(config, rawTraceSyncToken),
rawTrace: buildRawTraceConfig(config, rawTraceSyncToken, {
standaloneUsageCapture: options.publicGatewayMode === true
}),
providerPlugins: providerPluginsWithCapabilityAliases,
providers,
virtualModelProfiles
};
}
function routerPluginConfig(
config: AppConfig,
options: CoreGatewayCompileOptions = {},
coreAuthToken: string
): Record<string, unknown> | undefined {
if (!gatewayRuntimeSupportsRouterPlugin()) {
return undefined;
}
return {
config: {
appConfig: config,
coreAuthToken,
publicAuthKeys: options.publicAuthKeys,
publicGatewayMode: options.publicGatewayMode === true
},
enabled: true,
key: ccrRouterPluginKey,
modulePath: resolveRouterPluginEntry()
};
}
function coreGatewayStaticAuthKeys(
config: AppConfig,
coreAuthToken: string,
options: CoreGatewayCompileOptions
): string[] {
if (!options.publicGatewayMode) {
return [coreAuthToken];
}
return uniqueStrings([
coreAuthToken,
...(options.publicAuthKeys ?? []),
...(config.APIKEYS ?? []).map((apiKey) => apiKey.key),
config.APIKEY
].map((key) => key?.trim()).filter((key): key is string => Boolean(key)));
}
function localAgentAuthProviderHookPluginConfig(providerPlugins: unknown[]): Record<string, unknown> | undefined {
if (!providerPlugins.some(isLocalAgentOauthProviderPlugin)) {
return undefined;

View file

@ -0,0 +1,90 @@
import type { RouterFallbackConfig } from "@ccr/core/contracts/app";
export const ccrRouterPluginKey = "ccr-router";
export const ccrRouterRequestTransformKey = "ccr-router-request-transform";
export const ccrCodexBridgeRequestTransformKey = "ccr-codex-bridge-request-transform";
export const ccrCodexBridgeResponseHookKey = "ccr-codex-bridge-response-hook";
export const ccrCodexBridgeStreamHookKey = "ccr-codex-bridge-stream-hook";
export const ccrOpenRouterDiscountFinalizeResponseHookKey = "ccr-openrouter-discount-finalize-response-hook";
export const ccrOpenRouterDiscountFinalizeStreamHookKey = "ccr-openrouter-discount-finalize-stream-hook";
export const ccrRouterRouteResolverKey = "ccr-router-route-resolver";
export const ccrRouterHttpRouteKey = "ccr-router-route";
export const ccrRouterHttpRoutePath = "/__ccr/route";
export const ccrRawTraceSyncAckRouteKey = "ccr-raw-trace-sync-ack";
export const ccrRuntimeConfigReloadMessageType = "ccr:runtime-config-reload";
export const ccrRouteStageHeader = "x-ccr-route-stage";
export const ccrRouteReasonHeader = "x-ccr-route-reason";
export const ccrRouteSourceHeader = "x-ccr-route-source";
export const ccrRouteDiagnosticsHeader = "x-ccr-route-diagnostics";
export const ccrRoutedModelHeader = "x-ccr-routed-model";
export const ccrRouteFallbackHeader = "x-ccr-route-fallback";
export const ccrRouteSessionIdHeader = "x-ccr-route-session-id";
export const ccrRouteTokenCountHeader = "x-ccr-route-token-count";
export const ccrCodexApplyPatchBridgeHeader = "x-ccr-codex-apply-patch-bridge";
export const ccrCodexMultiAgentBridgeHeader = "x-ccr-codex-multi-agent-bridge";
export const ccrOpenRouterDiscountRequestIdHeader = "x-ccr-openrouter-discount-request-id";
export const ccrRouteHeaderNames = [
ccrCodexApplyPatchBridgeHeader,
ccrCodexMultiAgentBridgeHeader,
ccrOpenRouterDiscountRequestIdHeader,
ccrRouteDiagnosticsHeader,
ccrRouteFallbackHeader,
ccrRouteReasonHeader,
ccrRouteSessionIdHeader,
ccrRouteSourceHeader,
ccrRouteStageHeader,
ccrRouteTokenCountHeader,
ccrRoutedModelHeader
] as const;
export type CcrRouterPluginRouteRequest = {
body: Record<string, unknown>;
headers?: Record<string, string | string[] | undefined>;
method?: string;
path?: string;
url?: string;
};
export type CcrRouterPluginRouteResponse = {
body: Record<string, unknown>;
decision: {
diagnostics: unknown[];
fallback: RouterFallbackConfig;
model?: string;
reason: string;
sessionId?: string;
source: string;
tokenCount: number;
};
};
export function encodeCcrRouteFallbackHeader(fallback: RouterFallbackConfig): string {
return Buffer.from(JSON.stringify(fallback), "utf8").toString("base64url");
}
export function decodeCcrRouteFallbackHeader(value: string | undefined): RouterFallbackConfig | undefined {
if (!value) {
return undefined;
}
try {
const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as unknown;
return isRouterFallbackConfig(parsed) ? parsed : undefined;
} catch {
return undefined;
}
}
function isRouterFallbackConfig(value: unknown): value is RouterFallbackConfig {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const candidate = value as RouterFallbackConfig;
return (
(candidate.mode === "off" || candidate.mode === "retry" || candidate.mode === "model-chain") &&
Array.isArray(candidate.models) &&
candidate.models.every((model) => typeof model === "string") &&
typeof candidate.retryCount === "number" &&
Number.isFinite(candidate.retryCount)
);
}

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,7 @@ import { randomBytes } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { networkInterfaces } from "node:os";
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { delimiter as pathDelimiter, join as pathJoin, resolve as pathResolve } from "node:path";
import { delimiter as pathDelimiter, dirname as pathDirname, join as pathJoin, resolve as pathResolve } from "node:path";
import { CONFIGDIR } from "@ccr/core/config/constants";
import {
deletePersistedRuntimeState,
@ -15,6 +15,7 @@ import {
} from "@ccr/core/config/config-repository";
import type { AppConfig, GatewayNetworkEndpoint } from "@ccr/core/contracts/app";
import { fetchWithSystemProxy } from "@ccr/core/proxy/system-proxy-fetch";
import { uniqueStrings } from "@ccr/core/gateway/internal/collections";
import { isRecord, numberValue, stringValue } from "@ccr/core/gateway/internal/value";
import { formatError, readHeader } from "@ccr/core/gateway/http/io";
import { coreGatewayAuthHeader, coreGatewayAuthTokenEnv, gatewayEntryOverrideEnv, gatewayPackageCandidates, gatewayRuntimeMarkerFile, requireFromHere } from "@ccr/core/gateway/internal/shared";
@ -27,6 +28,11 @@ const gatewayStartupTimeoutMs = 15_000;
const gatewayChildOutputLimit = 4000;
const privateDirMode = 0o700;
const privateFileMode = 0o600;
const routerPluginSupportMarkers = [
"requestTransforms",
"routeResolvers",
"httpRoutes"
];
const gatewayChildOutput = new WeakMap<ChildProcess, { stderr: string; stdout: string }>();
@ -48,11 +54,10 @@ export function spawnGatewayProcess(
coreAuthToken: string
): SpawnedGatewayProcess {
const gatewayEntry = resolveGatewayEntry();
const fetchPreloadFile = writeGatewayFetchPreloadFile();
const nodeRuntime = resolveGatewayNodeRuntime();
const env = createGatewayProcessEnv(config, upstreamProxyUrl, runtimeId, coreAuthToken, nodeRuntime.electronRunAsNode);
const gatewayBootstrapEntry = resolveGatewayBootstrapEntry();
const args = ["--require", fetchPreloadFile, gatewayBootstrapEntry];
const args = [gatewayBootstrapEntry];
const child = spawn(nodeRuntime.command, args, {
cwd: CONFIGDIR,
env,
@ -200,6 +205,78 @@ export function resolveLocalAgentAuthProviderHookEntry(): string {
].find((candidate) => existsSync(candidate)) ?? pathJoin(__dirname, "local-agent-auth-provider-hook.js");
}
export function resolveRouterPluginEntry(): string {
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
return [
pathJoin(__dirname, "router-plugin.js"),
pathJoin(process.cwd(), ".test-dist", "core", "runtime", "router-plugin.js"),
...(resourcesPath
? [
pathJoin(resourcesPath, "app.asar", "dist", "main", "router-plugin.js"),
pathJoin(resourcesPath, "app", "dist", "main", "router-plugin.js")
]
: [])
].find((candidate) => existsSync(candidate)) ?? pathJoin(__dirname, "router-plugin.js");
}
export function gatewayRuntimeSupportsRouterPlugin(): boolean {
const override = process.env[gatewayEntryOverrideEnv]?.trim();
if (override) {
return gatewayEntrySupportsRouterPlugin(pathResolve(override));
}
const bundledEntry = resolveBundledGatewayEntry();
if (bundledEntry) {
return gatewayEntrySupportsRouterPlugin(bundledEntry);
}
for (const packageName of gatewayPackageCandidates) {
try {
if (gatewayEntrySupportsRouterPlugin(requireFromHere.resolve(packageName))) {
return true;
}
} catch {
// Try the next known package name.
}
}
return false;
}
function gatewayEntrySupportsRouterPlugin(entry: string): boolean {
return gatewayFileSupportsRouterPlugin(entry) || gatewayPackageSupportsRouterPlugin(packageRootForEntry(entry));
}
function gatewayPackageSupportsRouterPlugin(packageRoot: string | undefined): boolean {
if (!packageRoot) {
return false;
}
return gatewayFileSupportsRouterPlugin(pathJoin(packageRoot, "dist", "index.js"));
}
function gatewayFileSupportsRouterPlugin(file: string): boolean {
try {
const source = readFileSync(file, "utf8");
return routerPluginSupportMarkers.every((marker) => source.includes(marker));
} catch {
return false;
}
}
function packageRootForEntry(entry: string): string | undefined {
let current = pathDirname(entry);
for (let depth = 0; depth < 8; depth += 1) {
if (existsSync(pathJoin(current, "package.json"))) {
return current;
}
const parent = pathDirname(current);
if (parent === current) {
return undefined;
}
current = parent;
}
return undefined;
}
function resolveGatewayEntry(): string {
const override = process.env[gatewayEntryOverrideEnv]?.trim();
if (override) {
@ -271,6 +348,11 @@ function createGatewayProcessEnv(
coreAuthToken: string,
electronRunAsNode: boolean
): NodeJS.ProcessEnv {
const publicGatewayMode = config.gateway.coreHost === config.gateway.host &&
config.gateway.corePort === config.gateway.port;
const authTokens = publicGatewayMode
? publicGatewayAuthTokens(config, coreAuthToken)
: [coreAuthToken];
const env: NodeJS.ProcessEnv = {
...process.env,
AUTH_ENABLED: "true",
@ -278,11 +360,9 @@ function createGatewayProcessEnv(
AUTH_REQUIRED: "true",
AUTH_STATIC_API_KEY_BEARER_ONLY: "false",
AUTH_STATIC_API_KEY_ENV: coreGatewayAuthTokenEnv,
AUTH_STATIC_API_KEY_HEADER: coreGatewayAuthHeader,
AUTH_STATIC_API_KEY_HEADER: publicGatewayMode ? "authorization" : coreGatewayAuthHeader,
CCR_GATEWAY_RUNTIME_ID: runtimeId,
CCR_UNDICI_MODULE: resolveUndiciProxyAgentModule(),
CCR_UPSTREAM_TIMEOUT_MS: String(gatewayUpstreamTimeoutMs(config)),
[coreGatewayAuthTokenEnv]: coreAuthToken,
[coreGatewayAuthTokenEnv]: authTokens.join(","),
HOST: config.gateway.coreHost,
PORT: String(config.gateway.corePort)
};
@ -323,9 +403,12 @@ function createGatewayProcessEnv(
return env;
}
function gatewayUpstreamTimeoutMs(config: AppConfig): number {
const value = Number(config.API_TIMEOUT_MS);
return Number.isFinite(value) && value >= 0 ? Math.trunc(value) : 0;
export function publicGatewayAuthTokens(config: AppConfig, coreAuthToken: string): string[] {
return uniqueStrings([
coreAuthToken,
...(Array.isArray(config.APIKEYS) ? config.APIKEYS.map((apiKey) => apiKey.key) : []),
config.APIKEY
]);
}
function resolveGatewayNodeRuntime(): GatewayNodeRuntime {

View file

@ -1,5 +1,6 @@
import { applyResponsesSessionAffinity } from "@ccr/core/gateway/core-runtime/responses-session-affinity";
import type { ResponsesSessionAffinityInput } from "@ccr/core/gateway/core-runtime/responses-session-affinity";
import { sdkCompatibleTokenHeaderNames } from "@ccr/core/gateway/internal/shared";
type UpstreamRequest = {
body: unknown;
@ -36,11 +37,7 @@ const ccrRoutingHeaderNames = new Set([
"x-target-providers"
]);
const clientAuthHeaderNames = new Set([
"api-key",
"authorization",
"x-api-key"
]);
const clientAuthHeaderNames = new Set<string>(sdkCompatibleTokenHeaderNames);
const proxyMetadataHeaderNames = new Set([
"forwarded",

View file

@ -4,7 +4,7 @@
import type { IncomingHttpHeaders, IncomingMessage, Server, ServerResponse } from "node:http";
import type { ApiKeyConfig } from "@ccr/core/contracts/app";
import { ccrRemoteControlPathPrefix } from "@ccr/core/gateway/remote-control-service";
import { coreGatewayAuthHeader, localObservabilityHeaderNames, proxyHeaderDenyList, responseHeaderDenyList } from "@ccr/core/gateway/internal/shared";
import { coreGatewayAuthHeader, localObservabilityHeaderNames, proxyHeaderDenyList, responseHeaderDenyList, sdkCompatibleTokenHeaderNames } from "@ccr/core/gateway/internal/shared";
export function inferGatewayClient(apiKey: ApiKeyConfig | undefined, headers: IncomingHttpHeaders): string | undefined {
@ -61,11 +61,13 @@ function inferClientFromUserAgent(headers: IncomingHttpHeaders): string | undefi
export function readAuthToken(headers: IncomingHttpHeaders): string | undefined {
const raw = readHeader(headers.authorization) || readHeader(headers["x-api-key"]);
if (!raw) {
return undefined;
for (const headerName of sdkCompatibleTokenHeaderNames) {
const raw = readHeader(headers[headerName]);
if (raw) {
return raw.toLowerCase().startsWith("bearer ") ? raw.slice(7).trim() : raw.trim();
}
}
return raw.toLowerCase().startsWith("bearer ") ? raw.slice(7).trim() : raw;
return undefined;
}
@ -92,9 +94,9 @@ export function forwardHeaders(headers: IncomingHttpHeaders): Record<string, str
export function stripLocalGatewayAuthHeaders(headers: Record<string, string>): void {
delete headers.authorization;
delete headers["x-api-key"];
delete headers["api-key"];
for (const headerName of sdkCompatibleTokenHeaderNames) {
delete headers[headerName];
}
}

View file

@ -32,6 +32,15 @@ export const claudeAppGatewayModelRouteOptions: ClaudeAppGatewayModelRouteOption
supportsOneMillionContext: (model) => Boolean(findModelCatalogEntry(model)?.limits?.supports1MContext)
};
export const sdkCompatibleTokenHeaderNames = [
"authorization",
"x-api-key",
"api-key",
"x-goog-api-key",
"x-mcp-key",
"x-codex-access-token"
] as const;
export type ApiKeyAuthorizationResult =
| { ok: true; apiKey?: ApiKeyConfig }

View file

@ -9,7 +9,7 @@ import {
} from "@ccr/core/observability/request-log-store";
import { requestLogRequestedModel, requestLogResponseModel } from "@ccr/core/observability/request-log-model";
import { recordGatewayUsageCapture, type UsageCaptureInput } from "@ccr/core/usage/store";
import { ClaudeCodeRouterPlugin } from "@ccr/core/gateway/claude-code-router-plugin";
import { ClaudeCodeRouterPlugin, type ClaudeCodeRouteDecision } from "@ccr/core/gateway/claude-code-router-plugin";
import {
codexCompactResponseStream,
contextArchiveHandoffResponseStream,
@ -41,7 +41,8 @@ import { createBodySampler, requestLogSampled, shouldRecordRequestLogs } from "@
import { RequestRouteTraceRecorder } from "@ccr/core/observability/route-trace";
import { coreGatewayUsageAttributionConfig } from "@ccr/core/gateway/core-runtime/config-compiler";
import { providerModelPricingForUsage } from "@ccr/core/models/pricing-service";
import { clientClosedRequestStatusCode, clientDisconnectMessage, resolveStreamRequestLogOutcome, UpstreamRequestError } from "@ccr/core/gateway/internal/shared";
import { fetchWithSystemProxy } from "@ccr/core/proxy/system-proxy-fetch";
import { clientClosedRequestStatusCode, clientDisconnectMessage, coreGatewayAuthHeader, resolveStreamRequestLogOutcome, UpstreamRequestError } from "@ccr/core/gateway/internal/shared";
import type { BrowserWebSearchMcpIntegration, BrowserWebSearchProtocolRecord, UpstreamFetchResult } from "@ccr/core/gateway/internal/shared";
import { cancelResponseBody, destroyResponseStreams, fetchUpstreamWithFallback, mergeFallbackResponseHeaders, rewriteCapabilityResponseHeaders, uniqueStreams, upstreamResponseHeaders } from "@ccr/core/gateway/upstream/executor";
import { requestProtocolForPath, shouldApplyGatewayRouting } from "@ccr/core/routing/protocol-endpoints";
@ -49,6 +50,15 @@ import { createClaudeCodeWebSearchContinuationContext, createHostedWebSearchProt
import { isModelAllowedForProfile, profileForApiKey } from "@ccr/core/profiles/model-allowlist";
import { pluginService } from "@ccr/core/plugins/service";
import { finalizeOpenRouterDiscountProviderRouterSelection } from "@ccr/core/plugins/built-ins/openrouter-discount-provider-router";
import {
ccrRouteHeaderNames,
ccrRouteDiagnosticsHeader,
ccrRouteReasonHeader,
ccrRouteSourceHeader,
ccrRoutedModelHeader,
ccrRouterHttpRoutePath
} from "@ccr/core/gateway/core-runtime/router-plugin-contract";
import { isRecord } from "@ccr/core/gateway/internal/value";
export type GatewayRequestPipelineDependencies = {
getBrowserWebSearchMcpIntegration: () => BrowserWebSearchMcpIntegration | undefined;
@ -80,6 +90,24 @@ function isReportedRouteChange(change: RequestRouteTraceChange | undefined): cha
return change !== undefined;
}
function stripUntrustedCcrRouteHeaders(headers: Record<string, string>): RequestRouteTraceChange[] {
const changes: RequestRouteTraceChange[] = [];
for (const headerName of ccrRouteHeaderNames) {
const previous = headers[headerName];
if (previous === undefined) {
continue;
}
delete headers[headerName];
changes.push({
before: previous,
operation: "remove",
path: `/headers/${headerName}`,
scope: "headers"
});
}
return changes;
}
export class GatewayRequestPipeline {
constructor(private readonly dependencies: GatewayRequestPipelineDependencies) {}
@ -109,6 +137,7 @@ export class GatewayRequestPipeline {
routeTrace?.captureIngress();
const headerNormalizationStartedAt = Date.now();
const headers = forwardHeaders(request.headers);
const strippedCcrRouteHeaderChanges = stripUntrustedCcrRouteHeaders(headers);
const previousAuthorization = headers.authorization;
const previousApiKey = headers["x-api-key"];
const previousLegacyApiKey = headers["api-key"];
@ -123,6 +152,7 @@ export class GatewayRequestPipeline {
headers["x-client-request-id"] = requestId;
routeTrace?.capture({
changes: [
...strippedCcrRouteHeaderChanges,
...(apiKey ? [
reportedRouteChange("headers", "/headers/authorization", previousAuthorization, undefined),
reportedRouteChange("headers", "/headers/x-api-key", previousApiKey, undefined),
@ -338,7 +368,15 @@ export class GatewayRequestPipeline {
startedAtMs: routeAdaptationStartedAt
});
}
const routed = await this.plugin.routeRequest({
const routed = await routeRequestWithCoreGatewayPlugin({
body: adaptation.body,
coreAuthToken: this.coreAuthToken,
coreEndpoint: this.status.coreEndpoint,
headers: headers as Record<string, string | string[] | undefined>,
method,
path,
url: request.url ?? path
}) ?? await this.plugin.routeRequest({
body: adaptation.body,
bodyOwnership: "owned",
headers: headers as Record<string, string | string[] | undefined>,
@ -348,16 +386,16 @@ export class GatewayRequestPipeline {
});
const serialized = serializeJsonBody(restoreRouteRequestBody(routed.body, adaptation));
headers["content-type"] = "application/json";
headers["x-ccr-route-reason"] = sanitizeHeaderValue(routed.decision.reason);
headers["x-ccr-route-source"] = routed.decision.source;
headers[ccrRouteReasonHeader] = sanitizeHeaderValue(routed.decision.reason);
headers[ccrRouteSourceHeader] = routed.decision.source;
if (routed.decision.diagnostics.length > 0) {
headers["x-ccr-route-diagnostics"] = String(routed.decision.diagnostics.length);
headers[ccrRouteDiagnosticsHeader] = String(routed.decision.diagnostics.length);
}
routeFallback = routed.decision.fallback ?? routeFallback;
routedSessionId = routed.decision.sessionId;
routedTokenCount = routed.decision.tokenCount;
if (routed.decision.model) {
headers["x-ccr-routed-model"] = sanitizeHeaderValue(routed.decision.model);
headers[ccrRoutedModelHeader] = sanitizeHeaderValue(routed.decision.model);
routedModel = routed.decision.model;
}
bodyToForward = serialized;
@ -365,9 +403,9 @@ export class GatewayRequestPipeline {
changes: [
{ operation: "replace", path: "/body", scope: "body" },
{ after: headers["content-type"], operation: "replace", path: "/headers/content-type", scope: "headers" },
{ after: headers["x-ccr-route-reason"], operation: "add", path: "/headers/x-ccr-route-reason", scope: "headers" },
{ after: headers["x-ccr-route-source"], operation: "add", path: "/headers/x-ccr-route-source", scope: "headers" },
...(routedModel ? [{ after: routedModel, operation: "add" as const, path: "/headers/x-ccr-routed-model", scope: "headers" as const }] : [])
{ after: headers[ccrRouteReasonHeader], operation: "add", path: `/headers/${ccrRouteReasonHeader}`, scope: "headers" },
{ after: headers[ccrRouteSourceHeader], operation: "add", path: `/headers/${ccrRouteSourceHeader}`, scope: "headers" },
...(routedModel ? [{ after: routedModel, operation: "add" as const, path: `/headers/${ccrRoutedModelHeader}`, scope: "headers" as const }] : [])
],
decision: {
diagnostics: routed.decision.diagnostics,
@ -1044,6 +1082,84 @@ export class GatewayRequestPipeline {
}
}
async function routeRequestWithCoreGatewayPlugin(input: {
body: Record<string, unknown>;
coreAuthToken: string;
coreEndpoint: string;
headers: Record<string, string | string[] | undefined>;
method: string;
path: string;
url: string;
}): Promise<{ body: Record<string, unknown>; decision: ClaudeCodeRouteDecision } | undefined> {
if (!input.coreEndpoint || !input.coreAuthToken) {
return undefined;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 500);
try {
const response = await fetchWithSystemProxy(new URL(ccrRouterHttpRoutePath, input.coreEndpoint), {
body: JSON.stringify({
body: input.body,
headers: input.headers,
method: input.method,
path: input.path,
url: input.url
}),
headers: {
"content-type": "application/json",
[coreGatewayAuthHeader]: input.coreAuthToken
},
method: "POST",
signal: controller.signal
});
if (response.status === 404 || response.status === 405) {
return undefined;
}
if (!response.ok || !response.headers.get("content-type")?.toLowerCase().includes("application/json")) {
await response.body?.cancel().catch(() => undefined);
return undefined;
}
const payload = await response.json().catch(() => undefined) as unknown;
return normalizeCoreRouterPluginResponse(payload);
} catch {
return undefined;
} finally {
clearTimeout(timeout);
}
}
function normalizeCoreRouterPluginResponse(
payload: unknown
): { body: Record<string, unknown>; decision: ClaudeCodeRouteDecision } | undefined {
if (!isRecord(payload) || !isRecord(payload.body) || !isRecord(payload.decision)) {
return undefined;
}
const decision = payload.decision;
if (
typeof decision.reason !== "string" ||
typeof decision.source !== "string" ||
typeof decision.tokenCount !== "number" ||
!Array.isArray(decision.diagnostics) ||
!isRecord(decision.fallback)
) {
return undefined;
}
return {
body: payload.body,
decision: {
diagnostics: decision.diagnostics as ClaudeCodeRouteDecision["diagnostics"],
fallback: decision.fallback as ClaudeCodeRouteDecision["fallback"],
...(typeof decision.model === "string" ? { model: decision.model } : {}),
reason: decision.reason,
...(typeof decision.sessionId === "string" ? { sessionId: decision.sessionId } : {}),
source: decision.source as ClaudeCodeRouteDecision["source"],
tokenCount: decision.tokenCount
}
};
}
function profileDeniedModel(
config: AppConfig,
profile: ProfileConfig | undefined,

View file

@ -1,29 +1,35 @@
import type { AppConfig } from "@ccr/core/contracts/app";
export function shouldRestartGatewayForRuntimeConfigChange(previousConfig: AppConfig, nextConfig: AppConfig): boolean {
return (
previousConfig.gateway.enabled !== nextConfig.gateway.enabled ||
previousConfig.gateway.host !== nextConfig.gateway.host ||
previousConfig.gateway.port !== nextConfig.gateway.port ||
previousConfig.gateway.coreHost !== nextConfig.gateway.coreHost ||
previousConfig.gateway.corePort !== nextConfig.gateway.corePort ||
previousConfig.observability.requestLogs !== nextConfig.observability.requestLogs ||
previousConfig.observability.agentAnalysis !== nextConfig.observability.agentAnalysis ||
previousConfig.observability.requestLogBodyCapture !== nextConfig.observability.requestLogBodyCapture ||
previousConfig.observability.requestLogMaxBodyBytes !== nextConfig.observability.requestLogMaxBodyBytes ||
previousConfig.proxy.enabled !== nextConfig.proxy.enabled ||
previousConfig.proxy.host !== nextConfig.proxy.host ||
previousConfig.proxy.mode !== nextConfig.proxy.mode ||
previousConfig.proxy.port !== nextConfig.proxy.port ||
previousConfig.proxy.systemProxy !== nextConfig.proxy.systemProxy ||
JSON.stringify(previousConfig.proxy.targets) !== JSON.stringify(nextConfig.proxy.targets) ||
JSON.stringify(previousConfig.proxy.upstream) !== JSON.stringify(nextConfig.proxy.upstream) ||
JSON.stringify(previousConfig.agent) !== JSON.stringify(nextConfig.agent) ||
JSON.stringify(previousConfig.mediaTools) !== JSON.stringify(nextConfig.mediaTools) ||
JSON.stringify(previousConfig.Providers) !== JSON.stringify(nextConfig.Providers) ||
JSON.stringify(previousConfig.plugins) !== JSON.stringify(nextConfig.plugins) ||
JSON.stringify(previousConfig.providerPlugins) !== JSON.stringify(nextConfig.providerPlugins) ||
JSON.stringify(previousConfig.toolHub) !== JSON.stringify(nextConfig.toolHub) ||
JSON.stringify(previousConfig.virtualModelProfiles) !== JSON.stringify(nextConfig.virtualModelProfiles)
);
return runtimeGatewayConfigSignature(previousConfig) !== runtimeGatewayConfigSignature(nextConfig);
}
function runtimeGatewayConfigSignature(config: AppConfig): string {
return JSON.stringify({
APIKEY: config.APIKEY,
APIKEYS: config.APIKEYS,
API_TIMEOUT_MS: config.API_TIMEOUT_MS,
CUSTOM_ROUTER_PATH: config.CUSTOM_ROUTER_PATH,
HOST: config.HOST,
PORT: config.PORT,
Providers: config.Providers,
Router: config.Router,
agent: config.agent,
contextArchive: config.contextArchive,
gateway: config.gateway,
mediaTools: config.mediaTools,
observability: {
agentAnalysis: config.observability.agentAnalysis,
requestLogBodyCapture: config.observability.requestLogBodyCapture,
requestLogMaxBodyBytes: config.observability.requestLogMaxBodyBytes,
requestLogs: config.observability.requestLogs
},
plugins: config.plugins,
preferredProvider: config.preferredProvider,
profile: config.profile,
providerPlugins: config.providerPlugins,
proxy: config.proxy,
toolHub: config.toolHub,
virtualModelProfiles: config.virtualModelProfiles
});
}

View file

@ -72,7 +72,11 @@ export async function estimateUsageCostUsd(input: UsageCostInput): Promise<Usage
* Loads the remote catalog without holding a caller's database transaction.
* Callers that own a write transaction can then use the cache-only estimator.
*/
export async function preloadUsagePriceCatalog(): Promise<void> {
export async function preloadUsagePriceCatalog(options: { forceRefresh?: boolean } = {}): Promise<void> {
if (options.forceRefresh) {
catalog = undefined;
catalogPromise = undefined;
}
await getPriceCatalog();
}

View file

@ -9,6 +9,7 @@ import type { AppConfig } from "@ccr/core/contracts/app";
import { RAW_TRACE_SPOOL_DIR } from "@ccr/core/config/constants";
import {
enqueueGatewayRequestLogFromRawTrace,
type RequestLogRawTraceFile,
type RequestLogRawTraceFiles,
type RequestLogRawTraceUpdateInput
} from "@ccr/core/observability/request-log-store";
@ -18,10 +19,12 @@ import {
} from "@ccr/core/observability/request-log-runtime";
import { rawTraceMaxPartBytes, resolveRawTraceBodyLimit } from "@ccr/core/observability/request-log-limits";
import { isRecord, numberValue, stringValue } from "@ccr/core/gateway/internal/value";
import { formatError, parseJsonObject, readHeader, readRequestBody, sendJson } from "@ccr/core/gateway/http/io";
import { formatError, inferGatewayClient, parseJsonObject, readHeader, readRequestBody, sendJson, shouldCaptureGatewayUsage } from "@ccr/core/gateway/http/io";
import { endpoint } from "@ccr/core/gateway/core-runtime/supervisor";
import { maxUsageCaptureBytes, rawTraceSyncHeader, rawTraceSyncPath } from "@ccr/core/gateway/internal/shared";
import type { RawTracePartText } from "@ccr/core/gateway/internal/shared";
import { resolveResponseProviderProtocol } from "@ccr/core/providers/runtime-topology";
import { recordGatewayUsageCaptureIfMissing } from "@ccr/core/usage/store";
type RawTraceSynchronizerDependencies = {
enqueueUpdate?: (
@ -34,6 +37,7 @@ type RawTraceSynchronizerDependencies = {
deadLetterMaxBytes?: number;
deadLetterRetentionMs?: number;
getConfig: () => AppConfig | undefined;
allowStandaloneRequestLogs?: () => boolean;
inboxMaxBundles?: number;
inboxMaxBytes?: number;
measureDirectorySize?: (directory: string) => Promise<number>;
@ -372,7 +376,7 @@ export class RawTraceSynchronizer {
try {
const config = this.dependencies.getConfig();
if (!config || !shouldRecordRequestLogs(config)) {
if (!config) {
await this.cleanupStoredBundle(stored);
this.retryStates.delete(stored.bundleId);
return true;
@ -386,7 +390,17 @@ export class RawTraceSynchronizer {
this.retryStates.delete(stored.bundleId);
return true;
}
const policy = applyRawTraceRequestLogPolicy(config, bundle.update);
await recordUsageCaptureFromRawTrace(config, bundle.update, bundle.files);
if (!shouldRecordRequestLogs(config)) {
await this.cleanupStoredBundle(stored);
this.retryStates.delete(stored.bundleId);
return true;
}
const standaloneRequestLog = this.dependencies.allowStandaloneRequestLogs?.() === true;
const policy = applyRawTraceRequestLogPolicy(config, {
...bundle.update,
...(standaloneRequestLog ? { allowStandaloneRecord: true } : {})
});
const maxBodyBytes = resolveRawTraceBodyLimit(config.observability.requestLogMaxBodyBytes);
const files = policy.bodyDisposition === "suppress"
? { cleanupDirectory: bundle.files.cleanupDirectory, maxBodyBytes }
@ -1407,16 +1421,26 @@ function pendingRetryDelayMs(baseMs: number, maxMs: number, pendingAttempts: num
}
export function buildRawTraceConfig(config: AppConfig, rawTraceSyncToken: string): Record<string, unknown> {
export function buildRawTraceConfig(
config: AppConfig,
rawTraceSyncToken: string,
options: { standaloneUsageCapture?: boolean } = {}
): Record<string, unknown> {
const bodyCapture = config.observability.requestLogBodyCapture ?? "all";
const maxBodyBytes = resolveRawTraceBodyLimit(config.observability.requestLogMaxBodyBytes);
const bodyCaptureEnabled = bodyCapture !== "none" && maxBodyBytes >= 1024;
const enabled = rawTraceEnabledFromEnv() && shouldRecordRequestLogs(config) && bodyCaptureEnabled;
const requestLogsEnabled = shouldRecordRequestLogs(config);
const standaloneUsageCapture = options.standaloneUsageCapture === true;
const enabled = !rawTraceDisabledFromEnv() && (
standaloneUsageCapture ||
(requestLogsEnabled && bodyCaptureEnabled)
);
const mode = requestLogsEnabled && bodyCaptureEnabled ? "wire_raw" : "body_redacted";
return {
deleteLocalAfterUpload: false,
enabled,
maxPartBytes: rawTraceMaxPartBytes,
mode: "wire_raw",
mode,
spoolDir: RAW_TRACE_SPOOL_DIR,
sync: {
enabled,
@ -1483,6 +1507,78 @@ export function rawTraceRequestOutcome(
return "unknown";
}
async function recordUsageCaptureFromRawTrace(
config: AppConfig,
input: RequestLogRawTraceUpdateInput,
files: RequestLogRawTraceFiles
): Promise<void> {
const method = input.method ?? "POST";
const path = input.path ?? pathFromUrl(input.url) ?? "/";
if (!shouldCaptureGatewayUsage(method, path)) {
return;
}
const responseHeaders = headersFromRawTrace(input.responseHeaders);
await recordGatewayUsageCaptureIfMissing({
bodyText: await rawTraceUsageBodyText(input, files.responseBody),
config,
durationMs: numberValue(input.durationMs) ?? 0,
fallbackModel: input.model,
method,
path,
providerName: input.provider,
providerProtocol: resolveResponseProviderProtocol(responseHeaders, config),
requestId: input.requestId,
responseHeaders,
statusCode: numberValue(input.statusCode) ?? 0
});
}
async function rawTraceUsageBodyText(
input: RequestLogRawTraceUpdateInput,
file: RequestLogRawTraceFile | undefined
): Promise<string> {
if (input.responseBodyText !== undefined) {
return boundedText(input.responseBodyText, maxUsageCaptureBytes);
}
if (!file || !isUsageTextContentType(file.contentType)) {
return "";
}
try {
return boundedText(await readFile(file.filePath, "utf8"), maxUsageCaptureBytes);
} catch {
return "";
}
}
function boundedText(value: string, maxBytes: number): string {
const buffer = Buffer.from(value);
return buffer.byteLength > maxBytes
? buffer.subarray(0, maxBytes).toString("utf8")
: value;
}
function headersFromRawTrace(headers: RequestLogRawTraceUpdateInput["responseHeaders"]): Headers {
const result = new Headers();
for (const [key, value] of Object.entries(headers ?? {})) {
if (Array.isArray(value)) {
for (const item of value) result.append(key, item);
} else if (value !== undefined) {
result.set(key, value);
}
}
return result;
}
function isUsageTextContentType(contentType: string | undefined): boolean {
if (!contentType) return true;
const normalized = contentType.toLowerCase();
return normalized.includes("json") ||
normalized.includes("text") ||
normalized.includes("xml") ||
normalized.includes("event-stream");
}
export function requestLogSampled(requestId: string, rate: number): boolean {
if (rate >= 1) return true;
@ -1496,9 +1592,9 @@ export function requestLogSampled(requestId: string, rate: number): boolean {
}
function rawTraceEnabledFromEnv(): boolean {
function rawTraceDisabledFromEnv(): boolean {
const value = (process.env.CCR_RAW_TRACE_ENABLED ?? process.env.CCR_RAW_TRACE ?? "").trim().toLowerCase();
return value === "1" || value === "true" || value === "yes" || value === "on";
return value === "0" || value === "false" || value === "no" || value === "off";
}
@ -1534,8 +1630,11 @@ export async function readRawTraceRequestLogBundle(
const target = isRecord(manifest.target) ? manifest.target : {};
const rawUrl = stringValue(upstreamRequestMetadata?.url);
const url = sanitizeUrlForLog(rawUrl);
const clientRequestHeaders = headerRecordFromUnknown(clientRequestMetadata?.headers);
const upstreamRequestHeaders = headerRecordFromUnknown(upstreamRequestMetadata?.headers);
const client = inferGatewayClient(undefined, clientRequestHeaders ?? {});
const attempt = positiveAttemptNumber(readUnknownHeader(
clientRequestMetadata?.headers,
clientRequestHeaders,
"x-ccr-route-attempt"
));
@ -1549,6 +1648,9 @@ export async function readRawTraceRequestLogBundle(
...(attempt === undefined ? {} : { attempt }),
...(stringValue(manifest.uploadedAt) ? { bundleCapturedAt: stringValue(manifest.uploadedAt) } : {}),
...(bundleId ? { bundleId } : {}),
...(client ? { client } : {}),
...(stringValue(manifest.completedAt) ? { completedAt: stringValue(manifest.completedAt) } : {}),
...(numberValue(manifest.durationMs) !== undefined ? { durationMs: numberValue(manifest.durationMs) } : {}),
method: stringValue(upstreamRequestMetadata?.method) || "POST",
model: stringValue(target.model),
path: pathFromUrl(url),
@ -1556,13 +1658,14 @@ export async function readRawTraceRequestLogBundle(
requestBodyContentType: upstreamRequestBody?.contentType,
requestBodySizeBytes: upstreamRequestBody?.sizeBytes,
requestBodyTruncated: upstreamRequestBody?.truncated,
requestHeaders: headerRecordFromUnknown(upstreamRequestMetadata?.headers),
requestHeaders: upstreamRequestHeaders,
requestId,
isStream: upstreamResponseStream !== undefined,
responseBodyContentType: upstreamResponseBody?.contentType,
responseBodySizeBytes: upstreamResponseBody?.sizeBytes,
responseBodyTruncated: upstreamResponseBody?.truncated,
responseHeaders: headerRecordFromUnknown(upstreamResponseMetadata?.headers),
...(stringValue(manifest.startedAt) ? { startedAt: stringValue(manifest.startedAt) } : {}),
statusCode: numberValue(upstreamResponseMetadata?.statusCode),
url
}

View file

@ -247,19 +247,22 @@ export class RequestLogRuntime {
input: RequestLogRawTraceUpdateInput,
rawTraceFiles?: RequestLogRawTraceFiles
): RequestLogEnqueueResult {
const recordAdmission = input.deferOutcomeUntilRecord
const allowStandaloneRecord = input.allowStandaloneRecord === true;
const recordAdmission = input.deferOutcomeUntilRecord && !allowStandaloneRecord
? this.resolveRecordAdmission(input.requestId)
: this.readRecordAdmission(input.requestId);
if (recordAdmission === "pending" ||
(input.deferOutcomeUntilRecord && recordAdmission?.state === "pending") ||
(recordAdmission === undefined && input.deferOutcomeUntilRecord)) {
const pendingAdmission = recordAdmission === "pending";
const resolvedAdmission = pendingAdmission ? undefined : recordAdmission;
if ((!allowStandaloneRecord && pendingAdmission) ||
(!allowStandaloneRecord && input.deferOutcomeUntilRecord && resolvedAdmission?.state === "pending") ||
(!allowStandaloneRecord && recordAdmission === undefined && input.deferOutcomeUntilRecord)) {
return {
accepted: false,
degraded: false,
reason: "record_pending"
};
}
if (recordAdmission && !recordAdmission.accepted) {
if (resolvedAdmission && !resolvedAdmission.accepted) {
this.dropped += 1;
return {
accepted: false,
@ -276,11 +279,11 @@ export class RequestLogRuntime {
const configuredMaxBodyBytes = resolveRawTraceBodyLimit(rawTraceFiles?.maxBodyBytes);
const maxBodyBytes = Math.min(
configuredMaxBodyBytes,
recordAdmission?.bodyCaptureMaxBytes ?? configuredMaxBodyBytes
resolvedAdmission?.bodyCaptureMaxBytes ?? configuredMaxBodyBytes
);
const bodyPolicyDegraded = maxBodyBytes < configuredMaxBodyBytes;
const admittedInput = recordAdmission
? { ...input, bodyCapturePolicy: recordAdmission.bodyCapturePolicy }
const admittedInput = resolvedAdmission
? { ...input, bodyCapturePolicy: resolvedAdmission.bodyCapturePolicy }
: input;
const policyInput = maxBodyBytes === 0
? suppressRequestLogRawTraceBodies(admittedInput)

View file

@ -20,6 +20,7 @@ import { maxRequestLogBodyBytes, rawTraceHardMaxBodyBytes } from "@ccr/core/obse
import { compactBase64ImagePayloads } from "@ccr/core/observability/request-log-body";
import { requestLogRequestedModel, requestLogResponseModel } from "@ccr/core/observability/request-log-model";
import { isSensitiveRequestLogHeaderName } from "@ccr/core/observability/sensitive-headers";
import { inferGatewayClient } from "@ccr/core/gateway/http/io";
import type {
AgentAnalysisAgentRow,
AgentAnalysisConversationItem,
@ -101,6 +102,8 @@ type RequestLogStoredOutcome = {
hasRequestBody: boolean;
hasResponseBody: boolean;
ok: boolean;
responseBodyContentType: string;
responseBodyText: string;
statusCode: number;
};
@ -144,12 +147,16 @@ export type RequestLogRecordInput = {
};
export type RequestLogRawTraceUpdateInput = {
allowStandaloneRecord?: boolean;
attempt?: number;
bodyCapturePolicy?: "all" | "errors" | "none";
bundleCapturedAt?: string;
bundleId?: string;
client?: string;
completedAt?: string;
deferBodyCaptureUntilRecord?: boolean;
deferOutcomeUntilRecord?: boolean;
durationMs?: number;
method?: string;
model?: string;
path?: string;
@ -168,6 +175,7 @@ export type RequestLogRawTraceUpdateInput = {
responseBodyText?: string;
responseBodyTruncated?: boolean;
responseHeaders?: HeaderRecord;
startedAt?: string;
statusCode?: number;
url?: string;
};
@ -460,6 +468,13 @@ export class RequestLogStore {
if (bundleId && hasProcessedRawTraceBundle(database, bundleId)) {
continue;
}
if (command.input.allowStandaloneRecord && !hasRequestLogWithRequestId(database, command.input.requestId.trim())) {
await this.record(standaloneRecordInputFromRawTrace(command.input, command.rawTraceFiles));
if (bundleId) {
rememberProcessedRawTraceBundle(database, bundleId, command.input.requestId.trim());
}
continue;
}
const rawTraceInput = this.prepareRawTraceInput(command.input, command.rawTraceFiles);
const applied = await this.updateFromRawTrace(rawTraceInput);
if (bundleId && applied) {
@ -993,8 +1008,17 @@ export class RequestLogStore {
);
pushBodyValues(sets, params, "request", requestBody);
}
const shouldApplyResponseBody = input.responseBodyText !== undefined ||
Boolean(input.responseBodyRef && (!input.responseBodyTruncated || !existingOutcome.hasResponseBody));
const preserveExistingResponseBody = shouldPreserveExistingResponseBodyForRawTrace(
existingOutcome,
input,
responseHeaders,
responseBodyContentType,
sseError
);
const shouldApplyResponseBody = !preserveExistingResponseBody && (
input.responseBodyText !== undefined ||
Boolean(input.responseBodyRef && (!input.responseBodyTruncated || !existingOutcome.hasResponseBody))
);
if (shouldApplyResponseBody && (
captureResolution.bodiesSuppressed || Boolean(input.responseBodyRef) || (input.responseBodyText?.length ?? 0) > 0 || !existingOutcome.hasResponseBody
)) {
@ -1007,6 +1031,8 @@ export class RequestLogStore {
{ bodyDir: this.bodyDir, bodyRef: input.responseBodyRef, side: "response" }
);
pushBodyValues(sets, params, "response", responseBody);
} else if (preserveExistingResponseBody && input.responseBodyRef) {
deleteRequestLogBodyRefs(this.bodyDir, [input.responseBodyRef]);
}
const update = () => {
@ -1592,6 +1618,159 @@ export class RequestLogStore {
}
}
function standaloneRecordInputFromRawTrace(
input: RequestLogRawTraceUpdateInput,
rawTraceFiles?: RequestLogRawTraceFiles
): RequestLogRecordInput {
const now = new Date().toISOString();
const bodyCapturePolicy = input.bodyCapturePolicy === "errors" || input.bodyCapturePolicy === "none"
? input.bodyCapturePolicy
: "all";
const maxBodyBytes = resolveStandaloneRawTraceBodyLimit(rawTraceFiles?.maxBodyBytes);
const requestHeaders = headersToRecord(input.requestHeaders);
const responseHeaders = headersToRecord(input.responseHeaders) as Record<string, string | string[]>;
const responseContentType = input.responseBodyContentType ?? headerValue(responseHeaders, "content-type");
const responseBodyForOutcome = rawTraceBodyText(input.responseBodyText, rawTraceFiles?.responseBody, maxRequestLogBodyBytes);
const statusCode = normalizeCount(input.statusCode);
const rawFailure = (statusCode > 0 && (statusCode < 200 || statusCode >= 400)) ||
Boolean(detectSseError(responseBodyForOutcome, responseContentType));
const captureBody = bodyCapturePolicy === "all" || (bodyCapturePolicy === "errors" && rawFailure);
const requestBody = captureBody
? rawTraceBodyBuffer(input.requestBodyText, rawTraceFiles?.requestBody, maxBodyBytes)
: suppressedRawTraceBody(input.requestBodySizeBytes, input.requestBodyTruncated);
const responseBody = captureBody
? rawTraceBodyTextCapture(input.responseBodyText, rawTraceFiles?.responseBody, maxBodyBytes)
: suppressedRawTraceBody(input.responseBodySizeBytes, input.responseBodyTruncated);
const completedAt = normalizeFilterValue(input.completedAt) ??
normalizeFilterValue(input.bundleCapturedAt) ??
now;
const durationMs = normalizeCount(input.durationMs);
const startedAt = normalizeFilterValue(input.startedAt) ??
startedAtFromCompletedAt(completedAt, durationMs);
const client = normalizeFilterValue(input.client) ?? inferGatewayClient(undefined, requestHeaders);
return {
bodyCapturePolicy,
captureBody,
...(client ? { client } : {}),
completedAt,
durationMs,
...(input.bundleId ? { eventId: `raw-trace:${input.bundleId}` } : {}),
fallbackModel: input.model,
maxBodyBytes,
method: input.method ?? "POST",
model: input.model,
path: input.path ?? pathFromUrl(input.url) ?? "/",
providerName: input.provider,
requestBody: requestBody.buffer,
requestBodySizeBytes: requestBody.sizeBytes,
requestBodyTruncated: requestBody.truncated,
requestHeaders,
requestId: input.requestId,
responseBodySizeBytes: responseBody.sizeBytes,
responseBodyText: responseBody.text,
responseBodyTruncated: responseBody.truncated,
responseHeaders,
startedAt,
statusCode,
url: input.url ?? input.path ?? "/"
};
}
function startedAtFromCompletedAt(completedAt: string, durationMs: number): string {
const completedAtMs = Date.parse(completedAt);
if (!Number.isFinite(completedAtMs)) {
return completedAt;
}
return new Date(Math.max(0, completedAtMs - Math.max(0, durationMs))).toISOString();
}
function resolveStandaloneRawTraceBodyLimit(value: number | undefined): number {
if (value === undefined) {
return maxRequestLogBodyBytes;
}
const normalized = normalizeCount(value);
return normalized > 0 ? Math.min(normalized, maxRequestLogBodyBytes) : 0;
}
function rawTraceBodyBuffer(
text: string | undefined,
file: RequestLogRawTraceFile | undefined,
maxBytes: number
): { buffer: Buffer; sizeBytes?: number; truncated?: boolean } {
if (maxBytes <= 0) {
return suppressedRawTraceBody(file?.sizeBytes, file?.truncated);
}
if (text !== undefined) {
const buffer = Buffer.from(text);
const data = buffer.byteLength > maxBytes ? buffer.subarray(0, maxBytes) : buffer;
return {
buffer: data,
sizeBytes: Math.max(buffer.byteLength, normalizeCount(file?.sizeBytes)),
truncated: Boolean(file?.truncated) || data.byteLength < buffer.byteLength
};
}
if (!file) {
return { buffer: Buffer.alloc(0), sizeBytes: 0, truncated: false };
}
try {
const buffer = readFileSync(file.filePath);
return {
buffer: buffer.byteLength > maxBytes ? buffer.subarray(0, maxBytes) : buffer,
sizeBytes: Math.max(buffer.byteLength, normalizeCount(file.sizeBytes)),
truncated: Boolean(file.truncated) || buffer.byteLength > maxBytes
};
} catch {
return suppressedRawTraceBody(file.sizeBytes, true);
}
}
function rawTraceBodyTextCapture(
text: string | undefined,
file: RequestLogRawTraceFile | undefined,
maxBytes: number
): { text: string; sizeBytes?: number; truncated?: boolean } {
const buffer = rawTraceBodyBuffer(text, file, maxBytes);
return {
sizeBytes: buffer.sizeBytes,
text: new StringDecoder("utf8").write(buffer.buffer),
truncated: buffer.truncated
};
}
function rawTraceBodyText(
text: string | undefined,
file: RequestLogRawTraceFile | undefined,
maxBytes: number
): string {
if (text !== undefined) {
const buffer = Buffer.from(text);
return new StringDecoder("utf8").write(buffer.byteLength > maxBytes ? buffer.subarray(0, maxBytes) : buffer);
}
if (!file || maxBytes <= 0 || !isTextLikeContentType(file.contentType)) {
return "";
}
try {
const buffer = readFileSync(file.filePath);
return new StringDecoder("utf8").write(buffer.byteLength > maxBytes ? buffer.subarray(0, maxBytes) : buffer);
} catch {
return "";
}
}
function suppressedRawTraceBody(
sizeBytes: number | undefined,
truncated: boolean | undefined
): { buffer: Buffer; sizeBytes?: number; text: string; truncated?: boolean } {
const size = normalizeCount(sizeBytes);
return {
buffer: Buffer.alloc(0),
sizeBytes: size,
text: "",
truncated: Boolean(truncated) || size > 0
};
}
export const requestLogStore = new RequestLogStore(REQUEST_LOGS_DB_FILE);
export const requestLogRuntime = createRequestLogRuntime({ dbFile: REQUEST_LOGS_DB_FILE });
export { createRequestLogRuntime } from "@ccr/core/observability/request-log-runtime";
@ -5409,6 +5588,8 @@ function readRequestLogStoredOutcome(database: SqlDatabase, requestId: string):
length(request_body_text) AS request_body_chars,
length(response_body_text) AS response_body_chars,
request_body_ref,
response_body_content_type,
response_body_text,
response_body_ref,
ok,
status_code
@ -5427,6 +5608,8 @@ function readRequestLogStoredOutcome(database: SqlDatabase, requestId: string):
hasRequestBody: normalizeCount(row?.request_body_chars) > 0 || Boolean(normalizeFilterValue(String(row?.request_body_ref ?? ""))),
hasResponseBody: normalizeCount(row?.response_body_chars) > 0 || Boolean(normalizeFilterValue(String(row?.response_body_ref ?? ""))),
ok: normalizeCount(row?.ok) === 1,
responseBodyContentType: String(row?.response_body_content_type ?? ""),
responseBodyText: String(row?.response_body_text ?? ""),
statusCode: normalizeCount(row?.status_code)
};
}
@ -5443,6 +5626,32 @@ function applyRawTraceBodyCapturePolicy(
};
}
function shouldPreserveExistingResponseBodyForRawTrace(
existingOutcome: RequestLogStoredOutcome,
input: RequestLogRawTraceUpdateInput,
responseHeaders: Record<string, string | string[]> | undefined,
responseBodyContentType: string | undefined,
sseError: string | undefined
): boolean {
if (!existingOutcome.hasResponseBody) {
return false;
}
if (sseError) {
return false;
}
if (!hasMeaningfulExistingResponseBodyText(existingOutcome.responseBodyText)) {
return false;
}
return input.isStream === true ||
contentTypeLooksStreaming(responseBodyContentType) ||
contentTypeLooksStreaming(headerValue(responseHeaders ?? {}, "content-type"));
}
function hasMeaningfulExistingResponseBodyText(value: string): boolean {
const trimmed = value.trim();
return Boolean(trimmed && trimmed !== "{}" && trimmed !== "[]" && trimmed !== "null");
}
function serializePendingRawTraceUpdate(
input: RequestLogRawTraceUpdateInput
): { bytes: number; json: string } | undefined {

View file

@ -499,6 +499,10 @@ function pluginSurfaceAlias(value: string): string {
case "backends":
case "core-gateway":
case "core-gateway-config":
case "core-gateway-plugin":
case "core-gateway-plugins":
case "gateway-plugin":
case "gateway-plugins":
case "fusion-profile":
case "fusion-profiles":
case "virtual-model":
@ -553,6 +557,11 @@ function pluginPermissionAlias(value: string): string {
return "provider-account-connectors";
case "core-gateway":
return "core-gateway-config";
case "core-gateway-plugin":
case "core-gateway-plugins":
case "gateway-plugin":
case "gateway-plugins":
return "core-gateway-plugins";
case "provider-plugin":
case "provider-plugins":
case "core-provider-plugin":

View file

@ -142,6 +142,7 @@ export type GatewayPluginRegistration = {
apps?: GatewayPluginAppConfig[];
coreGateway?: {
config?: Record<string, unknown>;
plugins?: unknown[];
providerPlugins?: unknown[];
virtualModelProfiles?: unknown[];
};
@ -166,6 +167,7 @@ export type GatewayPluginContext = {
pluginId: string;
permissions: GatewayPluginPermission[];
openSqliteStore: (options?: PluginSqliteStoreOptions) => Promise<PluginSqliteStore>;
registerCoreGatewayPlugin: (plugin: unknown) => void;
registerCoreGatewayProviderPlugin: (providerPlugin: unknown) => void;
registerCoreGatewayVirtualModelProfile: (profile: unknown) => void;
registerApp: (app: GatewayPluginAppConfig) => void;
@ -238,6 +240,7 @@ type PluginPermissionAccess = {
type PluginServiceStateSnapshot = {
apps: InstalledBrowserApp[];
coreGatewayConfig: Record<string, unknown>;
coreGatewayPlugins: unknown[];
coreProviderPlugins: unknown[];
gatewayRequestTransforms: RegisteredGatewayRequestTransform[];
gatewayRoutes: RegisteredGatewayRoute[];
@ -253,6 +256,7 @@ const requireFromHere = createRequire(__filename);
class GatewayPluginService {
private config?: AppConfig;
private coreGatewayConfig: Record<string, unknown> = {};
private coreGatewayPlugins: unknown[] = [];
private coreProviderPlugins: unknown[] = [];
private apps: InstalledBrowserApp[] = [];
private gatewayRequestTransforms: RegisteredGatewayRequestTransform[] = [];
@ -308,6 +312,7 @@ class GatewayPluginService {
this.config = undefined;
this.apps = [];
this.coreGatewayConfig = {};
this.coreGatewayPlugins = [];
this.coreProviderPlugins = [];
this.gatewayRequestTransforms = [];
this.gatewayRoutes = [];
@ -316,8 +321,18 @@ class GatewayPluginService {
this.virtualModelProfiles = [];
}
hasGatewayRoutes(): boolean {
return this.gatewayRoutes.length > 0;
hasGatewayRoutes(options: { includePluginAdminRoutes?: boolean } = {}): boolean {
const includePluginAdminRoutes = options.includePluginAdminRoutes !== false;
return this.gatewayRoutes.some((route) =>
includePluginAdminRoutes || !isPluginAdminGatewayRoute(route)
);
}
hasGatewayRequestTransforms(options: { includeBuiltIns?: boolean } = {}): boolean {
const includeBuiltIns = options.includeBuiltIns !== false;
return this.gatewayRequestTransforms.some((transform) =>
includeBuiltIns || transform.pluginId !== "openrouter"
);
}
async applyGatewayRequestTransforms(input: GatewayPluginRequestTransformInput): Promise<GatewayPluginRequestTransformOutput> {
@ -396,7 +411,15 @@ class GatewayPluginService {
}
getCoreGatewayConfig(): Record<string, unknown> {
return { ...this.coreGatewayConfig };
const configuredPlugins = Array.isArray(this.coreGatewayConfig.plugins)
? this.coreGatewayConfig.plugins
: [];
return {
...this.coreGatewayConfig,
...(configuredPlugins.length + this.coreGatewayPlugins.length > 0
? { plugins: [...configuredPlugins, ...this.coreGatewayPlugins] }
: {})
};
}
getCoreProviderPlugins(): unknown[] {
@ -582,13 +605,17 @@ class GatewayPluginService {
]) {
this.virtualModelProfiles.push(profile);
}
if ((registration.coreGateway?.plugins ?? []).length > 0) {
this.requirePluginSurface(pluginConfig, "gateway", "register core gateway plugins");
this.requirePluginPermission(permissions, "core-gateway-plugins", "register core gateway plugins");
}
for (const plugin of registration.coreGateway?.plugins ?? []) {
this.coreGatewayPlugins.push(plugin);
}
if (registration.coreGateway?.config) {
this.requirePluginSurface(pluginConfig, "gateway", "register core gateway config");
this.requirePluginPermission(permissions, "core-gateway-config", "register core gateway config");
this.coreGatewayConfig = {
...this.coreGatewayConfig,
...registration.coreGateway.config
};
this.coreGatewayConfig = mergeCoreGatewayConfig(this.coreGatewayConfig, registration.coreGateway.config);
}
if (registration.stop) {
this.stopHooks.push({ pluginId, stop: registration.stop });
@ -632,12 +659,15 @@ class GatewayPluginService {
for (const profile of pluginConfig.coreGateway?.virtualModelProfiles ?? []) {
this.virtualModelProfiles.push(profile);
}
if ((pluginConfig.coreGateway?.plugins ?? []).length > 0) {
this.requirePluginPermission(permissions, "core-gateway-plugins", "register configured core gateway plugins");
}
for (const plugin of pluginConfig.coreGateway?.plugins ?? []) {
this.coreGatewayPlugins.push(plugin);
}
if (pluginConfig.coreGateway?.config) {
this.requirePluginPermission(permissions, "core-gateway-config", "register configured core gateway config");
this.coreGatewayConfig = {
...this.coreGatewayConfig,
...pluginConfig.coreGateway.config
};
this.coreGatewayConfig = mergeCoreGatewayConfig(this.coreGatewayConfig, pluginConfig.coreGateway.config);
}
}
@ -712,6 +742,11 @@ class GatewayPluginService {
this.requirePluginPermission(permissions, "sqlite-store", "open a SQLite store");
return this.openSqliteStore(pluginConfig.id, pluginDataDir, options);
},
registerCoreGatewayPlugin: (plugin) => {
this.requirePluginSurface(pluginConfig, "gateway", "register core gateway plugins");
this.requirePluginPermission(permissions, "core-gateway-plugins", "register core gateway plugins");
this.coreGatewayPlugins.push(plugin);
},
registerCoreGatewayProviderPlugin: (providerPlugin) => {
this.requirePluginSurface(pluginConfig, "provider", "register core provider plugins");
this.requirePluginPermission(permissions, "core-provider-plugins", "register core provider plugins");
@ -885,6 +920,7 @@ class GatewayPluginService {
return {
apps: [...this.apps],
coreGatewayConfig: { ...this.coreGatewayConfig },
coreGatewayPlugins: [...this.coreGatewayPlugins],
coreProviderPlugins: [...this.coreProviderPlugins],
gatewayRequestTransforms: [...this.gatewayRequestTransforms],
gatewayRoutes: [...this.gatewayRoutes],
@ -900,6 +936,7 @@ class GatewayPluginService {
const newStopHooks = this.stopHooks.slice(snapshot.stopHooks.length).reverse();
this.apps = snapshot.apps;
this.coreGatewayConfig = snapshot.coreGatewayConfig;
this.coreGatewayPlugins = snapshot.coreGatewayPlugins;
this.coreProviderPlugins = snapshot.coreProviderPlugins;
this.gatewayRequestTransforms = snapshot.gatewayRequestTransforms;
this.gatewayRoutes = snapshot.gatewayRoutes;
@ -1134,6 +1171,11 @@ function matchesPathPrefix(prefix: string, requestPath: string): boolean {
return normalizedPath === normalizedPrefix || normalizedPath.startsWith(`${normalizedPrefix.replace(/\/+$/, "")}/`);
}
function isPluginAdminGatewayRoute(route: RegisteredGatewayRoute): boolean {
const routePath = normalizeRoutePath(route.pathPrefix ?? route.path) ?? "/";
return routePath === "/plugins" || routePath.startsWith("/plugins/");
}
function joinUrlPaths(prefix: string, suffix: string): string {
const normalizedPrefix = prefix === "/" ? "" : prefix.replace(/\/+$/, "");
const normalizedSuffix = suffix.startsWith("/") ? suffix : `/${suffix}`;
@ -1290,6 +1332,37 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function mergeCoreGatewayConfig(
current: Record<string, unknown>,
patch: Record<string, unknown>,
path: string[] = []
): Record<string, unknown> {
const next = { ...current };
for (const [key, value] of Object.entries(patch)) {
const previous = next[key];
const childPath = [...path, key];
if (isRecord(previous) && isRecord(value)) {
next[key] = mergeCoreGatewayConfig(previous, value, childPath);
continue;
}
if (
Array.isArray(previous) &&
Array.isArray(value) &&
shouldAppendCoreGatewayConfigArray(childPath)
) {
next[key] = [...previous, ...value];
continue;
}
next[key] = value;
}
return next;
}
function shouldAppendCoreGatewayConfigArray(path: string[]): boolean {
const key = path.join(".");
return key === "plugins" || key === "agent.mcpServers";
}
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -81,6 +81,13 @@ const claudeCodeGatewayEnvKeys = [
"ANTHROPIC_API_BASE_URL",
"CLAUDE_AGENT_API_BASE_URL"
] as const;
const claudeCodeNoProxyEnvKeys = [
"NO_PROXY",
"no_proxy"
] as const;
const claudeCodeGatewayCompatibilityEnvKeys = [
"CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT"
] as const;
const claudeCodeRemovedAuthEnvKeys = [
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_API_KEY"
@ -209,6 +216,7 @@ export async function applyProfileConfig(
: applyCodexProfile(config, profile, token, appliedAt)
);
}
syncClaudeCodeManagedGatewaySettings(config, profiles);
result.clients.push(...takeoverStatuses);
cleanupManagedClaudeCodeToolHubArtifacts(profiles, { includeActive: false });
result.clients.push(...restoreInactiveGlobalProfileConfigs(profiles));
@ -384,23 +392,41 @@ function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token
const settingsFile = resolveClaudeCodeSettingsFile(profile);
if (!profile.enabled) {
cleanupClaudeCodeGeneratedFiles(profile);
return restoreDisabledGlobalProfile(profile, settingsFile, "Claude Code profile is disabled.", isManagedClaudeCodeSettingsContent);
return restoreDisabledGlobalProfile(profile, settingsFile, "Claude Code profile is disabled.", claudeCodeManagedContentPredicate(profile));
}
try {
const endpoint = gatewayEndpoint(config);
const settings = readClaudeCodeSettingsObject(settingsFile);
const settingsEnv = withoutBotGatewayEnv(Object.fromEntries(stringRecord(settings.env)));
const currentSettings = readClaudeCodeSettingsObject(settingsFile);
const profileSettingsState = readClaudeCodeProfileSettingsState(profile);
const profileSettings = claudeCodeProfileSettings(profile);
const profileSettingsEnv = withoutBotGatewayEnv(Object.fromEntries(stringRecord(profileSettings.env)));
delete profileSettings.env;
const profileEnvValues = profileEnv(profile);
const managedProfileEnvKeys = uniqueStrings([
...Object.keys(profileSettingsEnv),
...Object.keys(profileEnvValues)
]);
const managedSettingPaths = claudeCodeProfileSettingsPaths(profileSettings);
const managedSettingComparePaths = uniquePathStrings([...profileSettingsState.paths, ...managedSettingPaths]);
const settings = removeClaudeCodeProfileSettings(currentSettings, profileSettingsState.paths, managedSettingPaths);
const settingsEnv = removeClaudeCodeProfileEnv(
withoutBotGatewayEnv(Object.fromEntries(stringRecord(settings.env))),
profileSettingsState.envKeys,
managedProfileEnvKeys
);
delete settingsEnv[CLAUDE_CODE_MCP_CONFIG_ENV];
delete settingsEnv[CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV];
const profileEnvValues = profileEnv(profile);
const env = {
...settingsEnv,
...profileSettingsEnv,
...profileEnvValues
};
env.ANTHROPIC_BASE_URL = endpoint;
env.ANTHROPIC_API_BASE_URL = endpoint;
env.CLAUDE_AGENT_API_BASE_URL = endpoint;
applyGatewayNoProxyEnv(env, config);
applyClaudeCodeGatewayCompatibilityEnv(env);
for (const key of claudeCodeFirstPartyProviderEnvKeys) {
delete env[key];
}
@ -426,10 +452,15 @@ function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token
wifIdentityTokenFile: authMode === "wif" ? wifResult.file : undefined
}, toolHubMcpConfigResult.file);
const autoCompactResult = writeClaudeCodeAutoCompactWindowsCache(config, settingsFile);
const nextSettings = claudeCodeNextSettings(settings, env, authMode, helperResult?.file);
const managedEnvKeys = claudeCodeManagedSettingsEnvKeys(profileEnvValues, mcpConfigEnv, timezoneEnv, wifEnv);
const writeResult = writeClaudeCodeSettingsIfManagedChanged(settingsFile, settings, nextSettings, managedEnvKeys);
const nextSettings = claudeCodeNextSettings(settings, profileSettings, env, authMode, helperResult?.file);
const managedEnvKeys = claudeCodeManagedSettingsEnvKeys(profileEnvValues, profileSettingsEnv, mcpConfigEnv, timezoneEnv, wifEnv);
for (const key of profileSettingsState.envKeys) {
managedEnvKeys.add(key);
}
const writeResult = writeClaudeCodeSettingsIfManagedChanged(settingsFile, currentSettings, nextSettings, managedEnvKeys, managedSettingComparePaths);
const settingsStateResult = writeClaudeCodeProfileSettingsState(profile, managedSettingPaths, managedProfileEnvKeys);
const changed = writeResult.changed ||
settingsStateResult.changed ||
Boolean(helperResult?.changed) ||
wifResult.changed ||
wrapperResult.changed ||
@ -823,6 +854,48 @@ function claudeCodeToolHubMcpConfigFile(profile: ProfileConfig): string {
return path.join(ccrManagedProfileDir(profile), "claude", "toolhub-mcp.json");
}
function claudeCodeProfileSettingsStateFile(profile: ProfileConfig): string {
return path.join(ccrManagedProfileDir(profile), "claude", "settings-state.json");
}
function readClaudeCodeProfileSettingsState(profile: ProfileConfig): { envKeys: string[]; paths: string[] } {
const state = readJsonObject(claudeCodeProfileSettingsStateFile(profile));
const paths = Array.isArray(state.paths)
? state.paths.filter((item): item is string => typeof item === "string")
: [];
const envKeys = Array.isArray(state.envKeys)
? state.envKeys.filter((item): item is string => typeof item === "string")
: [];
return {
envKeys: uniqueStrings(envKeys),
paths: uniquePathStrings(paths)
};
}
function claudeCodeManagedContentPredicate(profile: ProfileConfig): (content: string) => boolean {
const profileSettings = claudeCodeProfileSettings(profile);
delete profileSettings.env;
const managedSettingPaths = uniquePathStrings([
...readClaudeCodeProfileSettingsState(profile).paths,
...claudeCodeProfileSettingsPaths(profileSettings)
]);
return (content) => isManagedClaudeCodeSettingsContent(content, managedSettingPaths);
}
function writeClaudeCodeProfileSettingsState(
profile: ProfileConfig,
paths: string[],
envKeys: string[]
): { changed: boolean } {
const file = claudeCodeProfileSettingsStateFile(profile);
const content = `${JSON.stringify({
version: 1,
envKeys: uniqueStrings(envKeys),
paths: uniquePathStrings(paths)
}, null, 2)}\n`;
return writeGeneratedFileIfChanged(file, content, { mode: privateFileMode });
}
function writeClaudeCodeToolHubMcpConfig(config: AppConfig, profile: ProfileConfig, token: string): { changed: boolean; file?: string } {
const file = claudeCodeToolHubMcpConfigFile(profile);
const entryPath = path.join(CONFIGDIR, "bin", TOOL_HUB_MCP_RUNTIME_FILE_NAME);
@ -1117,23 +1190,194 @@ function buildSeparateCodexProfileToml(
function claudeCodeNextSettings(
settings: Record<string, unknown>,
profileSettings: Record<string, unknown>,
env: Record<string, string>,
authMode: ClaudeCodeGatewayAuthMode,
apiKeyHelperFile: string | undefined
): Record<string, unknown> {
const mergedSettings = mergeClaudeCodeSettings(settings, profileSettings);
if (authMode === "api-key-helper" && apiKeyHelperFile) {
return {
...settings,
...mergedSettings,
apiKeyHelper: process.platform === "win32" ? `"${apiKeyHelperFile}"` : apiKeyHelperFile,
env
};
}
return {
...withoutManagedClaudeCodeApiKeyHelper(settings),
...withoutManagedClaudeCodeApiKeyHelper(mergedSettings),
env
};
}
function claudeCodeProfileSettings(profile: ProfileConfig): Record<string, unknown> {
return sanitizeClaudeCodeJsonObject(profile.claudeSettings);
}
function sanitizeClaudeCodeJsonObject(value: unknown): Record<string, unknown> {
if (!isRecord(value)) {
return {};
}
const result: Record<string, unknown> = {};
for (const [rawKey, rawValue] of Object.entries(value)) {
const key = rawKey.trim();
if (!key || key === "__proto__" || key === "constructor" || key === "prototype") {
continue;
}
const item = sanitizeClaudeCodeJsonValue(rawValue);
if (item !== undefined) {
result[key] = item;
}
}
return result;
}
function sanitizeClaudeCodeJsonValue(value: unknown): unknown {
if (value === null || typeof value === "string" || typeof value === "boolean") {
return value;
}
if (typeof value === "number") {
return Number.isFinite(value) ? value : undefined;
}
if (Array.isArray(value)) {
return value
.map(sanitizeClaudeCodeJsonValue)
.filter((item) => item !== undefined);
}
if (isRecord(value)) {
return sanitizeClaudeCodeJsonObject(value);
}
return undefined;
}
function mergeClaudeCodeSettings(
settings: Record<string, unknown>,
profileSettings: Record<string, unknown>
): Record<string, unknown> {
const merged = { ...settings };
for (const [key, value] of Object.entries(profileSettings)) {
const current = merged[key];
merged[key] = isRecord(current) && isRecord(value)
? mergeClaudeCodeSettings(current, value)
: value;
}
return merged;
}
function removeClaudeCodeProfileSettings(
settings: Record<string, unknown>,
previousPaths: string[],
currentPaths: string[]
): Record<string, unknown> {
const currentPathSet = new Set(currentPaths);
const removedPaths = previousPaths.filter((item) => !currentPathSet.has(item));
if (removedPaths.length === 0) {
return settings;
}
const next = cloneJsonObject(settings);
for (const item of removedPaths) {
deleteJsonPath(next, parseClaudeCodeSettingsPath(item));
}
return next;
}
function removeClaudeCodeProfileEnv(
env: Record<string, string>,
previousKeys: string[],
currentKeys: string[]
): Record<string, string> {
const currentKeySet = new Set(currentKeys);
const removedKeys = previousKeys.filter((key) => !currentKeySet.has(key));
if (removedKeys.length === 0) {
return env;
}
const next = { ...env };
for (const key of removedKeys) {
delete next[key];
}
return next;
}
function cloneJsonObject(value: Record<string, unknown>): Record<string, unknown> {
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
}
function claudeCodeProfileSettingsPaths(settings: Record<string, unknown>): string[] {
return uniquePathStrings(collectClaudeCodeSettingsPaths(settings));
}
function collectClaudeCodeSettingsPaths(value: unknown, parent: string[] = []): string[] {
if (!isRecord(value) || Object.keys(value).length === 0) {
return parent.length ? [formatClaudeCodeSettingsPath(parent)] : [];
}
return Object.entries(value).flatMap(([key, item]) => {
const nextParent = [...parent, key];
if (isRecord(item) && Object.keys(item).length > 0) {
return collectClaudeCodeSettingsPaths(item, nextParent);
}
return [formatClaudeCodeSettingsPath(nextParent)];
});
}
function formatClaudeCodeSettingsPath(pathParts: string[]): string {
return pathParts.join(".");
}
function parseClaudeCodeSettingsPath(value: string): string[] {
return value.split(".").map((part) => part.trim()).filter(Boolean);
}
function uniquePathStrings(values: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const normalized = formatClaudeCodeSettingsPath(parseClaudeCodeSettingsPath(value));
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
result.push(normalized);
}
return result;
}
function uniqueStrings(values: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const normalized = value.trim();
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
result.push(normalized);
}
return result;
}
function deleteJsonPath(target: Record<string, unknown>, pathParts: string[]): boolean {
if (pathParts.length === 0) {
return false;
}
const [key, ...rest] = pathParts;
if (!key || !hasOwn(target, key)) {
return false;
}
if (rest.length === 0) {
delete target[key];
return true;
}
const child = target[key];
if (!isRecord(child)) {
return false;
}
const changed = deleteJsonPath(child, rest);
if (changed && Object.keys(child).length === 0) {
delete target[key];
}
return changed;
}
function writeClaudeCodeWifIdentityToken(profile: ProfileConfig, token: string): { changed: boolean; file: string } {
const binDir = path.join(CONFIGDIR, "bin");
mkdirSync(binDir, { mode: privateDirMode, recursive: true });
@ -1498,7 +1742,7 @@ function kimiWrapperShellScript(config: AppConfig, profile: ProfileConfig, profi
const envExports = Object.entries(profileEnv(profile))
.filter(([key]) => !isKimiManagedEnvKey(key))
.map(([key, value]) => `export ${key}=${shellQuote(value)}`);
const noProxyHosts = grokGatewayNoProxyHosts(config);
const noProxyHosts = gatewayNoProxyHosts(config);
return [
"#!/bin/sh",
...envExports,
@ -1518,7 +1762,7 @@ function kimiWrapperCmdScript(config: AppConfig, profile: ProfileConfig, profile
const envExports = Object.entries(profileEnv(profile))
.filter(([key]) => !isKimiManagedEnvKey(key))
.map(([key, value]) => cmdSetLine(key, value));
const noProxyHosts = grokGatewayNoProxyHosts(config);
const noProxyHosts = gatewayNoProxyHosts(config);
return [
"@echo off",
...envExports,
@ -1592,7 +1836,7 @@ function piWrapperShellScript(
const envExports = Object.entries(profileEnv(profile))
.filter(([key]) => !isPiManagedEnvKey(key))
.map(([key, value]) => `export ${key}=${shellQuote(value)}`);
const noProxyHosts = grokGatewayNoProxyHosts(config);
const noProxyHosts = gatewayNoProxyHosts(config);
return [
"#!/bin/sh",
...envExports,
@ -1617,7 +1861,7 @@ function piWrapperCmdScript(
const envExports = Object.entries(profileEnv(profile))
.filter(([key]) => !isPiManagedEnvKey(key))
.map(([key, value]) => cmdSetLine(key, value));
const noProxyHosts = grokGatewayNoProxyHosts(config);
const noProxyHosts = gatewayNoProxyHosts(config);
return [
"@echo off",
...envExports,
@ -1934,7 +2178,7 @@ function grokWrapperShellScript(config: AppConfig, profile: ProfileConfig, token
.filter(([key]) => key !== "CCR_GROK_BIN" && !isGrokManagedEnvKey(key))
.map(([key, value]) => `export ${key}=${shellQuote(value)}`);
const gatewayBaseUrl = `${gatewayEndpoint(config).replace(/\/+$/g, "")}/v1`;
const noProxyHosts = grokGatewayNoProxyHosts(config);
const noProxyHosts = gatewayNoProxyHosts(config);
return [
"#!/bin/sh",
...envExports,
@ -1958,7 +2202,7 @@ function grokWrapperCmdScript(config: AppConfig, profile: ProfileConfig, token:
.filter(([key]) => key !== "CCR_GROK_BIN" && !isGrokManagedEnvKey(key))
.map(([key, value]) => cmdSetLine(key, value));
const gatewayBaseUrl = `${gatewayEndpoint(config).replace(/\/+$/g, "")}/v1`;
const noProxyHosts = grokGatewayNoProxyHosts(config);
const noProxyHosts = gatewayNoProxyHosts(config);
return [
"@echo off",
...envExports,
@ -1976,13 +2220,104 @@ function grokWrapperCmdScript(config: AppConfig, profile: ProfileConfig, token:
].join("\r\n");
}
function grokGatewayNoProxyHosts(config: AppConfig): string {
function gatewayNoProxyHosts(config: AppConfig): string {
const configuredHost = config.gateway.host === "0.0.0.0" || config.gateway.host === "::"
? "127.0.0.1"
: config.gateway.host?.trim().replace(/^\[|\]$/g, "") || "127.0.0.1";
return [...new Set([configuredHost, "127.0.0.1", "localhost", "::1"])].join(",");
}
function applyGatewayNoProxyEnv(env: Record<string, string>, config: AppConfig): void {
const hosts = gatewayNoProxyHosts(config).split(",");
env.NO_PROXY = mergeNoProxyHosts(env.NO_PROXY, hosts);
env.no_proxy = mergeNoProxyHosts(env.no_proxy, hosts);
}
function applyClaudeCodeGatewayCompatibilityEnv(env: Record<string, string>): void {
env.CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT = "1";
}
function syncClaudeCodeManagedGatewaySettings(config: AppConfig, profiles: ProfileConfig[]): void {
const claudeProfiles = profiles.filter((profile) => profile.agent === "claude-code");
if (claudeProfiles.length === 0) {
return;
}
const files = uniqueResolvedPaths([
"~/.claude/settings.json",
...claudeProfiles.map((profile) => profile.settingsFile || ""),
...claudeProfiles.map(resolveClaudeCodeSettingsFile),
...managedClaudeCodeSettingsFiles()
]);
for (const file of files) {
syncClaudeCodeManagedGatewaySettingsFile(config, file);
}
}
function syncClaudeCodeManagedGatewaySettingsFile(config: AppConfig, file: string): void {
if (!existsSync(file)) {
return;
}
let settings: Record<string, unknown>;
try {
settings = readClaudeCodeSettingsObject(file);
} catch {
return;
}
if (!isRecord(settings.env)) {
return;
}
const env = Object.fromEntries(stringRecord(settings.env));
if (!isManagedClaudeCodeGatewayEnv(env, config)) {
return;
}
const nextEnv = { ...env };
for (const key of claudeCodeGatewayEnvKeys) {
nextEnv[key] = gatewayEndpoint(config);
}
applyGatewayNoProxyEnv(nextEnv, config);
applyClaudeCodeGatewayCompatibilityEnv(nextEnv);
const nextSettings = {
...settings,
env: {
...settings.env,
...nextEnv
}
};
if (!claudeCodeSettingsManagedFieldsChanged(settings, nextSettings, claudeCodeManagedGatewaySyncEnvKeys())) {
chmodFileIfRequested(file, privateFileMode);
return;
}
writeFileWithBackup(file, `${JSON.stringify(nextSettings, null, 2)}\n`, { mode: privateFileMode });
}
function claudeCodeManagedGatewaySyncEnvKeys(): Set<string> {
return new Set([
...claudeCodeGatewayEnvKeys,
...claudeCodeNoProxyEnvKeys,
...claudeCodeGatewayCompatibilityEnvKeys
]);
}
function isManagedClaudeCodeGatewayEnv(env: Record<string, string>, config: AppConfig): boolean {
const endpoint = normalizeUrlForMatch(gatewayEndpoint(config));
const baseUrl = normalizeUrlForMatch(env.ANTHROPIC_BASE_URL);
const apiBaseUrl = normalizeUrlForMatch(env.ANTHROPIC_API_BASE_URL);
if (baseUrl === endpoint && apiBaseUrl === endpoint) {
return true;
}
return env.ANTHROPIC_FEDERATION_RULE_ID === claudeCodeWifFederationRuleId &&
env.ANTHROPIC_ORGANIZATION_ID === claudeCodeWifOrganizationId &&
[baseUrl, apiBaseUrl, normalizeUrlForMatch(env.CLAUDE_AGENT_API_BASE_URL)].includes(endpoint);
}
function mergeNoProxyHosts(current: string | undefined, hosts: string[]): string {
const values = [
...(current ?? "").split(","),
...hosts
].map((value) => value.trim()).filter(Boolean);
return [...new Set(values)].join(",");
}
function isGrokManagedEnvKey(key: string): boolean {
return key === "GROK_MODELS_BASE_URL" ||
key === "GROK_MODELS_LIST_URL" ||
@ -2139,6 +2474,8 @@ function claudeCodeRuntimeEnv(config: AppConfig, profile: ProfileConfig, setting
Object.assign(env, claudeCodeWifEnv(wifIdentityTokenFile));
}
Object.assign(env, claudeCodeProfileModelEnv(config, profile));
applyGatewayNoProxyEnv(env, config);
applyClaudeCodeGatewayCompatibilityEnv(env);
return env;
}
@ -2685,9 +3022,10 @@ function writeClaudeCodeSettingsIfManagedChanged(
file: string,
settings: Record<string, unknown>,
nextSettings: Record<string, unknown>,
managedEnvKeys: Set<string>
managedEnvKeys: Set<string>,
managedSettingPaths: string[] = []
): { backupFile?: string; changed: boolean } {
if (!claudeCodeSettingsManagedFieldsChanged(settings, nextSettings, managedEnvKeys)) {
if (!claudeCodeSettingsManagedFieldsChanged(settings, nextSettings, managedEnvKeys, managedSettingPaths)) {
chmodFileIfRequested(file, privateFileMode);
return { changed: false };
}
@ -2697,7 +3035,8 @@ function writeClaudeCodeSettingsIfManagedChanged(
function claudeCodeSettingsManagedFieldsChanged(
settings: Record<string, unknown>,
nextSettings: Record<string, unknown>,
managedEnvKeys: Set<string>
managedEnvKeys: Set<string>,
managedSettingPaths: string[] = []
): boolean {
if (settings.apiKeyHelper !== nextSettings.apiKeyHelper) {
return true;
@ -2717,9 +3056,31 @@ function claudeCodeSettingsManagedFieldsChanged(
return true;
}
}
for (const pathValue of managedSettingPaths) {
const pathParts = parseClaudeCodeSettingsPath(pathValue);
if (!jsonValuesEqual(readJsonPath(settings, pathParts), readJsonPath(nextSettings, pathParts))) {
return true;
}
}
return false;
}
function readJsonPath(value: Record<string, unknown>, pathParts: string[]): unknown {
let current: unknown = value;
for (const key of pathParts) {
if (!isRecord(current) || !hasOwn(current, key)) {
return undefined;
}
current = current[key];
}
return current;
}
function jsonValuesEqual(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
function withoutManagedClaudeCodeApiKeyHelper(settings: Record<string, unknown>): Record<string, unknown> {
const nextSettings = { ...settings };
if (isManagedClaudeCodeApiKeyHelper(nextSettings.apiKeyHelper)) {
@ -2734,15 +3095,19 @@ function isManagedClaudeCodeApiKeyHelper(value: unknown): boolean {
function claudeCodeManagedSettingsEnvKeys(
profileEnvValues: Record<string, string>,
profileSettingsEnv: Record<string, string>,
mcpConfigEnv: Record<string, string>,
timezoneEnv: Record<string, string>,
wifEnv: Record<string, string>
): Set<string> {
return new Set([
...claudeCodeGatewayEnvKeys,
...claudeCodeNoProxyEnvKeys,
...claudeCodeGatewayCompatibilityEnvKeys,
...claudeCodeFirstPartyProviderEnvKeys,
...claudeCodeWifEnvKeys,
...Object.keys(profileEnvValues),
...Object.keys(profileSettingsEnv),
...Object.keys(mcpConfigEnv),
...Object.keys(wifEnv),
...Object.keys(timezoneEnv),
@ -2753,6 +3118,8 @@ function claudeCodeManagedSettingsEnvKeys(
function isManagedClaudeCodeSettingsEnvKey(key: string): boolean {
return (claudeCodeGatewayEnvKeys as readonly string[]).includes(key) ||
(claudeCodeNoProxyEnvKeys as readonly string[]).includes(key) ||
(claudeCodeGatewayCompatibilityEnvKeys as readonly string[]).includes(key) ||
isClaudeCodeFirstPartyProviderEnvKey(key) ||
isClaudeCodeWifEnvKey(key) ||
key === CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV ||
@ -2968,7 +3335,7 @@ function restoreDisabledGlobalProfile(
function disabledProfileStatus(profile: ProfileConfig): ProfileClientApplyStatus {
if (profile.agent === "claude-code") {
return restoreDisabledGlobalProfile(profile, resolveClaudeCodeSettingsFile(profile), "Claude Code profile is disabled.", isManagedClaudeCodeSettingsContent);
return restoreDisabledGlobalProfile(profile, resolveClaudeCodeSettingsFile(profile), "Claude Code profile is disabled.", claudeCodeManagedContentPredicate(profile));
}
if (profile.agent === "zcode") {
return restoreDisabledZcodeProfile(profile, resolveZcodeConfigFile(profile));
@ -3517,12 +3884,12 @@ function isGlobalProfile(profile: ProfileConfig): boolean {
return normalizeProfileScope(profile.scope) === "global";
}
function isManagedClaudeCodeSettingsContent(content: string): boolean {
function isManagedClaudeCodeSettingsContent(content: string, managedSettingPaths: string[] = []): boolean {
const settings = parseJsonContent(content);
if (!settings) {
return false;
}
if (!isPureManagedClaudeCodeSettings(settings)) {
if (!isPureManagedClaudeCodeSettings(settings, managedSettingPaths)) {
return false;
}
if (isManagedClaudeCodeApiKeyHelper(settings.apiKeyHelper)) {
@ -3534,9 +3901,10 @@ function isManagedClaudeCodeSettingsContent(content: string): boolean {
typeof env.CLAUDE_AGENT_API_BASE_URL === "string";
}
function isPureManagedClaudeCodeSettings(settings: Record<string, unknown>): boolean {
const rootKeys = Object.keys(settings);
if (rootKeys.some((key) => key !== "apiKeyHelper" && key !== "env")) {
function isPureManagedClaudeCodeSettings(settings: Record<string, unknown>, managedSettingPaths: string[] = []): boolean {
const extraSettings = Object.fromEntries(Object.entries(settings).filter(([key]) => key !== "apiKeyHelper" && key !== "env"));
const extraPaths = collectClaudeCodeSettingsPaths(extraSettings);
if (extraPaths.some((pathValue) => !isClaudeCodeProfileManagedSettingsPath(pathValue, managedSettingPaths))) {
return false;
}
if ("apiKeyHelper" in settings && typeof settings.apiKeyHelper !== "string") {
@ -3551,6 +3919,15 @@ function isPureManagedClaudeCodeSettings(settings: Record<string, unknown>): boo
return Object.keys(settings.env).every((key) => isManagedClaudeCodeSettingsEnvKey(key));
}
function isClaudeCodeProfileManagedSettingsPath(pathValue: string, managedSettingPaths: string[]): boolean {
const normalized = formatClaudeCodeSettingsPath(parseClaudeCodeSettingsPath(pathValue));
return Boolean(normalized) && uniquePathStrings(managedSettingPaths).some((managedPath) =>
normalized === managedPath ||
normalized.startsWith(`${managedPath}.`) ||
managedPath.startsWith(`${normalized}.`)
);
}
function isManagedCodexConfigContent(content: string, providerId: string): boolean {
if (content.includes(managedRootStart) || content.includes(managedProviderStart)) {
return true;

View file

@ -522,6 +522,18 @@ export async function recordGatewayUsageCapture(input: UsageCaptureInput): Promi
}
}
export async function recordGatewayUsageCaptureIfMissing(input: UsageCaptureInput): Promise<void> {
try {
const requestId = input.requestId?.trim();
if (requestId && await usageStore.hasRequestId(requestId)) {
return;
}
await usageStore.recordCapture(input);
} catch (error) {
console.warn(`[usage] Failed to record usage: ${formatError(error)}`);
}
}
function resolveUsageResponseModelAttribution(
config: Pick<AppConfig, "Providers" | "virtualModelProfiles"> | undefined,
model: string | undefined

View file

@ -13,6 +13,8 @@ test("RequestLogStore commits while a background pricing refresh is stalled", as
const first = new RequestLogStore(dbFile);
const second = new RequestLogStore(dbFile);
const previousFetch = globalThis.fetch;
const previousNoProxy = process.env.NO_PROXY;
const previousLowerNoProxy = process.env.no_proxy;
let releaseFetch;
let signalFetchStarted;
const fetchGate = new Promise((resolve) => {
@ -39,7 +41,9 @@ test("RequestLogStore commits while a background pricing refresh is stalled", as
let pricingRefresh;
let fetchStartTimeout;
try {
pricingRefresh = preloadUsagePriceCatalog();
process.env.NO_PROXY = "*";
process.env.no_proxy = "*";
pricingRefresh = preloadUsagePriceCatalog({ forceRefresh: true });
await Promise.race([
fetchStarted,
new Promise((_, reject) => {
@ -109,6 +113,10 @@ test("RequestLogStore commits while a background pricing refresh is stalled", as
clearTimeout(fetchStartTimeout);
releaseFetch?.();
await pricingRefresh?.catch(() => undefined);
if (previousNoProxy === undefined) delete process.env.NO_PROXY;
else process.env.NO_PROXY = previousNoProxy;
if (previousLowerNoProxy === undefined) delete process.env.no_proxy;
else process.env.no_proxy = previousLowerNoProxy;
globalThis.fetch = previousFetch;
await Promise.all([first.close(), second.close()]);
rmSync(dir, { force: true, recursive: true });

View file

@ -62,6 +62,50 @@ test("RequestLogRuntime merges a raw trace update that arrives before its reques
}
});
test("RequestLogRuntime creates a standalone record from single-service raw trace", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-runtime-standalone-test-"));
const runtime = createRuntime(dir);
const startedAt = "2026-08-26T01:02:03.004Z";
const completedAt = "2026-08-26T01:02:04.238Z";
try {
const result = runtime.enqueueRawTrace({
allowStandaloneRecord: true,
bodyCapturePolicy: "all",
completedAt,
deferOutcomeUntilRecord: true,
durationMs: 1234,
method: "POST",
model: "standalone-model",
path: "/v1/messages",
provider: "standalone-provider",
requestBodyText: JSON.stringify({ model: "standalone-model" }),
requestHeaders: { "content-type": "application/json", "user-agent": "openai-codex test" },
requestId: "standalone-raw-trace",
responseBodyText: JSON.stringify({ usage: { input_tokens: 3, output_tokens: 4 } }),
responseHeaders: { "content-type": "application/json" },
startedAt,
statusCode: 200,
url: "http://127.0.0.1:3456/v1/messages"
});
assert.deepEqual(result, { accepted: true, degraded: false });
await runtime.flush({ timeoutMs: 10_000 });
const page = await runtime.list({ pageSize: 25 });
assert.equal(page.items.length, 1);
assert.equal(page.items[0].requestId, "standalone-raw-trace");
assert.equal(page.items[0].model, "standalone-model");
assert.equal(page.items[0].provider, "standalone-provider");
assert.equal(page.items[0].client, "Codex");
assert.equal(page.items[0].createdAt, startedAt);
assert.equal(page.items[0].completedAt, completedAt);
assert.equal(page.items[0].durationMs, 1234);
assert.equal(page.items[0].totalTokens, 7);
} finally {
await runtime.close({ timeoutMs: 5_000 });
rmSync(dir, { force: true, recursive: true });
}
});
test("RequestLogStore resolves unknown raw trace body capture from the final request status", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-raw-policy-test-"));
const store = new RequestLogStore(path.join(dir, "request-logs.sqlite"));
@ -278,6 +322,50 @@ test("RequestLogStore applies same-batch raw trace updates in sequence order", a
}
});
test("RequestLogStore preserves aggregate response bodies when raw trace streams arrive later", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-aggregate-response-test-"));
const store = new RequestLogStore(path.join(dir, "request-logs.sqlite"));
try {
await store.writeBatch([
{
eventId: "aggregate-response-event",
input: {
...createRecord("aggregate-response"),
responseBodyText: JSON.stringify({
content: [{ text: "aggregated assistant answer", type: "text" }],
model: "worker-model"
}),
responseHeaders: { "content-type": "application/json" }
},
kind: "record",
sequence: 1
},
{
input: {
isStream: true,
requestId: "aggregate-response",
responseBodyContentType: "text/event-stream",
responseBodyText: "event: message_start\ndata: {\"type\":\"message_start\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"text\":\"stream wire data\"}}\n\n",
responseHeaders: { "content-type": "text/event-stream" },
statusCode: 200
},
kind: "raw-trace-update",
sequence: 2
}
]);
const page = await store.list({ pageSize: 25 });
const detail = await store.getDetail({ id: page.items[0].id });
assert.match(detail.responseBody.text, /aggregated assistant answer/);
assert.doesNotMatch(detail.responseBody.text, /stream wire data/);
assert.equal(detail.statusCode, 200);
assert.equal(detail.isStream, true);
} finally {
await store.close();
rmSync(dir, { force: true, recursive: true });
}
});
test("RequestLogRuntime rejects an event that exceeds its hard queue byte limit", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-runtime-bound-test-"));
const runtime = createRequestLogRuntime({

View file

@ -5,6 +5,7 @@ import path from "node:path";
import test from "node:test";
import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts";
import { compileCoreGatewayConfig, coreGatewayUsageAttributionConfig } from "@ccr/core/gateway/core-runtime/config-compiler.ts";
import { gatewayRuntimeSupportsRouterPlugin } from "@ccr/core/gateway/core-runtime/supervisor.ts";
import { pluginService } from "@ccr/core/plugins/service.ts";
import { resolveUsageModelAttribution } from "@ccr/core/usage/model-attribution.ts";
@ -22,6 +23,7 @@ module.exports = async function brokenPlugin(context) {
context.registerApp({ id: "broken-context-app", name: "Broken context", url: "http://broken-context.local" });
context.registerGatewayRoute({ id: "broken-context-route", path: "/broken-context", handler(_request, response) { response.end("broken"); } });
context.registerGatewayRequestTransform({ id: "broken-transform", transform(input) { return { headers: { "x-broken-transform": input.requestId } }; } });
context.registerCoreGatewayPlugin({ key: "broken-context-core-plugin", modulePath: "/broken-context-core-plugin.cjs" });
context.registerCoreGatewayProviderPlugin({ key: "broken-context-provider" });
context.registerCoreGatewayVirtualModelProfile({ id: "broken-context-vm" });
context.registerProviderAccountConnector({ id: "broken-account", resolve() { return []; } });
@ -31,6 +33,7 @@ module.exports = async function brokenPlugin(context) {
writeFileSync(goodPlugin, `
module.exports = {
setup(context) {
context.registerCoreGatewayPlugin({ key: "good-context-core-plugin", modulePath: "/good-context-core-plugin.cjs" });
context.registerCoreGatewayProviderPlugin({ key: "good-context-provider" });
context.registerCoreGatewayVirtualModelProfile({ id: "good-context-vm" });
return {
@ -44,6 +47,7 @@ module.exports = {
}
}
},
plugins: [{ key: "good-core-plugin", modulePath: "/good-core-plugin.cjs" }],
providerPlugins: [{ key: "good-provider" }],
virtualModelProfiles: [{
baseModel: { fixedModel: "Good Provider/good-model", mode: "fixed" },
@ -155,7 +159,11 @@ module.exports = {
rates: {
openai: { inputPerMillionUsd: 1, outputPerMillionUsd: 2 }
}
}
},
plugins: [
{ key: "good-context-core-plugin", modulePath: "/good-context-core-plugin.cjs" },
{ key: "good-core-plugin", modulePath: "/good-core-plugin.cjs" }
]
});
const compiled = await compileCoreGatewayConfig(
config,
@ -169,6 +177,12 @@ module.exports = {
openai: { inputPerMillionUsd: 1, outputPerMillionUsd: 2 }
}
});
assert.equal(
compiled.plugins.some((plugin) => plugin.key === "ccr-router"),
gatewayRuntimeSupportsRouterPlugin()
);
assert.ok(compiled.plugins.some((plugin) => plugin.key === "good-context-core-plugin"));
assert.ok(compiled.plugins.some((plugin) => plugin.key === "good-core-plugin"));
assert.deepEqual(
resolveUsageModelAttribution(coreGatewayUsageAttributionConfig(config), "Fusion/plugin-usage"),
{

View file

@ -423,6 +423,94 @@ test("profile service does not rewrite Claude settings when only user-managed fi
}
});
test("profile service applies and removes profile Claude settings", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => {
const root = mkdtempSync(path.join(os.tmpdir(), "ccr-claude-profile-settings-"));
const takeoverFile = path.join(CONFIGDIR, "global-profile-takeover.json");
const profileId = "custom-claude-settings";
try {
rmSync(takeoverFile, { force: true });
const settingsFile = path.join(root, ".claude", "settings.json");
mkdirSync(path.dirname(settingsFile), { recursive: true });
writeFileSync(settingsFile, `${JSON.stringify({
env: {
USER_VALUE: "kept"
},
permissions: {
allow: ["Bash(echo:*)"],
defaultMode: "ask"
},
theme: "dark"
}, null, 2)}\n`);
const profile = {
agent: "claude-code",
claudeSettings: {
includeCoAuthoredBy: false,
permissions: {
defaultMode: "acceptEdits"
}
},
enabled: true,
env: {
CCR_CLAUDE_CODE_AUTH_MODE: "wif",
CLAUDE_CODE_MAX_RETRIES: "3"
},
id: profileId,
model: "Provider/model",
name: "Custom Claude Settings",
scope: "global",
settingsFile,
smallFastModel: "",
surface: "auto"
};
const config = createDefaultAppConfig();
config.APIKEY = "ccr-profile-claude-settings-test";
config.APIKEYS = [{
createdAt: "2026-01-01T00:00:00.000Z",
id: `profile:${profile.id}`,
key: "ccr-profile-claude-settings-test",
name: "Profile: Custom Claude Settings"
}];
config.Providers = [{
api_base_url: "https://example.test/v1",
api_key: "provider-key",
models: ["model"],
name: "Provider"
}];
config.profile.profiles = [profile];
const firstResult = await applyProfileConfig(config);
assert.equal(firstResult.clients.some((client) => client.client === "claude-code" && client.ok), true);
const firstSettings = JSON.parse(readFileSync(settingsFile, "utf8"));
assert.equal(firstSettings.includeCoAuthoredBy, false);
assert.equal(firstSettings.permissions.defaultMode, "acceptEdits");
assert.deepEqual(firstSettings.permissions.allow, ["Bash(echo:*)"]);
assert.equal(firstSettings.theme, "dark");
assert.equal(firstSettings.env.USER_VALUE, "kept");
assert.equal(firstSettings.env.CLAUDE_CODE_MAX_RETRIES, "3");
config.profile.profiles = [{
...profile,
claudeSettings: {},
env: {
CCR_CLAUDE_CODE_AUTH_MODE: "wif"
}
}];
const secondResult = await applyProfileConfig(config);
assert.equal(secondResult.clients.some((client) => client.client === "claude-code" && client.ok), true);
const secondSettings = JSON.parse(readFileSync(settingsFile, "utf8"));
assert.equal(secondSettings.includeCoAuthoredBy, undefined);
assert.equal(secondSettings.permissions.defaultMode, undefined);
assert.deepEqual(secondSettings.permissions.allow, ["Bash(echo:*)"]);
assert.equal(secondSettings.env.CLAUDE_CODE_MAX_RETRIES, undefined);
assert.equal(secondSettings.env.USER_VALUE, "kept");
} finally {
restoreGlobalProfileConfigsOnExit([], { manageMarker: true });
rmSync(takeoverFile, { force: true });
rmSync(root, { force: true, recursive: true });
}
});
test("profile service can exclude ZCode from automatic synchronization", async () => {
const root = mkdtempSync(path.join(os.tmpdir(), "ccr-zcode-auto-sync-"));
try {
@ -610,6 +698,9 @@ test("profile service overwrites generated bin files without creating backups",
assert.equal(settings.env.ANTHROPIC_FEDERATION_RULE_ID, "ccr-local");
assert.equal(settings.env.ANTHROPIC_ORGANIZATION_ID, "ccr-local");
assert.equal(settings.env.ANTHROPIC_IDENTITY_TOKEN_FILE, wifTokenFile);
assert.equal(settings.env.NO_PROXY, "127.0.0.1,localhost,::1");
assert.equal(settings.env.no_proxy, "127.0.0.1,localhost,::1");
assert.equal(settings.env.CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT, "1");
assert.equal(settings.env.ANTHROPIC_MODEL, "Provider/long[1m]");
assert.equal(settings.env.CCR_CLAUDE_CODE_MODEL, "Provider/long[1m]");
assert.equal(settings.env.CODEXL_CLAUDE_CODE_MODEL, "Provider/long[1m]");
@ -621,6 +712,8 @@ test("profile service overwrites generated bin files without creating backups",
assert.equal(settings.env.CLAUDE_CODE_USE_GATEWAY, undefined);
assert.equal(settings.env.ANTHROPIC_AUTH_TOKEN, undefined);
assert.equal(settings.env.ANTHROPIC_API_KEY, undefined);
assert.match(wrapperContent, /NO_PROXY.*127\.0\.0\.1,localhost,::1/);
assert.match(wrapperContent, /CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT.*1/);
assert.equal(settings.modelOverrides, undefined);
const claudeGlobalConfig = JSON.parse(readFileSync(path.join(claudeProfileDir, ".claude.json"), "utf8"));
assert.equal(claudeGlobalConfig.firstStartTime, "2026-01-01T00:00:00.000Z");
@ -641,6 +734,79 @@ test("profile service overwrites generated bin files without creating backups",
assert.deepEqual(backupEntries, []);
});
test("profile service refreshes CCR-managed global Claude gateway env for generated profiles", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => {
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-generated-claude-global-env-"));
const previousHome = process.env.HOME;
const takeoverFile = path.join(CONFIGDIR, "global-profile-takeover.json");
try {
process.env.HOME = home;
rmSync(takeoverFile, { force: true });
const settingsFile = path.join(home, ".claude", "settings.json");
mkdirSync(path.dirname(settingsFile), { recursive: true });
writeFileSync(settingsFile, `${JSON.stringify({
alwaysThinkingEnabled: true,
env: {
ANTHROPIC_API_BASE_URL: "http://127.0.0.1:3456",
ANTHROPIC_BASE_URL: "http://127.0.0.1:3456",
ANTHROPIC_FEDERATION_RULE_ID: "ccr-local",
ANTHROPIC_ORGANIZATION_ID: "ccr-local",
CLAUDE_AGENT_API_BASE_URL: "http://127.0.0.1:3456",
USER_VALUE: "kept"
}
}, null, 2)}\n`);
const config = createDefaultAppConfig();
config.APIKEY = "ccr-generated-global-env-test";
config.APIKEYS = [{
createdAt: "2026-01-01T00:00:00.000Z",
id: "profile:generated-global-env-test",
key: "ccr-generated-global-env-test",
name: "Profile: Generated Global Env Test"
}];
config.Providers = [{
api_base_url: "https://example.test/v1",
api_key: "provider-key",
models: ["model"],
name: "Provider"
}];
config.profile.profiles = [{
agent: "claude-code",
enabled: true,
env: {
CCR_CLAUDE_CODE_AUTH_MODE: "wif"
},
id: "generated-global-env-test",
model: "Provider/model",
name: "Generated Global Env Test",
scope: "ccr",
settingsFile: "~/.claude/settings.json",
smallFastModel: "",
surface: "auto"
}];
const result = await applyProfileConfig(config);
assert.equal(result.clients.some((client) => client.client === "claude-code" && client.ok), true);
const settings = JSON.parse(readFileSync(settingsFile, "utf8"));
assert.equal(settings.alwaysThinkingEnabled, true);
assert.equal(settings.env.USER_VALUE, "kept");
assert.equal(settings.env.ANTHROPIC_BASE_URL, "http://127.0.0.1:3456");
assert.equal(settings.env.ANTHROPIC_API_BASE_URL, "http://127.0.0.1:3456");
assert.equal(settings.env.CLAUDE_AGENT_API_BASE_URL, "http://127.0.0.1:3456");
assert.equal(settings.env.NO_PROXY, "127.0.0.1,localhost,::1");
assert.equal(settings.env.no_proxy, "127.0.0.1,localhost,::1");
assert.equal(settings.env.CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT, "1");
} finally {
restoreGlobalProfileConfigsOnExit([], { manageMarker: true });
rmSync(takeoverFile, { force: true });
rmSync(home, { force: true, recursive: true });
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
}
});
test("profile service falls back to apiKeyHelper when Claude Code WIF support is not detected", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => {
const root = mkdtempSync(path.join(os.tmpdir(), "ccr-claude-auth-mode-"));
const profileId = "old-claude-code";

View file

@ -92,7 +92,19 @@ test("gateway pipeline preserves Claude Code hex model id in Anthropic SSE respo
assert.doesNotMatch(result.output, /"model":"k3"/);
});
async function runAnthropicPipelineModelRewrite(requestModel) {
test("gateway pipeline strips client supplied CCR route headers", async () => {
const result = await runAnthropicPipelineModelRewrite("kimi-test/k3", {
"x-ccr-route-fallback": "forged-fallback",
"x-ccr-route-stage": "forged-stage",
"x-ccr-routed-model": "Other/forged"
});
assert.equal(result.upstreamHeaders?.["x-ccr-route-fallback"], undefined);
assert.equal(result.upstreamHeaders?.["x-ccr-route-stage"], undefined);
assert.equal(result.upstreamHeaders?.["x-ccr-routed-model"], "kimi-test/k3");
});
async function runAnthropicPipelineModelRewrite(requestModel, requestHeaders = {}) {
const config = createPipelineConfigForAnthropicModelRewrite();
const plugin = new ClaudeCodeRouterPlugin(config);
const pipeline = new GatewayRequestPipeline({
@ -108,6 +120,7 @@ async function runAnthropicPipelineModelRewrite(requestModel) {
const originalFetch = globalThis.fetch;
const originalWarn = console.warn;
let upstreamBody;
let upstreamHeaders;
console.warn = (message, ...args) => {
if (String(message).startsWith("[usage] Failed to record usage:")) {
return;
@ -116,6 +129,7 @@ async function runAnthropicPipelineModelRewrite(requestModel) {
};
globalThis.fetch = async (_input, init) => {
upstreamBody = JSON.parse(String(init?.body));
upstreamHeaders = Object.fromEntries(new Headers(init?.headers).entries());
return new Response(
[
"event: message_start\n",
@ -143,7 +157,8 @@ async function runAnthropicPipelineModelRewrite(requestModel) {
})]);
request.headers = {
"content-type": "application/json",
"user-agent": "claude-code/1.0"
"user-agent": "claude-code/1.0",
...requestHeaders
};
request.method = "POST";
request.url = "/v1/messages";
@ -159,7 +174,8 @@ async function runAnthropicPipelineModelRewrite(requestModel) {
return {
output: response.bodyText(),
response,
upstreamBody
upstreamBody,
upstreamHeaders
};
} finally {
await new Promise((resolve) => setImmediate(resolve));

View file

@ -50,6 +50,10 @@ test("gateway authorization accepts the configured API key on every supported ca
for (const request of [
{ headers: { authorization: `Bearer ${gatewayApiKey}` } },
{ headers: { "x-api-key": gatewayApiKey } },
{ headers: { "api-key": gatewayApiKey } },
{ headers: { "x-goog-api-key": gatewayApiKey } },
{ headers: { "x-mcp-key": gatewayApiKey } },
{ headers: { "x-codex-access-token": gatewayApiKey } },
{ url: `/__ccr/remote/status?api_key=${gatewayApiKey}` }
]) {
const { response, result } = await authorizeRequest(config, request);

View file

@ -3,44 +3,67 @@ 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 { rawTraceSyncHeader, rawTraceSyncPath } from "@ccr/core/gateway/internal/shared.ts";
import { publicGatewayAuthTokens } from "@ccr/core/gateway/core-runtime/supervisor.ts";
test("core gateway disables the full-trace billing webhook without disabling raw-trace observability", async () => {
const config = createDefaultAppConfig();
config.gateway.host = "0.0.0.0";
config.gateway.port = 4567;
config.observability.requestLogs = true;
const previousRawTraceEnabled = process.env.CCR_RAW_TRACE_ENABLED;
process.env.CCR_RAW_TRACE_ENABLED = "1";
try {
const compiled = await compileCoreGatewayConfig(
config,
"raw-trace-token",
"billing-usage-token",
"core-auth-token"
);
const compiled = await compileCoreGatewayConfig(
config,
"raw-trace-token",
"billing-usage-token",
"core-auth-token"
);
assert.deepEqual(compiled.billingWebhook, { enabled: false });
assert.deepEqual(compiled.billingQueue, { enabled: false });
assert.deepEqual(compiled.billing, { enabled: true });
const upstreamHeaderSanitizer = compiled.plugins?.at(-1);
assert.equal(upstreamHeaderSanitizer?.enabled, true);
assert.equal(upstreamHeaderSanitizer?.key, "ccr-upstream-header-sanitizer");
assert.match(upstreamHeaderSanitizer?.modulePath, /upstream-header-sanitizer\.js$/);
assert.equal(compiled.rawTrace?.enabled, true);
assert.deepEqual(compiled.rawTrace?.sync, {
enabled: true,
endpoint: `http://127.0.0.1:4567${rawTraceSyncPath}`,
headers: { [rawTraceSyncHeader]: "raw-trace-token" },
timeoutMs: 5_000
});
} finally {
if (previousRawTraceEnabled === undefined) {
delete process.env.CCR_RAW_TRACE_ENABLED;
} else {
process.env.CCR_RAW_TRACE_ENABLED = previousRawTraceEnabled;
assert.deepEqual(compiled.billingWebhook, { enabled: false });
assert.deepEqual(compiled.billingQueue, { enabled: false });
assert.deepEqual(compiled.billing, { enabled: true });
const upstreamHeaderSanitizer = compiled.plugins?.at(-1);
assert.equal(upstreamHeaderSanitizer?.enabled, true);
assert.equal(upstreamHeaderSanitizer?.key, "ccr-upstream-header-sanitizer");
assert.match(upstreamHeaderSanitizer?.modulePath, /upstream-header-sanitizer\.js$/);
assert.equal(compiled.rawTrace?.enabled, true);
assert.deepEqual(compiled.rawTrace?.sync, {
enabled: true,
endpoint: `http://127.0.0.1:4567${rawTraceSyncPath}`,
headers: { [rawTraceSyncHeader]: "raw-trace-token" },
timeoutMs: 5_000
});
});
test("core gateway public mode listens on the public gateway endpoint and accepts user API keys", async () => {
const config = createDefaultAppConfig();
config.gateway.host = "127.0.0.1";
config.gateway.port = 4567;
config.gateway.coreHost = "127.0.0.1";
config.gateway.corePort = 4568;
config.APIKEY = "client-key";
config.APIKEYS = [{ createdAt: new Date(0).toISOString(), id: "client", key: "client-key" }];
const compiled = await compileCoreGatewayConfig(
config,
"raw-trace-token",
"billing-usage-token",
"core-auth-token",
undefined,
undefined,
{
publicGatewayMode: true,
runtimeHost: config.gateway.host,
runtimePort: config.gateway.port
}
}
);
assert.equal(compiled.host, "127.0.0.1");
assert.equal(compiled.port, 4567);
assert.equal(compiled.auth.staticApiKeys.keyHeader, "authorization");
assert.deepEqual(compiled.auth.staticApiKeys.keys, ["core-auth-token", "client-key"]);
assert.deepEqual(publicGatewayAuthTokens(config, "core-auth-token"), ["core-auth-token", "client-key"]);
assert.equal(compiled.rawTrace.enabled, true);
assert.equal(compiled.rawTrace.mode, "body_redacted");
});
test("Codex OAuth providers remove unsupported Responses request fields", async () => {

View file

@ -116,3 +116,54 @@ test("main-process-only observability changes do not restart the gateway runtime
assert.equal(shouldRestartGatewayForRuntimeConfigChange(previous, next), false);
});
test("router plugin input changes restart the gateway runtime", () => {
const previous = createDefaultAppConfig();
const next = createDefaultAppConfig();
next.Router.rules = [
{
enabled: true,
id: "force-haiku",
name: "Force Haiku",
pattern: "haiku:",
target: "anthropic,claude-3-5-haiku-latest",
type: "model-prefix"
}
];
assert.equal(shouldRestartGatewayForRuntimeConfigChange(previous, next), true);
});
test("public API key changes restart the gateway runtime", () => {
const previous = createDefaultAppConfig();
const next = createDefaultAppConfig();
next.APIKEYS = [{ createdAt: new Date(0).toISOString(), id: "local", key: "client-key" }];
assert.equal(shouldRestartGatewayForRuntimeConfigChange(previous, next), true);
});
test("profile allowlist changes restart the gateway runtime", () => {
const previous = createDefaultAppConfig();
const next = createDefaultAppConfig();
next.profile.profiles = next.profile.profiles.map((profile) => profile.id === "default-claude-code"
? { ...profile, modelAllowlist: { enabled: true, models: ["glm-4.5-air"] } }
: profile);
assert.equal(shouldRestartGatewayForRuntimeConfigChange(previous, next), true);
});
test("core gateway timeout changes restart the gateway runtime", () => {
const previous = createDefaultAppConfig();
const next = createDefaultAppConfig();
next.API_TIMEOUT_MS = 30000;
assert.equal(shouldRestartGatewayForRuntimeConfigChange(previous, next), true);
});
test("context archive mode changes restart the gateway runtime", () => {
const previous = createDefaultAppConfig();
const next = createDefaultAppConfig();
next.contextArchive.enabled = true;
assert.equal(shouldRestartGatewayForRuntimeConfigChange(previous, next), true);
});

View file

@ -41,6 +41,10 @@ test("gateway client inference honors explicit, proxy, API-key, and user-agent i
test("gateway authentication accepts supported headers and scopes query tokens to remote control", () => {
assert.equal(readAuthToken({ authorization: " Bearer secret-token " }), "secret-token");
assert.equal(readAuthToken({ "x-api-key": " api-key-token " }), "api-key-token");
assert.equal(readAuthToken({ "api-key": " legacy-token " }), "legacy-token");
assert.equal(readAuthToken({ "x-goog-api-key": " google-token " }), "google-token");
assert.equal(readAuthToken({ "x-mcp-key": " mcp-token " }), "mcp-token");
assert.equal(readAuthToken({ "x-codex-access-token": " codex-token " }), "codex-token");
assert.equal(readAuthToken({}), undefined);
assert.equal(readHeader([" first ", "second"]), "first");
@ -72,6 +76,9 @@ test("gateway header forwarding strips hop-by-hop, local auth, and observability
const authHeaders = {
"api-key": "legacy",
authorization: "Bearer local",
"x-codex-access-token": "codex",
"x-goog-api-key": "google",
"x-mcp-key": "mcp",
"x-api-key": "local",
"x-keep": "yes"
};

View file

@ -0,0 +1,671 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts";
import {
ccrCodexApplyPatchBridgeHeader,
ccrCodexBridgeRequestTransformKey,
ccrCodexBridgeResponseHookKey,
ccrCodexBridgeStreamHookKey,
ccrCodexMultiAgentBridgeHeader,
ccrOpenRouterDiscountFinalizeResponseHookKey,
ccrRuntimeConfigReloadMessageType,
ccrRouteReasonHeader,
ccrRouteSourceHeader,
ccrRoutedModelHeader,
ccrRouterHttpRoutePath,
ccrRouterRouteResolverKey,
ccrRouterRequestTransformKey
} from "@ccr/core/gateway/core-runtime/router-plugin-contract.ts";
import { coreGatewayAuthHeader } from "@ccr/core/gateway/internal/shared.ts";
import { ccrRemoteControlPathPrefix } from "@ccr/core/gateway/remote-control-service.ts";
import { gatewayRuntimeConfigControlPath, gatewayRuntimeConfigRevision } from "@ccr/core/gateway/runtime-config-control.ts";
import { createGatewayPlugin } from "@ccr/core/gateway/core-runtime/router-plugin.ts";
import { providerRuntimeId } from "@ccr/core/routing/model-registry.ts";
test("CCR router core plugin exposes route endpoint and beforeRouting transform", async () => {
const config = createDefaultAppConfig();
config.Providers = [
{ models: ["alpha"], name: "Primary", type: "openai_chat_completions" },
{ models: ["beta"], name: "Secondary", type: "openai_chat_completions" }
];
config.Router.rules = [{
condition: { left: "request.url", operator: "contains", right: "/v1/chat/completions" },
enabled: true,
id: "route-to-secondary",
name: "Route to secondary",
rewrites: [{ key: "request.body.model", operation: "set", value: "Secondary/beta" }],
type: "condition"
}];
const plugin = await createGatewayPlugin({ plugin: { config: { appConfig: config } } });
assert.equal(plugin.httpRoutes[0].path, ccrRouterHttpRoutePath);
const transformed = await plugin.requestTransforms[0].transform({
request: {
headers: {},
method: "POST",
url: "/v1/chat/completions"
},
requestBody: {
messages: [{ role: "user", content: "hello" }],
model: "Primary/alpha"
},
route: {
method: "POST",
url: "/v1/chat/completions"
}
});
assert.ok(transformed);
assert.equal(transformed.requestBody.model, "Secondary/beta");
assert.equal(transformed.model, "Secondary/beta");
assert.equal(transformed.headers[ccrRoutedModelHeader], "Secondary/beta");
assert.equal(transformed.headers[ccrRouteReasonHeader], "rule:route-to-secondary");
assert.equal(transformed.headers[ccrRouteSourceHeader], "rule");
const resolver = plugin.routeResolvers.find((item) => item.key === ccrRouterRouteResolverKey);
const resolved = resolver.resolve({
model: transformed.model,
request: {
headers: transformed.headers,
method: "POST",
url: "/v1/chat/completions"
},
requestBody: transformed.requestBody,
route: {
method: "POST",
url: "/v1/chat/completions"
}
});
assert.equal(resolved.targetProviderName, providerRuntimeId(config.Providers[1]));
assert.equal(resolved.model, "beta");
assert.equal(resolved.requestBody.model, "beta");
});
test("CCR router core plugin applies Codex bridge request and response hooks", async () => {
const config = createDefaultAppConfig();
config.Providers = [{
models: ["claude-sonnet"],
name: "Primary",
type: "openai_responses"
}];
const plugin = await createGatewayPlugin({ plugin: { config: { appConfig: config } } });
const transform = plugin.requestTransforms.find((item) => item.key === ccrCodexBridgeRequestTransformKey);
const patch = "*** Begin Patch\n*** Add File: foo.txt\n+hi\n*** End Patch\n";
const transformed = await transform.transform({
model: "Primary/claude-sonnet",
request: {
headers: { "user-agent": "codex-test" },
id: "codex-bridge-1",
method: "POST",
url: "/v1/responses"
},
requestBody: {
input: [
{ type: "custom_tool_call", call_id: "call_patch", name: "apply_patch", input: patch },
{
arguments: JSON.stringify({ message: "inspect tests" }),
call_id: "call_agent",
name: "spawn_agent",
namespace: "multi_agent_v1",
type: "function_call"
}
],
model: "Primary/claude-sonnet",
tools: [
{ type: "custom", name: "apply_patch", format: { type: "grammar", syntax: "lark", definition: "start: begin_patch" } },
multiAgentNamespaceTool()
]
},
route: {
method: "POST",
url: "/v1/responses"
}
});
assert.ok(transformed);
assert.equal(transformed.headers[ccrCodexApplyPatchBridgeHeader]?.startsWith("Primary/claude-sonnet:"), true);
assert.equal(transformed.headers[ccrCodexMultiAgentBridgeHeader]?.startsWith("Primary/claude-sonnet:"), true);
assert.equal(transformed.requestBody.tools[0].name, "virtual_apply_patch");
assert.equal(transformed.requestBody.tools[1].name, "multi_agent_v1_spawn_agent");
assert.equal(transformed.requestBody.input[0].name, "virtual_apply_patch");
assert.equal(transformed.requestBody.input[1].name, "multi_agent_v1_spawn_agent");
const responseHook = plugin.responseHooks.find((item) => item.key === ccrCodexBridgeResponseHookKey);
const response = await responseHook.transformResponse({
request: {
headers: transformed.headers,
id: "codex-bridge-1",
method: "POST",
url: "/v1/responses"
},
responseHeaders: {},
responsePayload: {
output: [{
arguments: JSON.stringify({ patch }),
call_id: "call_patch",
name: "virtual_apply_patch",
type: "function_call"
}, {
arguments: JSON.stringify({ targets: ["agent-1"] }),
call_id: "call_wait",
name: "multi_agent_v1_wait_agent",
type: "function_call"
}]
},
statusCode: 200
});
assert.equal(response.responsePayload.output[0].type, "custom_tool_call");
assert.equal(response.responsePayload.output[0].name, "apply_patch");
assert.equal(response.responsePayload.output[0].input, patch);
assert.equal(response.responsePayload.output[1].name, "wait_agent");
assert.equal(response.responsePayload.output[1].namespace, "multi_agent_v1");
const streamHook = plugin.streamHooks.find((item) => item.key === ccrCodexBridgeStreamHookKey);
const streamed = await streamHook.transformResponse({
request: {
headers: transformed.headers,
id: "codex-bridge-1",
method: "POST",
url: "/v1/responses"
},
upstreamRequest: {
body: {},
headers: {},
method: "POST",
url: "https://provider.test/v1/responses"
},
upstreamResponse: new Response([
"event: response.output_item.done",
`data: ${JSON.stringify({
type: "response.output_item.done",
item: {
arguments: JSON.stringify({ patch }),
call_id: "call_patch",
name: "virtual_apply_patch",
type: "function_call"
}
})}`,
""
].join("\n"), {
headers: {
"content-length": "999",
"content-type": "text/event-stream"
},
status: 200
})
});
const streamText = await streamed.text();
assert.equal(streamed.headers.get("content-length"), null);
assert.match(streamText, /"type":"custom_tool_call"/);
assert.match(streamText, /"name":"apply_patch"/);
});
test("CCR router core plugin applies OpenRouter discount provider routing", async (t) => {
const config = createDefaultAppConfig();
config.Providers = [{
api_base_url: "https://openrouter.ai/api/v1",
modelMetadata: {
"author/router-plugin-discount-model": {
openRouterDiscountRouting: {
allowFallbacks: false,
cacheHitRate: 0,
enabled: true,
minSavingsRatio: 0,
minSavingsUsd: 0,
minUptime5m: 0
}
}
},
models: ["author/router-plugin-discount-model"],
name: "OpenRouter",
type: "openai_chat_completions"
}];
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(JSON.stringify({
data: {
endpoints: [{
name: "Expensive Provider",
pricing: { completion: 0.000004, prompt: 0.000002 },
provider_name: "Expensive Provider",
status: 0,
tag: "expensive"
}, {
name: "Cheap Provider",
pricing: { completion: 0.000001, prompt: 0.0000005 },
provider_name: "Cheap Provider",
status: 0,
tag: "cheap"
}]
}
}), { headers: { "content-type": "application/json" }, status: 200 });
t.after(() => {
globalThis.fetch = originalFetch;
});
const plugin = await createGatewayPlugin({ plugin: { config: { appConfig: config } } });
const transformed = await plugin.requestTransforms[0].transform({
request: {
headers: { "x-claude-code-session-id": "router-plugin-discount-session" },
id: "router-plugin-discount-1",
method: "POST",
url: "/v1/chat/completions"
},
requestBody: {
max_tokens: 512,
messages: [{ role: "user", content: "hello" }],
model: "OpenRouter/author/router-plugin-discount-model",
provider: { order: ["expensive"] }
},
route: {
method: "POST",
url: "/v1/chat/completions"
}
});
assert.equal(transformed.requestBody.provider.order[0], "cheap");
assert.equal(transformed.responseHeaders["x-ccr-openrouter-discount-reason"], "switched-cheaper-after-cache-loss");
assert.equal(transformed.responseHeaders["x-ccr-openrouter-discount-selected-provider"], "cheap");
assert.equal(transformed.responseHeaders["x-ccr-openrouter-discount-model"], "author/router-plugin-discount-model");
const beforeConfirm = await plugin.requestTransforms[0].transform({
request: {
headers: { "x-claude-code-session-id": "router-plugin-discount-session" },
id: "router-plugin-discount-before-confirm",
method: "POST",
url: "/v1/chat/completions"
},
requestBody: {
max_tokens: 512,
messages: [{ role: "user", content: "hello" }],
model: "OpenRouter/author/router-plugin-discount-model"
},
route: {
method: "POST",
url: "/v1/chat/completions"
}
});
assert.equal(beforeConfirm.responseHeaders["x-ccr-openrouter-discount-reason"], "initial-cheapest");
const finalizer = plugin.responseHooks.find((item) => item.key === ccrOpenRouterDiscountFinalizeResponseHookKey);
await finalizer.transformResponse({
request: {
headers: {
...transformed.headers,
"x-claude-code-session-id": "router-plugin-discount-session"
},
id: "router-plugin-discount-1",
method: "POST",
url: "/v1/chat/completions"
},
responseHeaders: {},
responsePayload: {},
statusCode: 200,
upstreamResponse: new Response("{}", { status: 200 })
});
const afterConfirm = await plugin.requestTransforms[0].transform({
request: {
headers: { "x-claude-code-session-id": "router-plugin-discount-session" },
id: "router-plugin-discount-2",
method: "POST",
url: "/v1/chat/completions"
},
requestBody: {
max_tokens: 512,
messages: [{ role: "user", content: "hello" }],
model: "OpenRouter/author/router-plugin-discount-model"
},
route: {
method: "POST",
url: "/v1/chat/completions"
}
});
assert.equal(afterConfirm.responseHeaders["x-ccr-openrouter-discount-reason"], "already-cheapest");
await finalizer.transformResponse({
request: {
headers: beforeConfirm.headers,
id: "router-plugin-discount-before-confirm",
method: "POST",
url: "/v1/chat/completions"
},
responseHeaders: {},
responsePayload: {},
statusCode: 500
});
await finalizer.transformResponse({
request: {
headers: afterConfirm.headers,
id: "router-plugin-discount-2",
method: "POST",
url: "/v1/chat/completions"
},
responseHeaders: {},
responsePayload: {},
statusCode: 500
});
});
test("CCR router core plugin maps public API keys before gateway auth", async () => {
const config = createDefaultAppConfig();
config.APIKEY = "client-key";
config.APIKEYS = [{ createdAt: new Date(0).toISOString(), id: "profile:limited", key: "client-key" }];
const plugin = await createGatewayPlugin({ plugin: { config: { appConfig: config, coreAuthToken: "core-token", publicGatewayMode: true } } });
const headers = { authorization: "Bearer client-key" };
const result = await plugin.requestHooks[0].beforeAuth({ request: { headers, method: "GET", url: "/v1/models" } });
assert.equal(result, undefined);
assert.equal(headers["x-auth-api-key-id"], "profile:limited");
assert.equal(headers["x-auth-sub"], "profile:limited");
});
test("CCR router core plugin maps SDK-compatible public API key headers before gateway auth", async () => {
const config = createDefaultAppConfig();
config.APIKEY = "client-key";
config.APIKEYS = [{ createdAt: new Date(0).toISOString(), id: "profile:limited", key: "client-key" }];
const plugin = await createGatewayPlugin({ plugin: { config: { appConfig: config, coreAuthToken: "core-token", publicGatewayMode: true } } });
for (const headerName of ["api-key", "x-goog-api-key", "x-mcp-key", "x-codex-access-token"]) {
const headers = { [headerName]: "client-key" };
const result = await plugin.requestHooks[0].beforeAuth({ request: { headers, method: "GET", url: "/v1/models" } });
assert.equal(result, undefined);
assert.equal(headers["x-auth-api-key-id"], "profile:limited", headerName);
assert.equal(headers["x-auth-sub"], "profile:limited", headerName);
}
});
test("CCR router core plugin rejects expired public API keys before gateway static auth", async () => {
const config = createDefaultAppConfig();
config.APIKEY = "expired-key";
config.APIKEYS = [{
createdAt: new Date(0).toISOString(),
expiresAt: new Date(Date.now() - 60_000).toISOString(),
id: "expired",
key: "expired-key"
}];
const plugin = await createGatewayPlugin({ plugin: { config: { appConfig: config, coreAuthToken: "core-token", publicGatewayMode: true } } });
const headers = { authorization: "Bearer expired-key" };
const result = await plugin.requestHooks[0].beforeAuth({ request: { headers, method: "GET", url: "/v1/models" } });
assert.deepEqual(result, {
ok: false,
status: 401,
error: "Invalid or expired API key."
});
assert.equal(headers["x-auth-api-key-id"], undefined);
assert.equal(headers["x-auth-sub"], undefined);
});
test("CCR router core plugin protects internal route decisions with the core token", async () => {
const config = createDefaultAppConfig();
config.APIKEY = "client-key";
config.APIKEYS = [{ createdAt: new Date(0).toISOString(), id: "client", key: "client-key" }];
config.Providers = [{ models: ["alpha"], name: "Primary", type: "openai_chat_completions" }];
const plugin = await createGatewayPlugin({ plugin: { config: { appConfig: config, coreAuthToken: "core-token", publicGatewayMode: true } } });
const route = plugin.httpRoutes.find((item) => item.path === ccrRouterHttpRoutePath);
const clientReply = createReply();
const denied = await route.handler({
request: {
body: { body: { model: "Primary/alpha" }, method: "POST", path: "/v1/messages", url: "/v1/messages" },
headers: { authorization: "Bearer client-key" },
method: "POST",
url: ccrRouterHttpRoutePath
},
reply: clientReply
});
assert.equal(clientReply.statusCode, 401);
assert.match(denied.error.message, /Unauthorized CCR router route/);
const allowed = await route.handler({
request: {
body: { body: { model: "Primary/alpha" }, method: "POST", path: "/v1/messages", url: "/v1/messages" },
headers: { [coreGatewayAuthHeader]: "core-token" },
method: "POST",
url: ccrRouterHttpRoutePath
},
reply: createReply()
});
assert.equal(allowed.body.model, "Primary/alpha");
assert.equal(allowed.decision.source, "default");
});
test("CCR router core plugin exposes runtime config control through the managed parent process", async (t) => {
const config = createDefaultAppConfig();
config.APIKEY = "primary-key";
config.APIKEYS = [{ createdAt: new Date(0).toISOString(), id: "primary", key: "primary-key" }];
const plugin = await createGatewayPlugin({ plugin: { config: { appConfig: config, coreAuthToken: "core-token", publicGatewayMode: true } } });
const route = plugin.httpRoutes.find((item) => item.path === gatewayRuntimeConfigControlPath);
const originalSend = process.send;
let sent;
process.send = (message, callback) => {
sent = message;
callback?.(null);
return true;
};
t.after(() => {
if (originalSend) process.send = originalSend;
else delete process.send;
});
const status = await route.handler({
request: {
headers: { authorization: "Bearer primary-key" },
method: "GET",
url: gatewayRuntimeConfigControlPath
},
reply: createReply()
});
assert.equal(status.revision, gatewayRuntimeConfigRevision(config));
assert.equal(status.state, "running");
const revision = "a".repeat(64);
const reloadReply = createReply();
const reload = await route.handler({
request: {
body: { configRevision: revision, forceRestart: true },
headers: { authorization: "Bearer primary-key" },
method: "POST",
url: gatewayRuntimeConfigControlPath
},
reply: reloadReply
});
assert.equal(reloadReply.statusCode, 202);
assert.deepEqual(reload, { accepted: true, configRevision: revision, restarting: true });
assert.deepEqual(sent, {
configRevision: revision,
forceRestart: true,
protocolVersion: 1,
type: ccrRuntimeConfigReloadMessageType
});
});
test("CCR router core plugin allows internal core auth tokens before public gateway auth", async () => {
const config = createDefaultAppConfig();
config.APIKEY = "client-key";
config.APIKEYS = [{ createdAt: new Date(0).toISOString(), id: "client", key: "client-key" }];
const plugin = await createGatewayPlugin({ plugin: { config: { appConfig: config, coreAuthToken: "core-token", publicGatewayMode: true } } });
const headers = { authorization: "Bearer core-token" };
const result = await plugin.requestHooks[0].beforeAuth({ request: { headers, method: "GET", url: "/v1/models" } });
assert.equal(result, undefined);
assert.equal(headers["x-auth-api-key-id"], undefined);
assert.equal(headers["x-auth-sub"], undefined);
});
test("CCR router core plugin serves remote control capabilities with query auth", async () => {
const config = createDefaultAppConfig();
config.APIKEY = "client-key";
config.APIKEYS = [{ createdAt: new Date(0).toISOString(), id: "client", key: "client-key" }];
const plugin = await createGatewayPlugin({ plugin: { config: { appConfig: config, coreAuthToken: "core-token", publicGatewayMode: true } } });
const route = plugin.httpRoutes.find((item) => item.path === ccrRemoteControlPathPrefix);
const response = createRawResponse();
const reply = createReply({ raw: response });
const payload = await route.handler({
request: {
headers: {},
method: "GET",
raw: { headers: {}, method: "GET", url: `${ccrRemoteControlPathPrefix}?api_key=client-key` },
url: `${ccrRemoteControlPathPrefix}?api_key=client-key`
},
reply
});
assert.equal(payload, reply);
assert.equal(reply.hijacked, true);
assert.equal(response.statusCode, 200);
assert.equal(response.headers["content-type"], "application/json; charset=utf-8");
assert.equal(JSON.parse(response.body).name, "ccr-remote-control");
});
test("CCR router core plugin serves public model discovery before built-in gateway routes", async () => {
const config = createDefaultAppConfig();
config.Providers = [{
models: ["alpha", "beta"],
name: "Primary",
type: "openai_chat_completions"
}];
config.APIKEY = "client-key";
config.APIKEYS = [{ createdAt: new Date(0).toISOString(), id: "profile:limited", key: "client-key" }];
config.profile.profiles = [{
...config.profile.profiles[0],
availableModels: ["Primary/alpha"],
enabled: true,
id: "limited",
model: "Primary/alpha",
name: "Limited"
}];
const plugin = await createGatewayPlugin({ plugin: { config: { appConfig: config } } });
const route = plugin.httpRoutes.find((item) => item.path === "/v1/models");
assert.equal(route.priority, "pre");
const payload = await route.handler({
request: { headers: { authorization: "Bearer client-key" }, method: "GET", url: "/v1/models" },
reply: createReply()
});
assert.deepEqual(payload.data.map((model) => model.id), ["Primary/alpha"]);
});
test("CCR router core plugin rejects profile-disallowed routed models", async () => {
const config = createDefaultAppConfig();
config.Providers = [{
models: ["alpha", "beta"],
name: "Primary",
type: "openai_chat_completions"
}];
config.APIKEY = "client-key";
config.APIKEYS = [{ createdAt: new Date(0).toISOString(), id: "profile:limited", key: "client-key" }];
config.profile.profiles = [{
...config.profile.profiles[0],
availableModels: ["Primary/alpha"],
enabled: true,
id: "limited",
model: "Primary/alpha",
name: "Limited"
}];
const plugin = await createGatewayPlugin({ plugin: { config: { appConfig: config } } });
const transform = plugin.requestTransforms.find((item) => item.key === ccrRouterRequestTransformKey);
const transformed = await transform.transform({
request: {
headers: { authorization: "Bearer client-key" },
method: "POST",
url: "/v1/chat/completions"
},
requestBody: {
messages: [{ role: "user", content: "hello" }],
model: "Primary/beta"
},
route: {
method: "POST",
url: "/v1/chat/completions"
}
});
assert.equal(transformed.ok, false);
assert.equal(transformed.status, 403);
assert.equal(transformed.details.code, "profile_model_not_allowed");
});
function createReply(overrides = {}) {
return {
code(statusCode) {
this.statusCode = statusCode;
return this;
},
hijack() {
this.hijacked = true;
},
hijacked: false,
...overrides,
send(payload) {
this.payload = payload;
return payload;
},
statusCode: 200
};
}
function createRawResponse() {
return {
body: "",
headers: {},
statusCode: 0,
end(chunk) {
if (chunk !== undefined) {
this.body += String(chunk);
}
},
setHeader(name, value) {
this.headers[name.toLowerCase()] = value;
}
};
}
function multiAgentNamespaceTool() {
return {
description: "Tools for spawning and managing sub-agents.",
name: "multi_agent_v1",
tools: [{
additionalProperties: false,
description: "Spawn a sub-agent.",
name: "spawn_agent",
parameters: {
properties: {
message: { type: "string" }
},
type: "object"
},
type: "function"
}, {
additionalProperties: false,
description: "Wait for agents.",
name: "wait_agent",
parameters: {
properties: {
targets: { items: { type: "string" }, type: "array" }
},
required: ["targets"],
type: "object"
},
type: "function"
}],
type: "namespace"
};
}

View file

@ -0,0 +1,107 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts";
import { singleGatewayRuntimeBlockersForTest } from "@ccr/core/gateway/application/gateway-service.ts";
test("single gateway runtime falls back when Media Tools need CCR HTTP routes", () => {
const config = createDefaultAppConfig();
config.mediaTools.enabled = true;
assert.ok(singleGatewayRuntimeBlockersForTest(config).includes("media-tools"));
});
test("single gateway runtime falls back when browser automation MCP needs Electron routing", () => {
const config = createDefaultAppConfig();
config.toolHub.enabled = true;
config.toolHub.browserAutomation = true;
assert.ok(singleGatewayRuntimeBlockersForTest(config).includes("browser-automation-mcp"));
});
test("single gateway runtime falls back for any plugin gateway route", () => {
const config = createDefaultAppConfig();
const blockers = singleGatewayRuntimeBlockersForTest(config, {
gatewayRuntimeSupportsRouterPlugin: () => true,
hasGatewayRequestTransforms: () => false,
hasGatewayRoutes: () => true
});
assert.ok(blockers.includes("plugin-gateway-routes"));
});
test("single gateway runtime falls back when global router fallback can retry", () => {
const config = createDefaultAppConfig();
config.Router.fallback = { mode: "retry", models: [], retryCount: 1 };
const blockers = singleGatewayRuntimeBlockersForTest(config, pluginCapableOptions());
assert.ok(blockers.includes("router-fallback"));
});
test("single gateway runtime falls back when global router fallback has a model chain", () => {
const config = createDefaultAppConfig();
config.Router.fallback = { mode: "model-chain", models: ["Provider/beta"], retryCount: 0 };
const blockers = singleGatewayRuntimeBlockersForTest(config, pluginCapableOptions());
assert.ok(blockers.includes("router-fallback"));
});
test("single gateway runtime falls back when a router rule has fallback", () => {
const config = createDefaultAppConfig();
config.Router.rules = [{
condition: { left: "request.url", operator: "contains", right: "/v1/messages" },
enabled: true,
fallback: { mode: "retry", models: [], retryCount: 1 },
id: "retry-messages",
name: "Retry messages",
type: "condition"
}];
const blockers = singleGatewayRuntimeBlockersForTest(config, pluginCapableOptions());
assert.ok(blockers.includes("router-fallback"));
});
test("single gateway runtime falls back when a profile route has fallback", () => {
const config = createDefaultAppConfig();
config.profile.profiles = config.profile.profiles.map((profile) => profile.id === "default-claude-code"
? {
...profile,
routing: {
enabled: true,
enhancedRoute: false,
rules: [{
condition: { left: "request.url", operator: "contains", right: "/v1/messages" },
enabled: true,
fallback: { mode: "model-chain", models: ["Provider/beta"], retryCount: 0 },
id: "profile-fallback",
name: "Profile fallback",
type: "condition"
}]
}
}
: profile);
const blockers = singleGatewayRuntimeBlockersForTest(config, pluginCapableOptions());
assert.ok(blockers.includes("router-fallback"));
});
test("single gateway runtime allows no-op retry fallback", () => {
const config = createDefaultAppConfig();
config.Router.fallback = { mode: "retry", models: [], retryCount: 0 };
const blockers = singleGatewayRuntimeBlockersForTest(config, pluginCapableOptions());
assert.equal(blockers.includes("router-fallback"), false);
});
function pluginCapableOptions() {
return {
gatewayRuntimeSupportsRouterPlugin: () => true,
hasGatewayRequestTransforms: () => false,
hasGatewayRoutes: () => false
};
}

View file

@ -61,6 +61,7 @@ test("gateway sanitizer hook forwards client headers without overriding provider
request: {
headers: {
authorization: "Bearer ccr-client-token",
"api-key": "legacy-client-token",
connection: "keep-alive, x-hop-only",
"content-length": "123",
"content-type": "text/plain",
@ -71,9 +72,13 @@ test("gateway sanitizer hook forwards client headers without overriding provider
"x-auth-api-key-id": "profile:codex",
"x-auth-provider-extension": "provider-extension",
"x-ccr-core-auth": "core-secret",
"x-codex-access-token": "codex-client-token",
"x-custom-provider-header": "custom-value",
"x-custom-list": ["one", "two"],
"x-goog-api-key": "google-client-token",
"x-hop-only": "remove-me",
"x-mcp-key": "mcp-client-token",
"x-api-key": "client-token",
"x-target-model": "internal-provider/internal-model",
"x-target-provider": "internal-provider",
"x-title": "Claude Code Router"

View file

@ -119,33 +119,72 @@ test("raw trace defers body persistence when the upstream status is unknown", ()
test("raw trace source keeps raw body parts unbounded for sidecar storage", () => {
const config = createConfig();
const previous = process.env.CCR_RAW_TRACE_ENABLED;
process.env.CCR_RAW_TRACE_ENABLED = "1";
const rawTrace = buildRawTraceConfig(config, "sync-token");
assert.equal(rawTrace.enabled, true);
assert.equal(rawTrace.mode, "wire_raw");
assert.equal(rawTrace.maxPartBytes, rawTraceMaxPartBytes);
assert.ok(rawTrace.maxPartBytes > rawTraceHardMaxBodyBytes);
config.observability.requestLogMaxBodyBytes = Number.MAX_SAFE_INTEGER;
assert.equal(buildRawTraceConfig(config, "sync-token").maxPartBytes, rawTraceMaxPartBytes);
});
test("raw trace capture is metadata-only for standalone usage when body logging is disabled", () => {
const config = createConfig();
config.observability.requestLogBodyCapture = "none";
assert.equal(buildRawTraceConfig(config, "sync-token").enabled, false);
const standalone = buildRawTraceConfig(config, "sync-token", { standaloneUsageCapture: true });
assert.equal(standalone.enabled, true);
assert.equal(standalone.mode, "body_redacted");
const previous = process.env.CCR_RAW_TRACE;
process.env.CCR_RAW_TRACE = "false";
try {
const rawTrace = buildRawTraceConfig(config, "sync-token");
assert.equal(rawTrace.maxPartBytes, rawTraceMaxPartBytes);
assert.ok(rawTrace.maxPartBytes > rawTraceHardMaxBodyBytes);
config.observability.requestLogMaxBodyBytes = Number.MAX_SAFE_INTEGER;
assert.equal(buildRawTraceConfig(config, "sync-token").maxPartBytes, rawTraceMaxPartBytes);
assert.equal(buildRawTraceConfig(config, "sync-token", { standaloneUsageCapture: true }).enabled, false);
} finally {
if (previous === undefined) delete process.env.CCR_RAW_TRACE_ENABLED;
else process.env.CCR_RAW_TRACE_ENABLED = previous;
if (previous === undefined) delete process.env.CCR_RAW_TRACE;
else process.env.CCR_RAW_TRACE = previous;
}
});
test("raw trace capture is disabled at the source for metadata-only logging", () => {
const config = createConfig();
config.observability.requestLogBodyCapture = "none";
const previous = process.env.CCR_RAW_TRACE_ENABLED;
process.env.CCR_RAW_TRACE_ENABLED = "1";
test("raw trace bundle preserves manifest lifecycle metadata", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "ccr-raw-trace-lifecycle-test-"));
const spoolDirectory = path.join(dir, "spool");
const bundleDirectory = path.join(spoolDirectory, "producer-bundle");
const clientMetadataFile = path.join(bundleDirectory, "client_request_metadata.json");
const bodyFile = path.join(bundleDirectory, "upstream_request.json");
const startedAt = "2026-08-26T01:02:03.004Z";
const completedAt = "2026-08-26T01:02:04.238Z";
mkdirSync(bundleDirectory, { recursive: true });
writeFileSync(clientMetadataFile, JSON.stringify({ headers: { "x-ccr-client": "opencode" } }));
writeFileSync(bodyFile, "{}");
try {
assert.equal(buildRawTraceConfig(config, "sync-token").enabled, false);
config.observability.requestLogBodyCapture = "all";
config.observability.requestLogMaxBodyBytes = 0;
assert.equal(buildRawTraceConfig(config, "sync-token").enabled, false);
const bundle = await readRawTraceRequestLogBundle({
completedAt,
durationMs: 1234,
parts: [{
filePath: clientMetadataFile,
partType: "client_request_metadata"
}, {
contentType: "application/json",
filePath: bodyFile,
originalBytes: 2,
partType: "upstream_request"
}],
requestId: "core-bundle-lifecycle",
startedAt,
turnKey: "logical-lifecycle-request",
uploadedAt: completedAt
}, spoolDirectory);
assert.ok(bundle);
assert.equal(bundle.update.requestId, "logical-lifecycle-request");
assert.equal(bundle.update.client, "opencode");
assert.equal(bundle.update.startedAt, startedAt);
assert.equal(bundle.update.completedAt, completedAt);
assert.equal(bundle.update.durationMs, 1234);
assert.equal(bundle.update.bundleCapturedAt, completedAt);
} finally {
if (previous === undefined) delete process.env.CCR_RAW_TRACE_ENABLED;
else process.env.CCR_RAW_TRACE_ENABLED = previous;
rmSync(dir, { force: true, recursive: true });
}
});

File diff suppressed because it is too large Load diff

View file

@ -1,15 +1,17 @@
import {
AddProfileDraft, AddRoutingRuleDraft, AgentLogo, AnimatedIconSwap, AnimatedPopover, AnimatePresence, AppConfig, Badge, BotGatewaySavedConfig, botGatewaySavedConfigLabel, BotHandoffScanTarget, Button,
Card, CardContent, CardHeader, CardTitle, Check, ChevronDown, CircleAlert, Copy,
createKeyValueDraftRow,
createRoutingRuleDraft, createRoutingRuleDraftFromRule,
cn, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader,
DialogTitle, Field, GatewayProviderConfig, Info, Input, KeyValueRowsControl, LoaderCircle, motion,
normalizeProfileScope, normalizeProfileSurface, Pencil, Plus, PopoverContent,
profileAgentLabel, profileAgentOptions, ProfileConfig, type ProfileAgentOption, profileModelProviderOptions, profileOpenSurfaces, profileScopeLabel, profileScopeOptions, profileSummaryItems, profileSurfaceLabel, profileSurfaceOptions,
Play, Power, RefreshCw, Select, SelectControl, Terminal, Toggle, translateOptions, Trash2, useAppErrorText, useAppText, useLayoutEffect, type ProfileOpenSurface, type ProfileRuntimeStatus, type ReactDragEvent, type ReactNode, type VirtualModelProfileConfig,
copyTextToClipboard, formatRouterRuleCondition, formatRouterRuleTarget, isRoutingRuleDraftSubmittable, normalizeProviderModelSelector, routerRuleTypeLabel, routingRuleFromDraft, type RouterRule, uniqueStrings, validateProfileEnvRows,
Play, Power, RefreshCw, Search, Select, SelectControl, Settings, Terminal, Toggle, translateOptions, Trash2, useAppErrorText, useAppText, useLayoutEffect, type KeyValueDraftRow, type ProfileOpenSurface, type ProfileRuntimeStatus, type ReactDragEvent, type ReactNode, type VirtualModelProfileConfig,
copyTextToClipboard, formatClaudeSettingsDraft, formatRouterRuleCondition, formatRouterRuleTarget, isClaudeSettingsDraftValid, isPlainRecord, isRoutingRuleDraftSubmittable, normalizeProviderModelSelector, routerRuleTypeLabel, routingRuleFromDraft, type RouterRule, uniqueStrings, useResolvedAppLanguage, validateProfileEnvRows,
useCallback, useEffect, useMemo, useRef, useState, X
} from "../shared/index";
import claudeCodeConfigOptionsJson from "@/generated/claude-code-config-options.json";
import { PopoverPortal } from "@/components/ui/popover";
import { Tooltip } from "@/components/ui/tooltip";
import { ModelMultiSelector, ModelSelector } from "./model-selector";
@ -17,6 +19,31 @@ import { AddRoutingRuleDialog } from "./routing";
const useClientLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
type ClaudeCodeConfigOption = {
default?: string;
description?: {
en?: string;
zh?: string;
};
enumValues?: string[];
example?: string;
key: string;
path?: string[];
scope?: string;
type?: string;
valueKind?: string;
};
type ClaudeCodeConfigCatalog = {
env: ClaudeCodeConfigOption[];
generatedAt?: string;
settings: ClaudeCodeConfigOption[];
};
const claudeCodeConfigOptions = claudeCodeConfigOptionsJson as ClaudeCodeConfigCatalog;
const CLAUDE_CONFIG_SETTINGS_TAB = "settings";
const CLAUDE_CONFIG_ENV_TAB = "env";
type ProfileActionBusy = {
profileId: string;
surface: ProfileOpenSurface;
@ -727,12 +754,13 @@ export function AddProfileForm({
const advancedIssueCount = [
validation.allowedModels,
validation.bot,
validation.claudeSettings,
validation.handoff,
validation.env
].filter(Boolean).length;
const advancedSummary = advancedIssueCount > 0
? t("Advanced settings need attention")
: t("Models, paths, routing, bot, compact, and env");
: t("Models, paths, routing, bot, compact, Claude settings, and env");
const handleAppPathDrop = useCallback((event: ReactDragEvent<HTMLElement>) => {
if (!showAppPathField) {
return;
@ -1029,6 +1057,13 @@ export function AddProfileForm({
/>
</Field>
) : null}
{draft.agent === "claude-code" ? (
<ClaudeConfigEditor
draft={draft}
error={validation.claudeSettings ? t(validation.claudeSettings) : ""}
onChange={onChange}
/>
) : null}
<Field className="sm:col-span-2" label={t("Environment variables")} requirement="optional" requirementLabel={optionalFieldLabel}>
<KeyValueRowsControl
addLabel={t("Add env variable")}
@ -1241,6 +1276,523 @@ function ProfileEnhancedRouteSetting({
);
}
function ClaudeConfigEditor({
draft,
error,
onChange
}: {
draft: AddProfileDraft;
error: string;
onChange: (patch: Partial<AddProfileDraft>) => void;
}) {
const t = useAppText();
const [open, setOpen] = useState(false);
return (
<div className="sm:col-span-2 rounded-md border border-border bg-muted/20 p-3">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<Settings className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate text-[12px] font-semibold">{t("Claude settings")}</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button onClick={() => setOpen(true)} size="sm" type="button" variant="outline">
<Settings className="h-3.5 w-3.5" />
{t("Edit Claude settings")}
</Button>
</div>
</div>
{error ? <ProfileFieldHint>{error}</ProfileFieldHint> : null}
{open ? (
<Dialog onOpenChange={setOpen} open>
<DialogContent className="h-[min(780px,calc(100dvh-1.5rem))] max-w-[980px]">
<DialogHeader>
<DialogTitle>{t("Claude settings")}</DialogTitle>
<Button aria-label={t("Close dialog")} onClick={() => setOpen(false)} size="iconSm" title={t("Close")} type="button" variant="ghost">
<X className="h-4 w-4" />
</Button>
</DialogHeader>
<DialogBody className="overflow-hidden p-0">
<ClaudeConfigEditorContent
draft={draft}
error={error}
onChange={onChange}
/>
</DialogBody>
<DialogFooter>
<Button onClick={() => setOpen(false)} type="button" variant="outline">
{t("Done")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : null}
</div>
);
}
function ClaudeConfigEditorContent({
draft,
error,
onChange
}: {
draft: AddProfileDraft;
error: string;
onChange: (patch: Partial<AddProfileDraft>) => void;
}) {
const t = useAppText();
const language = useResolvedAppLanguage();
const [tab, setTab] = useState<typeof CLAUDE_CONFIG_SETTINGS_TAB | typeof CLAUDE_CONFIG_ENV_TAB>(CLAUDE_CONFIG_SETTINGS_TAB);
const [query, setQuery] = useState("");
const [configuredFilter, setConfiguredFilter] = useState("all");
const [rawInputs, setRawInputs] = useState<Record<string, string>>({});
const [inputErrors, setInputErrors] = useState<Record<string, string>>({});
const settings = useMemo(() => claudeSettingsDraftObject(draft.claudeSettingsText), [draft.claudeSettingsText]);
const env = useMemo(() => envRowsRecord(draft.envRows), [draft.envRows]);
const options = tab === CLAUDE_CONFIG_SETTINGS_TAB ? claudeCodeConfigOptions.settings : claudeCodeConfigOptions.env;
const filteredOptions = useMemo(() =>
options.filter((option) => claudeConfigOptionVisible(option, {
configuredFilter,
env,
language,
query,
settings,
tab
})),
[configuredFilter, env, language, options, query, settings, tab]);
function clearInputState(inputId: string) {
setRawInputs((current) => {
if (!hasOwnValue(current, inputId)) {
return current;
}
const next = { ...current };
delete next[inputId];
return next;
});
setInputErrors((current) => {
if (!hasOwnValue(current, inputId)) {
return current;
}
const next = { ...current };
delete next[inputId];
return next;
});
}
function setInputError(inputId: string, value: string, issue: string) {
setRawInputs((current) => ({ ...current, [inputId]: value }));
setInputErrors((current) => ({ ...current, [inputId]: issue }));
}
function updateSetting(option: ClaudeCodeConfigOption, value: unknown | undefined) {
const nextSettings = cloneSettingsObject(settings);
const pathParts = claudeConfigOptionPath(option);
if (value === undefined) {
deleteSettingsPath(nextSettings, pathParts);
} else {
setSettingsPath(nextSettings, pathParts, value);
}
onChange({ claudeSettingsText: formatClaudeSettingsDraft(nextSettings) });
}
function updateSettingInput(option: ClaudeCodeConfigOption, value: string) {
const inputId = claudeConfigInputId(CLAUDE_CONFIG_SETTINGS_TAB, option.key);
const parsed = parseClaudeConfigInputValue(option, value);
if (!parsed.ok) {
setInputError(inputId, value, parsed.error);
return;
}
clearInputState(inputId);
updateSetting(option, parsed.value);
}
function updateEnv(option: ClaudeCodeConfigOption, value: string) {
const inputId = claudeConfigInputId(CLAUDE_CONFIG_ENV_TAB, option.key);
clearInputState(inputId);
onChange({ envRows: updateEnvRows(draft.envRows, option.key, value) });
}
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex min-w-0 flex-wrap items-center gap-2 border-b border-border p-3">
<div className="grid h-8 shrink-0 grid-cols-2 rounded-md border border-border bg-background p-0.5">
{([
{ label: "Settings", value: CLAUDE_CONFIG_SETTINGS_TAB },
{ label: "Environment", value: CLAUDE_CONFIG_ENV_TAB }
] as const).map((item) => (
<button
className={cn(
"h-7 rounded-[5px] px-3 text-[12px] font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/25",
tab === item.value ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:bg-muted/70 hover:text-foreground"
)}
key={item.value}
onClick={() => setTab(item.value)}
type="button"
>
{t(item.label)}
</button>
))}
</div>
<div className="relative min-w-[220px] flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
className="h-8 pl-8"
placeholder={t(tab === CLAUDE_CONFIG_SETTINGS_TAB ? "Search Claude settings" : "Search environment variables")}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<div className="min-w-[150px] shrink-0">
<SelectControl
onChange={setConfiguredFilter}
options={translateOptions([
{ label: "All items", value: "all" },
{ label: "Configured", value: "configured" },
{ label: "Unset", value: "unset" }
], t)}
value={configuredFilter}
/>
</div>
</div>
{error ? (
<div className="border-b border-border px-3 py-2">
<ProfileFieldHint>{error}</ProfileFieldHint>
</div>
) : null}
<div className="min-h-0 flex-1 overflow-auto bg-background">
{filteredOptions.length === 0 ? (
<div className="px-3 py-8 text-center text-[12px] text-muted-foreground">
{t(tab === CLAUDE_CONFIG_SETTINGS_TAB ? "No matching Claude settings" : "No matching environment variables")}
</div>
) : filteredOptions.map((option) => {
const inputId = claudeConfigInputId(tab, option.key);
const description = claudeConfigOptionDescription(option, language);
const currentValue = tab === CLAUDE_CONFIG_SETTINGS_TAB
? readSettingsPath(settings, claudeConfigOptionPath(option))
: env[option.key];
const active = currentValue !== undefined;
const managedOnly = tab === CLAUDE_CONFIG_SETTINGS_TAB && isManagedOnlyClaudeSetting(option);
const rawValue = hasOwnValue(rawInputs, inputId)
? rawInputs[inputId]
: formatClaudeConfigInputValue(currentValue);
const inputError = inputErrors[inputId];
return (
<div className="grid min-w-0 gap-2 border-b border-border/60 p-3 last:border-b-0" key={`${tab}-${option.key}`}>
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<code className="min-w-0 truncate rounded border border-border bg-muted/30 px-1.5 py-0.5 font-mono text-[11px] font-semibold">
{option.key}
</code>
{option.valueKind ? <Badge variant="outline">{option.valueKind}</Badge> : null}
{active ? <Badge variant="success">{t("Set")}</Badge> : null}
</div>
{description ? (
<div className="text-[11px] leading-4 text-muted-foreground">
{description}
</div>
) : null}
<div className="flex min-w-0 items-start gap-2">
{tab === CLAUDE_CONFIG_SETTINGS_TAB && option.valueKind === "boolean" ? (
<div className="flex min-h-8 flex-1 items-center">
<Toggle
checked={currentValue === true}
disabled={managedOnly}
onChange={(checked) => updateSetting(option, checked)}
title={option.key}
/>
</div>
) : (
<Input
className="min-h-8 flex-1 font-mono text-[12px]"
disabled={managedOnly}
placeholder={managedOnly ? t("Managed settings can only be set by administrators.") : claudeConfigPlaceholder(option, tab)}
value={managedOnly ? "" : rawValue}
onChange={(event) =>
tab === CLAUDE_CONFIG_SETTINGS_TAB
? updateSettingInput(option, event.target.value)
: updateEnv(option, event.target.value)}
/>
)}
{active ? (
<Button
aria-label={`${t("Remove")} ${option.key}`}
disabled={managedOnly}
onClick={() =>
tab === CLAUDE_CONFIG_SETTINGS_TAB
? updateSetting(option, undefined)
: updateEnv(option, "")}
size="iconSm"
title={t("Remove")}
type="button"
variant="ghost"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
{inputError ? <ProfileFieldHint>{t(inputError)}</ProfileFieldHint> : null}
</div>
);
})}
</div>
</div>
);
}
function claudeConfigInputId(tab: string, key: string): string {
return `${tab}:${key}`;
}
function claudeConfigOptionPath(option: ClaudeCodeConfigOption): string[] {
return option.path?.length ? option.path : option.key.split(".").filter(Boolean);
}
function claudeConfigOptionDescription(option: ClaudeCodeConfigOption, language: "en" | "zh"): string {
return option.description?.[language]?.trim() || option.description?.en?.trim() || "";
}
function claudeConfigOptionMatches(option: ClaudeCodeConfigOption, query: string, language: "en" | "zh"): boolean {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) {
return true;
}
return [
option.key,
option.type ?? "",
option.valueKind ?? "",
option.default ?? "",
claudeConfigOptionDescription(option, language)
].some((value) => value.toLowerCase().includes(normalizedQuery));
}
function claudeConfigOptionVisible(
option: ClaudeCodeConfigOption,
filter: {
configuredFilter: string;
env: Record<string, string>;
language: "en" | "zh";
query: string;
settings: Record<string, unknown>;
tab: typeof CLAUDE_CONFIG_SETTINGS_TAB | typeof CLAUDE_CONFIG_ENV_TAB;
}
): boolean {
if (!claudeConfigOptionMatches(option, filter.query, filter.language)) {
return false;
}
const active = isClaudeConfigOptionActive(option, filter.tab, filter.settings, filter.env);
if (filter.configuredFilter === "configured") {
return active;
}
if (filter.configuredFilter === "unset") {
return !active;
}
return true;
}
function isClaudeConfigOptionActive(
option: ClaudeCodeConfigOption,
tab: typeof CLAUDE_CONFIG_SETTINGS_TAB | typeof CLAUDE_CONFIG_ENV_TAB,
settings: Record<string, unknown>,
env: Record<string, string>
): boolean {
return tab === CLAUDE_CONFIG_SETTINGS_TAB
? readSettingsPath(settings, claudeConfigOptionPath(option)) !== undefined
: env[option.key] !== undefined;
}
function claudeSettingsDraftObject(value: string): Record<string, unknown> {
try {
const parsed = JSON.parse(value) as unknown;
return isPlainRecord(parsed) ? parsed : {};
} catch {
return {};
}
}
function envRowsRecord(rows: KeyValueDraftRow[]): Record<string, string> {
const result: Record<string, string> = {};
for (const row of rows) {
const key = row.key.trim();
if (key) {
result[key] = row.value;
}
}
return result;
}
function updateEnvRows(rows: KeyValueDraftRow[], key: string, value: string): KeyValueDraftRow[] {
const nextRows = rows.filter((row) => row.key.trim() !== key);
return value.trim()
? [...nextRows, createKeyValueDraftRow(key, value)]
: nextRows;
}
function cloneSettingsObject(value: Record<string, unknown>): Record<string, unknown> {
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
}
function readSettingsPath(value: Record<string, unknown>, pathParts: string[]): unknown {
let current: unknown = value;
for (const key of pathParts) {
if (!isPlainRecord(current) || !hasOwnValue(current, key)) {
return undefined;
}
current = current[key];
}
return current;
}
function setSettingsPath(target: Record<string, unknown>, pathParts: string[], value: unknown): void {
const key = pathParts[0];
if (!key) {
return;
}
if (pathParts.length === 1) {
target[key] = value;
return;
}
const current = target[key];
const child = isPlainRecord(current) ? current : {};
target[key] = child;
setSettingsPath(child, pathParts.slice(1), value);
}
function deleteSettingsPath(target: Record<string, unknown>, pathParts: string[]): boolean {
const key = pathParts[0];
if (!key || !hasOwnValue(target, key)) {
return false;
}
if (pathParts.length === 1) {
delete target[key];
return true;
}
const child = target[key];
if (!isPlainRecord(child)) {
return false;
}
const changed = deleteSettingsPath(child, pathParts.slice(1));
if (changed && Object.keys(child).length === 0) {
delete target[key];
}
return changed;
}
function hasOwnValue(value: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(value, key);
}
type ClaudeConfigInputParseResult =
| { ok: true; value: unknown | undefined }
| { error: string; ok: false };
function parseClaudeConfigInputValue(option: ClaudeCodeConfigOption, value: string): ClaudeConfigInputParseResult {
const trimmed = value.trim();
if (!trimmed) {
return { ok: true, value: undefined };
}
switch (option.valueKind) {
case "boolean": {
const normalized = trimmed.toLowerCase();
if (normalized === "true" || normalized === "false") {
return { ok: true, value: normalized === "true" };
}
return { error: "Enter true or false.", ok: false };
}
case "integer": {
const parsed = Number(trimmed);
return Number.isInteger(parsed)
? { ok: true, value: parsed }
: { error: "Enter an integer.", ok: false };
}
case "number": {
const parsed = Number(trimmed);
return Number.isFinite(parsed)
? { ok: true, value: parsed }
: { error: "Enter a number.", ok: false };
}
case "array": {
try {
const parsed = JSON.parse(trimmed) as unknown;
return Array.isArray(parsed)
? { ok: true, value: parsed }
: { error: "Enter a JSON array.", ok: false };
} catch {
return { error: "Enter a JSON array.", ok: false };
}
}
case "object": {
try {
const parsed = JSON.parse(trimmed) as unknown;
return isPlainRecord(parsed)
? { ok: true, value: parsed }
: { error: "Enter a JSON object.", ok: false };
} catch {
return { error: "Enter a JSON object.", ok: false };
}
}
default:
return { ok: true, value };
}
}
function formatClaudeConfigInputValue(value: unknown): string {
if (value === undefined) {
return "";
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return JSON.stringify(value);
}
function claudeConfigPlaceholder(option: ClaudeCodeConfigOption, tab: string): string {
if (tab === CLAUDE_CONFIG_ENV_TAB) {
return option.example?.trim() || option.default?.trim() || "";
}
const exampleValue = claudeConfigExampleValue(option);
if (exampleValue !== undefined) {
return formatClaudeConfigInputValue(exampleValue);
}
switch (option.valueKind) {
case "array":
return "[]";
case "boolean":
return "true";
case "integer":
case "number":
return "1";
case "object":
return "{}";
default:
return option.default?.trim() || "";
}
}
function claudeConfigExampleValue(option: ClaudeCodeConfigOption): unknown {
if (!option.example?.trim()) {
return undefined;
}
try {
const parsed = JSON.parse(option.example) as unknown;
return isPlainRecord(parsed) ? readSettingsPath(parsed, claudeConfigOptionPath(option)) : undefined;
} catch {
return undefined;
}
}
function isManagedOnlyClaudeSetting(option: ClaudeCodeConfigOption): boolean {
const scope = option.scope?.trim().toLowerCase() ?? "";
return scope.startsWith("managed") && !scope.includes("any file");
}
function createProfileRoutingRuleDraftFromRule(rule: RouterRule): AddRoutingRuleDraft {
const draft = createRoutingRuleDraftFromRule(rule);
return draft.type === "condition"
@ -1280,8 +1832,8 @@ function profileDraftValidation(
draft: AddProfileDraft,
botConfigs: BotGatewaySavedConfig[],
availableModelCount: number
): Partial<Record<"allowedModels" | "bot" | "defaultModel" | "env" | "handoff" | "kimiModel" | "models" | "name", string>> {
const issues: Partial<Record<"allowedModels" | "bot" | "defaultModel" | "env" | "handoff" | "kimiModel" | "models" | "name", string>> = {};
): Partial<Record<"allowedModels" | "bot" | "claudeSettings" | "defaultModel" | "env" | "handoff" | "kimiModel" | "models" | "name", string>> {
const issues: Partial<Record<"allowedModels" | "bot" | "claudeSettings" | "defaultModel" | "env" | "handoff" | "kimiModel" | "models" | "name", string>> = {};
if (!draft.name.trim()) {
issues.name = "Profile name is required.";
}
@ -1308,6 +1860,9 @@ function profileDraftValidation(
if (!validateProfileEnvRows(draft.envRows)) {
issues.env = "Environment variable rows need valid keys.";
}
if (draft.agent === "claude-code" && !isClaudeSettingsDraftValid(draft.claudeSettingsText)) {
issues.claudeSettings = "Claude settings JSON must be an object.";
}
return issues;
}

View file

@ -214,6 +214,11 @@ function pluginPermissionAlias(value: string): string {
return "provider-account-connectors";
case "core-gateway":
return "core-gateway-config";
case "core-gateway-plugin":
case "core-gateway-plugins":
case "gateway-plugin":
case "gateway-plugins":
return "core-gateway-plugins";
case "provider-plugin":
case "provider-plugins":
case "core-provider-plugin":
@ -571,6 +576,9 @@ export function wrapperPluginCapability(item: Record<string, unknown>): string {
const providerPlugins = isPlainRecord(coreGateway) && Array.isArray(coreGateway.providerPlugins) ? coreGateway.providerPlugins.length : 0;
if (providerPlugins > 0) capabilities.push(`${providerPlugins} provider ${providerPlugins === 1 ? "plugin" : "plugins"}`);
const coreGatewayPlugins = isPlainRecord(coreGateway) && Array.isArray(coreGateway.plugins) ? coreGateway.plugins.length : 0;
if (coreGatewayPlugins > 0) capabilities.push(`${coreGatewayPlugins} core gateway ${coreGatewayPlugins === 1 ? "plugin" : "plugins"}`);
const virtualModels = isPlainRecord(coreGateway) && Array.isArray(coreGateway.virtualModelProfiles) ? coreGateway.virtualModelProfiles.length : 0;
if (virtualModels > 0) capabilities.push(`${virtualModels} Fusion ${virtualModels === 1 ? "profile" : "profiles"}`);

View file

@ -454,16 +454,35 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Clear filters or broaden the search to find more request logs.": "Clear filters or broaden the search to find more request logs.",
"Default model is required.": "Default model is required.",
"Default model is required when allowed model list is set.": "Default model is required when allowed model list is set.",
"All items": "All items",
"Configured": "Configured",
"Enable at least one provider model before saving this profile.": "Enable at least one provider model before saving this profile.",
"Enable request logs": "Enable request logs",
"Claude settings": "Claude settings",
"Claude settings JSON must be an object.": "Claude settings JSON must be an object.",
"Edit Claude settings": "Edit Claude settings",
"Enter a JSON array.": "Enter a JSON array.",
"Enter a JSON object.": "Enter a JSON object.",
"Enter a number.": "Enter a number.",
"Enter an integer.": "Enter an integer.",
"Enter true or false.": "Enter true or false.",
"Environment": "Environment",
"Environment variable rows need valid keys.": "Environment variable rows need valid keys.",
"Idle seconds must be between 30 and 86400.": "Idle seconds must be between 30 and 86400.",
"Managed settings can only be set by administrators.": "Managed settings can only be set by administrators.",
"No request logs match the current filters.": "No request logs match the current filters.",
"No request logs yet.": "No request logs yet.",
"No matching Claude settings": "No matching Claude settings",
"No matching environment variables": "No matching environment variables",
"Session not found or outside the selected range": "Session not found or outside the selected range",
"Models, paths, routing, bot, compact, and env": "Models, paths, routing, bot, compact, and env",
"Models, paths, routing, bot, compact, Claude settings, and env": "Models, paths, routing, bot, compact, Claude settings, and env",
"Paths, routing, bot, compact, and env": "Paths, routing, bot, compact, and env",
"Paths, bot, compact, and env": "Paths, bot, compact, and env",
"Search Claude settings": "Search Claude settings",
"Search environment variables": "Search environment variables",
"Settings": "Settings",
"Unset": "Unset",
"Enhanced route": "Enhanced route",
"Enhanced route description Claude Code": "CCR built-in Claude Code routing optimizes requests to third-party models for this profile.",
"Enhanced route description Codex": "CCR built-in Codex routing optimizes requests to third-party models for this profile.",
@ -1129,6 +1148,17 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Disable provider": "停用供应商",
"Endpoint": "端点",
"Entry mode": "入口模式",
"All items": "全部配置项",
"Claude settings": "Claude 设置",
"Claude settings JSON must be an object.": "Claude 设置 JSON 必须是对象。",
"Configured": "已配置",
"Edit Claude settings": "编辑 Claude 设置",
"Enter a JSON array.": "请输入 JSON 数组。",
"Enter a JSON object.": "请输入 JSON 对象。",
"Enter a number.": "请输入数字。",
"Enter an integer.": "请输入整数。",
"Enter true or false.": "请输入 true 或 false。",
"Environment": "环境变量",
"Environment variables": "环境变量",
"Environment variable rows need valid keys.": "环境变量行需要填写有效的 Key。",
"Endpoint Health": "端点健康",
@ -1431,9 +1461,16 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Retry attempts": "重试尝试",
"Weighted order": "按权重排序",
"Models, paths, routing, bot, compact, and env": "模型、路径、路由、Bot、压缩和环境变量",
"Models, paths, routing, bot, compact, Claude settings, and env": "模型、路径、路由、Bot、压缩、Claude 设置和环境变量",
"Paths, routing, bot, compact, and env": "路径、路由、Bot、压缩和环境变量",
"Paths, bot, compact, and env": "路径、Bot、压缩和环境变量",
"Managed settings can only be set by administrators.": "托管配置只能由管理员设置。",
"No matching Claude settings": "没有匹配的 Claude 设置",
"No matching environment variables": "没有匹配的环境变量",
"Ready to route": "可以开始路由",
"Search Claude settings": "搜索 Claude 设置",
"Search environment variables": "搜索环境变量",
"Settings": "设置项",
"Enhanced route": "增强路由",
"Enhanced route description Claude Code": "CCR 内置的 Claude Code 路由会针对该配置使用三方模型时的请求做优化。",
"Enhanced route description Codex": "CCR 内置的 Codex 路由会针对该配置使用三方模型时的请求做优化。",
@ -2409,6 +2446,11 @@ export function useAppNumberLocale(): Intl.LocalesArgument {
return copy === appCopy.zh ? "zh-CN" : "en-US";
}
export function useResolvedAppLanguage(): ResolvedLanguage {
const copy = useContext(AppI18nContext);
return copy === appCopy.zh ? "zh" : "en";
}
export function translateText(copy: AppCopy, value: string): string {
return copy.text[value] ?? value;
}

View file

@ -459,6 +459,89 @@ function profileRoutingConfigFromDraft(draft: AddProfileDraft): ProfileRoutingCo
};
}
export function parseClaudeSettingsDraft(value: string): Record<string, unknown> | undefined {
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
try {
return normalizeClaudeSettingsConfig(JSON.parse(trimmed));
} catch {
return undefined;
}
}
export function isClaudeSettingsDraftValid(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) {
return true;
}
try {
const parsed = JSON.parse(trimmed) as unknown;
return isPlainRecord(parsed);
} catch {
return false;
}
}
export function formatClaudeSettingsDraft(value: unknown): string {
const settings = normalizeClaudeSettingsConfig(value);
return settings ? JSON.stringify(settings, null, 2) : "{}";
}
export function normalizeClaudeSettingsConfig(value: unknown): Record<string, unknown> | undefined {
if (!isPlainRecord(value)) {
return undefined;
}
const normalized = normalizeClaudeSettingsObject(value);
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
function normalizeClaudeSettingsObject(value: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [rawKey, rawValue] of Object.entries(value)) {
const key = rawKey.trim();
if (!key || key === "__proto__" || key === "constructor" || key === "prototype") {
continue;
}
const item = normalizeClaudeSettingsValue(rawValue);
if (item !== undefined) {
result[key] = item;
}
}
return result;
}
function normalizeClaudeSettingsValue(value: unknown): unknown {
if (value === null || typeof value === "string" || typeof value === "boolean") {
return value;
}
if (typeof value === "number") {
return Number.isFinite(value) ? value : undefined;
}
if (Array.isArray(value)) {
return value
.map(normalizeClaudeSettingsValue)
.filter((item) => item !== undefined);
}
if (isPlainRecord(value)) {
return normalizeClaudeSettingsObject(value);
}
return undefined;
}
function claudeSettingsCount(value: unknown): number {
const settings = normalizeClaudeSettingsConfig(value);
return settings ? countClaudeSettingsLeaves(settings) : 0;
}
function countClaudeSettingsLeaves(value: unknown): number {
if (!isPlainRecord(value) || Object.keys(value).length === 0) {
return 1;
}
return Object.values(value).reduce<number>((count, item) => count + countClaudeSettingsLeaves(item), 0);
}
export function createProfileDraft(agent: ProfileConfig["agent"] = "claude-code", name?: string): AddProfileDraft {
const surface = agent === "workbuddy" || agent === "zcode" || agent === "claude-design" ? "app" : "cli";
return {
@ -466,6 +549,7 @@ export function createProfileDraft(agent: ProfileConfig["agent"] = "claude-code"
appPath: "",
availableModels: [],
...createBotGatewayDraft(),
claudeSettingsText: "{}",
configFile: defaultCodexConfigFile(agent),
envRows: agent === "claude-code" ? keyValueRowsFromRecord(claudeCodeProfileEnv()) : [],
fableModel: "",
@ -518,6 +602,7 @@ export function createProfileDraftFromProfile(profile: ProfileConfig, botConfigs
appPath: profile.appPath ?? "",
botConfigId,
botEnabled: surface !== "cli" && Boolean(selectedBot || profile.botGateway?.enabled),
claudeSettingsText: formatClaudeSettingsDraft(profile.claudeSettings),
envRows: keyValueRowsFromRecord(claudeCodeProfileEnv(profile.env ?? {})),
availableModels: profileDraftAvailableModels(profile),
fableModel: profile.fableModel ?? "",
@ -581,6 +666,9 @@ export function isProfileDraftSubmittable(draft: AddProfileDraft): boolean {
if (!validateProfileEnvRows(draft.envRows)) {
return false;
}
if (draft.agent === "claude-code" && !isClaudeSettingsDraftValid(draft.claudeSettingsText)) {
return false;
}
const botAllowed = draft.surface !== "cli";
if (botAllowed && draft.botEnabled && !draft.botConfigId.trim()) {
return false;
@ -648,6 +736,7 @@ export function profileConfigFromDraft(
appPath: draft.appPath,
availableModels: profileConfigAvailableModelsFromDraft(draft),
...botGateway,
claudeSettings: draft.agent === "claude-code" ? parseClaudeSettingsDraft(draft.claudeSettingsText) : undefined,
configFile: draft.configFile,
enabled: existingProfile?.enabled ?? true,
env: draft.agent === "claude-code"
@ -1202,6 +1291,10 @@ export function profileSummaryItems(
const envSummaryItems = envCount > 0
? [{ label: t("Environment variables"), value: String(envCount) }]
: [];
const claudeSettingsItemCount = profile.agent === "claude-code" ? claudeSettingsCount(profile.claudeSettings) : 0;
const claudeSettingsSummaryItems = claudeSettingsItemCount > 0
? [{ label: t("Claude settings"), value: String(claudeSettingsItemCount) }]
: [];
const appPath = profile.appPath?.trim() || "";
const appPathSummaryItems = appPath && surface !== "cli" && profile.agent !== "zcode"
? [{ label: t("APP_PATH"), value: appPath }]
@ -1265,6 +1358,7 @@ export function profileSummaryItems(
...routingSummaryItems,
...botSummaryItems,
...appPathSummaryItems,
...claudeSettingsSummaryItems,
...envSummaryItems
];
}
@ -1313,11 +1407,13 @@ export function normalizeProfileItem(profile: ProfileConfig, index: number): Pro
const routing = normalizeProfileRoutingConfig(profile.routing);
if (agent === "claude-code") {
const appPath = profile.appPath?.trim() || "";
const claudeSettings = normalizeClaudeSettingsConfig(profile.claudeSettings);
return {
agent: "claude-code",
...(surface !== "cli" && appPath ? { appPath } : {}),
...(botConfigId ? { botConfigId } : {}),
...(botGateway ? { botGateway } : {}),
...(claudeSettings ? { claudeSettings } : {}),
...(availableModels ? { availableModels } : {}),
enabled: profile.enabled,
env: claudeCodeProfileEnv(env),
@ -1402,6 +1498,7 @@ export function legacyProfileItemsFromProfileConfig(profile: AppConfig["profile"
return [
normalizeProfileItem({
agent: "claude-code",
claudeSettings: profile.claudeCode.claudeSettings,
enabled: profile.claudeCode.enabled,
env: claudeCodeProfileEnv(),
fableModel: profile.claudeCode.fableModel,
@ -1477,6 +1574,7 @@ export function normalizeUnknownProfileItem(value: Record<string, unknown>, inde
: undefined,
botConfigId: typeof value.botConfigId === "string" ? value.botConfigId : typeof value.bot_config_id === "string" ? value.bot_config_id : undefined,
botGateway: normalizeBotGatewayRuntimeConfig(value.botGateway ?? value.bot_gateway ?? value.bot),
claudeSettings: normalizeClaudeSettingsConfig(value.claudeSettings ?? value.claude_settings),
cliMiddleware: typeof value.cliMiddleware === "boolean" ? value.cliMiddleware : undefined,
codexCliPath: typeof value.codexCliPath === "string" ? value.codexCliPath : undefined,
codexHome: typeof value.codexHome === "string" ? value.codexHome : undefined,

View file

@ -182,6 +182,7 @@ export type AddProfileDraft = {
botHandoffPhoneBluetoothTargets: string;
botHandoffPhoneWifiTargets: string;
botPlatform: string;
claudeSettingsText: string;
configFile: string;
envRows: KeyValueDraftRow[];
fableModel: string;

5796
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,618 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const outputFile = path.join(projectRoot, "packages", "ui", "src", "generated", "claude-code-config-options.json");
const sources = {
envEn: "https://code.claude.com/docs/en/env-vars.md",
envZh: "https://code.claude.com/docs/zh-CN/env-vars.md",
settingsEn: "https://code.claude.com/docs/en/settings-reference.md",
settingsZh: "https://code.claude.com/docs/zh-CN/settings.md"
};
const excludedSettingKeys = new Set([
"env"
]);
const generatedBy = "scripts/generate-claude-code-config-options.mjs";
async function main() {
let previous;
try {
previous = JSON.parse(readFileSync(outputFile, "utf8"));
} catch {
previous = undefined;
}
try {
const docs = await fetchDocs(sources);
const settingsEn = parseSettingsReference(docs.settingsEn, "en");
const settingsZh = parseSettingsReference(docs.settingsZh, "zh");
const envEn = parseEnvReference(docs.envEn);
const envZh = parseEnvReference(docs.envZh);
const generated = {
generatedAt: new Date().toISOString(),
generatedBy,
sourceUrls: sources,
settings: mergeLocalizedSettings(settingsEn, settingsZh),
env: mergeLocalizedEnv(envEn, envZh)
};
writeJsonIfChanged(outputFile, generated);
console.log(`[claude-config] Generated ${generated.settings.length} settings and ${generated.env.length} environment variables from official Claude Code docs.`);
} catch (error) {
if (previous?.settings?.length || previous?.env?.length) {
console.warn(`[claude-config] Failed to refresh official Claude Code docs; reusing existing metadata. ${formatError(error)}`);
return;
}
throw error;
}
}
async function fetchDocs(urls) {
const entries = await Promise.all(Object.entries(urls).map(async ([key, url]) => {
const response = await fetch(url, {
headers: {
"accept": "text/markdown,text/plain,*/*",
"user-agent": "claude-code-router-build"
}
});
if (!response.ok) {
throw new Error(`${url} returned HTTP ${response.status}`);
}
return [key, await response.text()];
}));
return Object.fromEntries(entries);
}
function parseSettingsReference(markdown, locale) {
const globalConfigIndex = markdown.indexOf("\n## Global config settings");
const scopedMarkdown = globalConfigIndex > 0 ? markdown.slice(0, globalConfigIndex) : markdown;
const sections = markdownSections(scopedMarkdown, /^### `([^`]+)`/gm);
if (sections.length > 0) {
return sections
.map((section) => settingFromSection(section, locale))
.filter((option) => option && !excludedSettingKeys.has(option.key));
}
return settingOptionsFromTables(scopedMarkdown)
.filter((option) => !excludedSettingKeys.has(option.key));
}
function settingFromSection(section, locale) {
const key = section.title.trim();
if (!key || key.includes(" ")) {
return undefined;
}
const scope = firstMetadataValue(section.body, locale === "zh" ? "作用域" : "Scope");
const type = firstMetadataValue(section.body, locale === "zh" ? "类型" : "Type");
const defaultValue = firstMetadataValue(section.body, locale === "zh" ? "默认值" : "Default");
const example = firstCodeBlock(section.body, "json");
return {
key,
path: key.split("."),
scope,
type,
default: defaultValue,
example,
valueKind: inferValueKind(type, example),
enumValues: enumValuesFromSection(section.body),
description: cleanDescription(firstDescription(section.body))
};
}
function parseEnvReference(markdown) {
const variablesIndex = markdown.indexOf("\n## Variables");
const scopedMarkdown = variablesIndex > 0 ? markdown.slice(variablesIndex) : markdown;
const sections = markdownSections(scopedMarkdown, /^### `([^`]+)`/gm);
if (sections.length > 0) {
return sections
.map((section) => envFromSection(section))
.filter(Boolean);
}
return envFromTables(scopedMarkdown);
}
function settingOptionsFromTables(markdown) {
const options = [];
for (const table of markdownTables(markdown)) {
const headers = table.headers.map((item) => stripMarkdown(item).toLowerCase());
const keyIndex = headers.findIndex((item) => item.includes("key") || item.includes("键"));
const descriptionIndex = headers.findIndex((item) => item.includes("description") || item.includes("描述"));
const exampleIndex = headers.findIndex((item) => item.includes("example") || item.includes("示例"));
if (keyIndex < 0 || descriptionIndex < 0) {
continue;
}
for (const row of table.rows) {
const key = firstBacktickValue(row[keyIndex]) || stripMarkdown(row[keyIndex]).trim();
if (!key || key.includes(" ")) {
continue;
}
const example = exampleIndex >= 0 ? cleanInline(row[exampleIndex]) : "";
options.push({
key,
path: key.split("."),
scope: "",
type: "",
default: "",
example,
valueKind: inferValueKind("", example),
enumValues: enumValuesFromSection(row[descriptionIndex]),
description: cleanDescription(row[descriptionIndex])
});
}
}
return options;
}
function envFromSection(section) {
const key = section.title.trim();
if (!isEnvName(key)) {
return undefined;
}
const example = firstEnvExample(section.body, key) || placeholderForEnvName(key);
return {
key,
description: cleanDescription(firstDescription(section.body)),
example,
valueKind: inferEnvValueKind(key, section.body, example)
};
}
function envFromTables(markdown) {
const rows = [];
for (const table of markdownTables(markdown)) {
const headers = table.headers.map((item) => item.toLowerCase());
const keyIndex = headers.findIndex((item) => item.includes("variable") || item.includes("变量"));
const purposeIndex = headers.findIndex((item) => item.includes("purpose") || item.includes("description") || item.includes("用途") || item.includes("目的") || item.includes("描述"));
if (keyIndex < 0 || purposeIndex < 0) {
continue;
}
for (const row of table.rows) {
const key = stripMarkdown(row[keyIndex]).trim();
if (!isEnvName(key)) {
continue;
}
const description = cleanDescription(row[purposeIndex]);
const valueKind = inferEnvValueKind(key, description, "");
rows.push({
key,
description,
example: envExampleFromTableCell(key, row[purposeIndex], valueKind),
valueKind
});
}
}
return rows;
}
function mergeLocalizedSettings(enOptions, zhOptions) {
const zhByKey = new Map(zhOptions.map((item) => [item.key, item]));
return enOptions
.map((option) => {
const zh = zhByKey.get(option.key);
return {
key: option.key,
path: option.path,
scope: option.scope,
type: option.type,
default: option.default,
example: option.example || zh?.example || placeholderForKind(option.valueKind),
valueKind: option.valueKind,
enumValues: option.enumValues.length ? option.enumValues : zh?.enumValues ?? [],
description: {
en: option.description,
zh: zh?.description || option.description
}
};
})
.filter((option) => isConfigurableSettingsOption(option))
.sort((a, b) => a.key.localeCompare(b.key));
}
function mergeLocalizedEnv(enOptions, zhOptions) {
const zhByKey = new Map(zhOptions.map((item) => [item.key, item]));
return uniqueByKey(enOptions)
.map((option) => {
const zh = zhByKey.get(option.key);
return {
key: option.key,
example: option.example || zh?.example || placeholderForEnvName(option.key),
valueKind: option.valueKind,
description: {
en: option.description,
zh: zh?.description || option.description
}
};
})
.filter((option) => isEnvName(option.key) && option.description.en)
.sort((a, b) => a.key.localeCompare(b.key));
}
function isConfigurableSettingsOption(option) {
if (!option.description.en) {
return false;
}
if (option.scope && /^global config$/i.test(stripMarkdown(option.scope))) {
return false;
}
return true;
}
function markdownSections(markdown, headingPattern) {
const matches = [...markdown.matchAll(headingPattern)];
return matches.map((match, index) => {
const next = matches[index + 1];
return {
title: match[1],
body: markdown.slice(match.index + match[0].length, next?.index ?? markdown.length)
};
});
}
function markdownTables(markdown) {
const lines = markdown.split(/\r?\n/);
const tables = [];
for (let index = 0; index < lines.length; index += 1) {
if (!isTableSeparator(lines[index + 1] || "") || !lines[index].includes("|")) {
continue;
}
const headers = splitMarkdownTableRow(lines[index]);
const rows = [];
index += 2;
while (index < lines.length && lines[index].includes("|")) {
rows.push(splitMarkdownTableRow(lines[index]));
index += 1;
}
tables.push({ headers, rows });
}
return tables;
}
function splitMarkdownTableRow(line) {
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
const cells = [];
let current = "";
let escaped = false;
let code = false;
for (const char of trimmed) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === "\\") {
current += char;
escaped = true;
continue;
}
if (char === "`") {
code = !code;
current += char;
continue;
}
if (char === "|" && !code) {
cells.push(current.trim());
current = "";
continue;
}
current += char;
}
cells.push(current.trim());
return cells;
}
function isTableSeparator(line) {
return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line);
}
function firstMetadataValue(markdown, label) {
const escaped = escapeRegExp(label);
const pattern = new RegExp(`^\\s*\\*\\s*\\*\\*${escaped}\\*\\*:\\s*(.+)$`, "im");
return cleanInline(markdown.match(pattern)?.[1] || "");
}
function firstDescription(markdown) {
const withoutImports = markdown
.replace(/^\s*import\s+.*$/gm, "")
.replace(/^\s*<Warning>[\s\S]*?<\/Warning>\s*/m, "")
.trimStart();
const lines = withoutImports.split(/\r?\n/);
const paragraph = [];
let inCode = false;
for (const line of lines) {
if (/^\s*```/.test(line)) {
inCode = !inCode;
continue;
}
if (inCode) {
continue;
}
const trimmed = line.trim();
if (!trimmed) {
if (paragraph.length > 0) {
break;
}
continue;
}
if (
trimmed.startsWith("* **") ||
trimmed.startsWith("|") ||
trimmed.startsWith("<") ||
trimmed.startsWith("{") ||
trimmed.startsWith("}") ||
trimmed.startsWith("#### ") ||
trimmed.startsWith("### ") ||
trimmed.startsWith("## ")
) {
if (paragraph.length > 0) {
break;
}
continue;
}
paragraph.push(trimmed);
}
return paragraph.join(" ");
}
function firstCodeBlock(markdown, lang) {
const pattern = new RegExp("```" + lang + "[^\\n]*\\n([\\s\\S]*?)```", "i");
const code = markdown.match(pattern)?.[1]?.trim();
return code ? stripCommonIndent(code) : "";
}
function firstEnvExample(markdown, key) {
const examples = [];
const codePattern = /```(?:bash|json|powershell|batch|text)?[^\n]*\n([\s\S]*?)```/gi;
for (const match of markdown.matchAll(codePattern)) {
examples.push(match[1]);
}
const assignmentPattern = new RegExp(`${escapeRegExp(key)}\\s*[:=]\\s*[\"']?([^\"'\\n,}]+)`, "i");
for (const example of examples) {
const value = example.match(assignmentPattern)?.[1]?.trim();
if (value) {
return value;
}
}
return firstBacktickValue(markdown);
}
function firstBacktickValue(markdown) {
const match = markdown.match(/`([^`\n]+)`/);
return match?.[1]?.trim() || "";
}
function envExampleFromTableCell(key, markdown, valueKind) {
const assignmentPattern = new RegExp(`${escapeRegExp(key)}\\s*[:=]\\s*["']?([^"'\\s,}]+)`, "i");
const assignment = markdown.match(assignmentPattern)?.[1]?.trim();
if (assignment) {
return assignment;
}
for (const value of backtickValues(markdown)) {
const assignedValue = value.match(assignmentPattern)?.[1]?.trim();
if (assignedValue) {
return assignedValue;
}
if (valueKind === "boolean-string" && /^(?:0|1|true|false)$/i.test(value)) {
return value;
}
if (valueKind === "number-string" && /^\d+$/.test(value)) {
return value;
}
if ((key.includes("BASE_URL") || key.includes("ENDPOINT") || key.includes("URL")) && /^https?:\/\//i.test(value)) {
return value;
}
if (key.includes("MODEL") && /^claude-/i.test(value)) {
return value;
}
if (key.includes("REGION") && /^[a-z]{2}(?:-[a-z]+)+-\d$/.test(value)) {
return value;
}
}
return placeholderForEnvName(key);
}
function backtickValues(markdown) {
return [...String(markdown || "").matchAll(/`([^`\n]+)`/g)]
.map((match) => match[1].trim())
.filter(Boolean);
}
function enumValuesFromSection(markdown) {
const values = [];
const seen = new Set();
const enumIntro = /one of|valid values|accepts|有效值|可选值|以下值/i;
if (!enumIntro.test(markdown)) {
return values;
}
for (const match of markdown.matchAll(/^\s*[*-]\s*`"([^"`]+)"`/gm)) {
const value = match[1].trim();
if (!seen.has(value)) {
seen.add(value);
values.push(value);
}
}
return values;
}
function inferValueKind(type, example) {
const normalized = stripMarkdown(type).toLowerCase();
if (normalized.includes("boolean")) {
return "boolean";
}
if (normalized.includes("integer")) {
return "integer";
}
if (normalized.includes("number") || normalized.includes("float")) {
return "number";
}
if (normalized.includes("array")) {
return "array";
}
if (normalized.includes("object") || normalized.includes("dictionary") || normalized.includes("map")) {
return "object";
}
const parsed = parseJsonExampleValue(example);
if (Array.isArray(parsed)) {
return "array";
}
if (parsed && typeof parsed === "object") {
return "object";
}
if (typeof parsed === "boolean") {
return "boolean";
}
if (typeof parsed === "number") {
return Number.isInteger(parsed) ? "integer" : "number";
}
return "string";
}
function inferEnvValueKind(key, description, example) {
const text = `${key} ${description} ${example}`.toLowerCase();
if (
/\b(?:1|true)\b.*\b(?:0|false)\b/.test(text) ||
/\b(?:0|false)\b.*\b(?:1|true)\b/.test(text) ||
/\bset to\s+(?:1|0|true|false)\b/.test(text) ||
/设置为\s*(?:1|0|true|false)\b/.test(text)
) {
return "boolean-string";
}
if (text.includes("milliseconds") || text.includes("tokens") || text.includes("timeout") || /^MAX_/.test(key) || key.endsWith("_MS")) {
return "number-string";
}
return "string";
}
function parseJsonExampleValue(example) {
if (!example) {
return undefined;
}
try {
const parsed = JSON.parse(example);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
const entries = Object.entries(parsed);
return entries.length === 1 ? entries[0][1] : parsed;
}
return parsed;
} catch {
return undefined;
}
}
function placeholderForKind(kind) {
if (kind === "boolean") {
return "true";
}
if (kind === "integer") {
return "300000";
}
if (kind === "number") {
return "0.05";
}
if (kind === "array") {
return "[]";
}
if (kind === "object") {
return "{}";
}
return "value";
}
function placeholderForEnvName(key) {
if (key.includes("API_KEY") || key.includes("AUTH_TOKEN")) {
return "sk-...";
}
if (key.includes("BASE_URL") || key.includes("ENDPOINT") || key.includes("URL")) {
return "https://example.com";
}
if (key.endsWith("_MS") || key.includes("TIMEOUT") || key.includes("TOKENS") || key.includes("RETRIES")) {
return "300000";
}
if (key.includes("DISABLE") || key.includes("ENABLE") || key.includes("SKIP") || key.includes("USE_")) {
return "1";
}
if (key.includes("MODEL")) {
return "claude-sonnet-4-6";
}
if (key.includes("REGION")) {
return "us-east-1";
}
return "value";
}
function cleanDescription(value) {
return cleanInline(value)
.replace(/\s+/g, " ")
.trim();
}
function cleanInline(value) {
return stripMarkdown(value)
.replace(/\s+/g, " ")
.trim();
}
function stripMarkdown(value) {
return String(value || "")
.replace(/<[^>]+>/g, "")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/`([^`]+)`/g, "$1")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\*([^*]+)\*/g, "$1")
.replace(/&quot;/g, "\"")
.replace(/&amp;/g, "&")
.replace(/&#x27;/g, "'");
}
function stripCommonIndent(value) {
const lines = value.replace(/\s+$/g, "").split(/\r?\n/);
const indents = lines
.filter((line) => line.trim())
.map((line) => line.match(/^\s*/)?.[0].length ?? 0);
const indent = indents.length > 0 ? Math.min(...indents) : 0;
return indent > 0 ? lines.map((line) => line.slice(indent)).join("\n") : lines.join("\n");
}
function uniqueByKey(options) {
const seen = new Set();
return options.filter((option) => {
if (seen.has(option.key)) {
return false;
}
seen.add(option.key);
return true;
});
}
function isEnvName(value) {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
}
function writeJsonIfChanged(file, value) {
const content = `${JSON.stringify(value, null, 2)}\n`;
let previous = "";
try {
previous = readFileSync(file, "utf8");
} catch {
previous = "";
}
if (previous === content) {
return;
}
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, content, "utf8");
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function formatError(error) {
return error instanceof Error ? error.message : String(error);
}
await main();

View file

@ -37,6 +37,18 @@ test("package code and tests do not reach into another package through relative
assert.deepEqual(violations, []);
});
test("npm and Docker builds carry the plugin-capable gateway runtime", () => {
const buildConfig = readFileSync(path.join(projectRoot, "build", "esbuild.config.mjs"), "utf8");
const manifest = JSON.parse(readFileSync(path.join(projectRoot, "package.json"), "utf8"));
assert.match(buildConfig, /export function createCliBuildOptions[\s\S]*?entryPoints:\s*\[[\s\S]*?gatewayRuntimeInput/);
assert.match(buildConfig, /export function createCoreServerBuildOptions[\s\S]*?entryPoints:\s*\[[\s\S]*?gatewayRuntimeInput/);
assert.equal(manifest.scripts["docker:prepare-gateway"], "node build/docker-local-gateway.mjs prepare");
assert.equal(manifest.scripts["docker:build"], "node build/docker-image.mjs");
assert.equal(manifest.scripts["docker:compose:build"], "node build/docker-compose.mjs build");
assert.equal(manifest.scripts["docker:compose:up"], "node build/docker-compose.mjs up -d --build");
});
function sourceFiles(directory) {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const file = path.join(directory, entry.name);

View file

@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto";
import net from "node:net";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { prepareLocalGatewayPackage } from "../../build/docker-local-gateway.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..", "..");
@ -19,6 +20,7 @@ let volumeCreated = false;
try {
if (process.env.CCR_DOCKER_TEST_SKIP_BUILD !== "1") {
prepareLocalGatewayPackage();
run("docker", ["build", "-t", imageName, "."], { cwd: projectRoot });
}
@ -152,16 +154,23 @@ try {
const restartedGatewayStatusPayload = await restartedGatewayStatus.json();
assert.equal(restartedGatewayStatusPayload.ok, true);
assert.equal(restartedGatewayStatusPayload.value.state, "running");
assert.equal(restartedGatewayStatusPayload.value.coreEndpoint, restartedGatewayStatusPayload.value.endpoint);
const runningGatewayHealth = await fetch(`${baseUrl}/health`);
assert.equal(runningGatewayHealth.status, 200);
assert.equal((await runningGatewayHealth.json()).status, "running");
const runningGatewayHealthPayload = await runningGatewayHealth.json();
assert.equal(runningGatewayHealthPayload.status, "ok");
assert.equal(typeof runningGatewayHealthPayload.runtimeId, "string");
assert.ok(runningGatewayHealthPayload.runtimeId.length > 0);
const configStorage = dockerConfigStorage(containerId);
assert.equal(configStorage.configDatabase, true);
assert.equal(configStorage.legacyConfigJson, false);
assert.equal(configStorage.gatewayConfigJson, false);
assert.equal(configStorage.gatewayRuntimeJson, false);
assert.equal(Object.hasOwn(configStorage.persistedGateway ?? {}, "coreHost"), false);
assert.equal(Object.hasOwn(configStorage.persistedGateway ?? {}, "corePort"), false);
assert.doesNotMatch(run("docker", ["logs", containerId]), /Using compatibility gateway server/);
console.log(`Docker smoke test passed for ${imageName} on ${baseUrl}`);
} finally {
@ -219,11 +228,27 @@ function dockerConfigStorage(containerId) {
const fs = require("node:fs");
const path = require("node:path");
const configDir = "/data/.claude-code-router";
const appConfigDbFile = path.join(configDir, "config.sqlite");
let persistedGateway = null;
if (fs.existsSync(appConfigDbFile)) {
const Database = require("better-sqlite3");
const db = new Database(appConfigDbFile, { readonly: true });
try {
const row = db.prepare("select value_json from app_config where key = ?").get("default");
if (row && row.value_json) {
const config = JSON.parse(row.value_json);
persistedGateway = config && typeof config === "object" ? config.gateway || null : null;
}
} finally {
db.close();
}
}
process.stdout.write(JSON.stringify({
configDatabase: fs.existsSync(path.join(configDir, "config.sqlite")),
gatewayConfigJson: fs.existsSync(path.join(configDir, "gateway.config.json")),
gatewayRuntimeJson: fs.existsSync(path.join(configDir, "gateway-runtime.json")),
legacyConfigJson: fs.existsSync(path.join(configDir, "config.json"))
legacyConfigJson: fs.existsSync(path.join(configDir, "config.json")),
persistedGateway
}));
`;
return JSON.parse(run("docker", ["exec", containerId, "node", "-e", script]));