mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-10-19 00:46:15 +00:00
Implement comprehensive application updates
This commit is contained in:
parent
829298cf8b
commit
4afd350eef
64 changed files with 18217 additions and 259 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -20,3 +20,4 @@ playwright-report
|
|||
blob-report
|
||||
.tmp
|
||||
.test-dist
|
||||
docker/local-ai-gateway/*.tgz
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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/)。
|
||||
|
||||
## 构建桌面应用
|
||||
|
||||
|
|
|
|||
|
|
@ -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
14
build/docker-compose.mjs
Normal 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
15
build/docker-image.mjs
Normal 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);
|
||||
108
build/docker-local-gateway.mjs
Normal file
108
build/docker-local-gateway.mjs
Normal 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}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
7
docker/local-ai-gateway/README.md
Normal file
7
docker/local-ai-gateway/README.md
Normal 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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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
6
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 : []),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
1210
packages/core/src/gateway/core-runtime/router-plugin.ts
Normal file
1210
packages/core/src/gateway/core-runtime/router-plugin.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -3,16 +3,14 @@ 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",
|
||||
|
|
@ -34,13 +32,38 @@ test("core gateway disables the full-trace billing webhook without disabling raw
|
|||
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;
|
||||
}
|
||||
});
|
||||
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
};
|
||||
|
|
|
|||
671
packages/core/test/unit/gateway/router-plugin.test.mjs
Normal file
671
packages/core/test/unit/gateway/router-plugin.test.mjs
Normal 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"
|
||||
};
|
||||
}
|
||||
107
packages/core/test/unit/gateway/single-runtime-blockers.test.mjs
Normal file
107
packages/core/test/unit/gateway/single-runtime-blockers.test.mjs
Normal 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
|
||||
};
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
try {
|
||||
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 {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
6811
packages/ui/src/generated/claude-code-config-options.json
Normal file
6811
packages/ui/src/generated/claude-code-config-options.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
5796
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
618
scripts/generate-claude-code-config-options.mjs
Normal file
618
scripts/generate-claude-code-config-options.mjs
Normal 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(/"/g, "\"")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/'/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();
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue