Document SQLite config migration and add test script

This commit is contained in:
musi 2026-06-30 09:19:53 +08:00
parent 608915c037
commit a784eba837
47 changed files with 1858 additions and 460 deletions

View file

@ -67,9 +67,11 @@ The web management UI listens on `http://127.0.0.1:3458` by default. Use `ccr st
- Windows: `Claude Code Router_<version>.exe`
- Linux: `Claude Code Router_<version>.AppImage`
3. Install and launch **Claude Code Router**.
4. On first launch, CCR creates its local configuration:
- macOS/Linux: `~/.claude-code-router/config.json`
- Windows: `%APPDATA%\Claude Code Router\config.json`
4. On first launch, CCR creates its local configuration database:
- macOS/Linux: `~/.claude-code-router/config.sqlite`
- Windows: `%APPDATA%\Claude Code Router\config.sqlite`
CCR stores runtime configuration in SQLite. A legacy `config.json` is read only once for migration when no SQLite config exists.
CCR starts two local services when the gateway is enabled:

View file

@ -67,9 +67,11 @@ Web 管理端默认监听 `http://127.0.0.1:3458`。可以用 `ccr start --host
- Windows`Claude Code Router_<version>.exe`
- Linux`Claude Code Router_<version>.AppImage`
3. 安装并启动 **Claude Code Router**
4. 首次启动后CCR 会创建本地配置:
- macOS/Linux`~/.claude-code-router/config.json`
- Windows`%APPDATA%\Claude Code Router\config.json`
4. 首次启动后CCR 会创建本地配置数据库:
- macOS/Linux`~/.claude-code-router/config.sqlite`
- Windows`%APPDATA%\Claude Code Router\config.sqlite`
CCR 的运行配置存储在 SQLite 中。旧版 `config.json` 只会在没有 SQLite 配置时作为迁移来源读取一次。
启用网关后CCR 会启动两个本地服务:

33
build/run-tests.mjs Normal file
View file

@ -0,0 +1,33 @@
import electron from "electron";
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const testHome = mkdtempSync(path.join(os.tmpdir(), "ccr-test-home-"));
const child = spawn(electron, ["--test", "dist/tests/*.js"], {
cwd: projectRoot,
env: {
...process.env,
CCR_INTERNAL_APP_DATA_DIR: path.join(testHome, "app-data"),
CCR_INTERNAL_HOME_DIR: testHome,
CCR_INTERNAL_USER_DATA_DIR: path.join(testHome, "user-data"),
HOME: testHome,
ELECTRON_RUN_AS_NODE: "1"
},
shell: process.platform === "win32",
stdio: "inherit"
});
child.on("exit", (code, signal) => {
rmSync(testHome, { force: true, recursive: true });
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});

44
build/test.mjs Normal file
View file

@ -0,0 +1,44 @@
import esbuild from "esbuild";
import { mkdirSync, readdirSync, rmSync, statSync } 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 outdir = path.join(projectRoot, "dist", "tests");
rmSync(outdir, { force: true, recursive: true });
mkdirSync(outdir, { recursive: true });
const entryPoints = findTestFiles(path.join(projectRoot, "tests"));
await esbuild.build({
absWorkingDir: projectRoot,
bundle: true,
entryNames: "[name]",
entryPoints,
external: [
"better-sqlite3",
"electron"
],
format: "cjs",
legalComments: "none",
logLevel: "info",
outdir,
platform: "node",
target: "node22"
});
function findTestFiles(dir) {
const files = [];
for (const name of readdirSync(dir)) {
const file = path.join(dir, name);
const stat = statSync(file);
if (stat.isDirectory()) {
files.push(...findTestFiles(file));
} else if (name.endsWith(".test.mjs")) {
files.push(file);
}
}
return files.sort();
}

View file

@ -2,7 +2,7 @@
title: Claude Code Router Detailed Configuration
pageTitle: Detailed Configuration
eyebrow: Detailed Configuration
lead: Configure providers, routing, Agent Config, Fusion, Bots, and the config file location in detail.
lead: Configure providers, routing, Agent Config, Fusion, Bots, and the config database location in detail.
---
## Page Structure
@ -19,7 +19,7 @@ Detailed configuration docs are split into standalone pages. Every left-sidebar
| Agent Config | Agent launch method, model, scope, multi-instance launching, and Bot binding |
| Extension Mechanism | Wrapper plugins, core gateway plugins, custom extension creation, and debugging |
| Bots And IM Agent Relay | Bot forwarding, handoff mode, and platform pages |
| Config File Location | Config file location maintained by the desktop app |
| Config Database Location | SQLite config database location maintained by the desktop app |
## Content Relationships

View file

@ -1,16 +1,17 @@
---
title: Config File Location
pageTitle: Config File Location
title: Config Database Location
pageTitle: Config Database Location
eyebrow: Detailed Configuration
lead: Locate the JSON configuration file maintained by the CCR desktop app.
lead: Locate the SQLite configuration database maintained by the CCR desktop app.
---
## Default Locations
- **macOS**: `~/Library/Application Support/claude-code-router/config.json`
- **Windows**: `%APPDATA%/claude-code-router/config.json`
- **Linux**: `~/.config/claude-code-router/config.json`
- **macOS/Linux**: `~/.claude-code-router/config.sqlite`
- **Windows**: `%APPDATA%\Claude Code Router\config.sqlite`
## Applying Changes
Restart CCR after editing the file directly.
CCR stores runtime configuration in SQLite. A legacy `config.json` is read only once as a migration source when no SQLite config exists; after migration, editing `config.json` does not affect the current configuration.
Use the desktop UI to change configuration, or export a backup from **Settings**. Do not edit `config.sqlite` directly while CCR is running; SQLite also maintains companion `config.sqlite-wal` and `config.sqlite-shm` files in the same directory.

View file

@ -177,7 +177,7 @@ The recommended flow is through the desktop UI:
4. Save the config.
5. Open **Server** and restart the gateway.
You can also edit the config file directly:
CCR stores runtime configuration in SQLite, so add extensions through the UI instead of editing the legacy JSON config file. The extension entry has this shape:
```json
{
@ -194,7 +194,7 @@ You can also edit the config file directly:
}
```
Restart CCR after editing the config file directly. See [Config File Location](/en/configuration/configuration-file/).
Restart the gateway after saving the extension config. See [Config Database Location](/en/configuration/configuration-file/).
The local directory picker recognizes entry metadata from:

View file

@ -26,7 +26,7 @@ The top navigation is split into four standalone pages:
| --- | --- |
| [Documentation](./) | Product positioning, architecture overview, and reading path |
| [Quick Start](guides/) | From installation and provider setup to connecting an agent |
| [Detailed Configuration](configuration/) | Providers, routing, Agent Config, Fusion, Bots, and config file location |
| [Detailed Configuration](configuration/) | Providers, routing, Agent Config, Fusion, Bots, and config database location |
| [Q&A](troubleshooting/) | Request logs, observability panel, and common questions |
Bot platform guides are child pages under Detailed Configuration. Each platform has its own page so platform dashboard fields, callback URLs, signatures, and FAQs can be expanded independently.

View file

@ -2,7 +2,7 @@
title: Claude Code Router 详细配置
pageTitle: 详细配置
eyebrow: 详细配置
lead: 深入配置供应商、路由、Agent配置、Fusion、Bot 和配置文件位置。这里是按功能查字段和扩展能力的地方。
lead: 深入配置供应商、路由、Agent配置、Fusion、Bot 和配置数据库位置。这里是按功能查字段和扩展能力的地方。
---
## 页面结构
@ -19,7 +19,7 @@ lead: 深入配置供应商、路由、Agent配置、Fusion、Bot 和配置文
| Agent配置 | Agent 启动方式、模型、作用范围、多开和 Bot 绑定 |
| 扩展机制 | Wrapper plugin、Core gateway plugin、自定义扩展创建和调试 |
| Bot 与 IM 接力 Agent | Bot 转发、接力模式和平台页面 |
| 配置文件位置 | 桌面 App 维护的配置文件位置 |
| 配置数据库位置 | 桌面 App 维护的 SQLite 配置数据库位置 |
## 内容关系

View file

@ -1,16 +1,17 @@
---
title: 配置文件位置
pageTitle: 配置文件位置
title: 配置数据库位置
pageTitle: 配置数据库位置
eyebrow: 详细配置
lead: 找到 CCR 桌面 App 默认维护的配置文件
lead: 找到 CCR 桌面 App 默认维护的 SQLite 配置数据库
---
## 默认位置
- macOS`~/Library/Application Support/claude-code-router/config.json`
- Windows`%APPDATA%/claude-code-router/config.json`
- Linux`~/.config/claude-code-router/config.json`
- macOS/Linux`~/.claude-code-router/config.sqlite`
- Windows`%APPDATA%\Claude Code Router\config.sqlite`
## 生效方式
直接编辑配置文件后,需要重启 CCR。
CCR 的运行配置存储在 SQLite 中。旧版 `config.json` 只会在没有 SQLite 配置时作为迁移来源读取一次,迁移完成后继续编辑 `config.json` 不会影响当前配置。
建议通过桌面 UI 修改配置,或在 **Settings** 中导出备份。不要在 CCR 运行时直接编辑 `config.sqlite`SQLite 还会维护同目录的 `config.sqlite-wal``config.sqlite-shm` 辅助文件。

View file

@ -177,7 +177,7 @@ module.exports = {
4. 保存配置。
5. 打开 **Server** 页面,重启网关。
也可以直接编辑配置文件
CCR 的运行配置存储在 SQLite 中,因此推荐通过 UI 添加扩展,而不是编辑旧版 JSON 配置文件。扩展条目的配置结构如下
```json
{
@ -194,7 +194,7 @@ module.exports = {
}
```
直接编辑配置文件后需要重启 CCR。配置文件位置见 [配置文件位置](/configuration/config-file/)。
保存扩展配置后需要重启网关。配置数据库位置见 [配置数据库位置](/configuration/config-file/)。
本地目录选择器会按顺序识别这些入口信息:

View file

@ -26,7 +26,7 @@ CCR 默认监听本机地址 `http://localhost:8080`。Agent 只要指向这个
| --- | --- |
| [文档](./) | 产品定位、架构概览、阅读路径 |
| [快速开始](guides/) | 从安装、接供应商,到接入 Agent 的上手流程 |
| [详细配置](configuration/) | 供应商、路由、配置、Fusion、Bot 和配置文件位置 |
| [详细配置](configuration/) | 供应商、路由、配置、Fusion、Bot 和配置数据库位置 |
| [Q&A](troubleshooting/) | 请求日志、观测面板和常见问题 |
Bot 平台教程是「详细配置」分类下的子页面,每个平台有独立页面,方便逐步补齐平台后台字段、回调 URL、签名和 FAQ。

View file

@ -71,7 +71,7 @@ export const docsContent = {
"Agent配置",
"扩展机制",
"Bot 与 IM 接力 Agent",
"配置文件位置",
"配置数据库位置",
],
active: "供应商配置",
},
@ -112,7 +112,7 @@ export const docsContent = {
: "/bot-与-im-接力-agent/wecom/",
: "/bot-与-im-接力-agent/feishu/",
: "/bot-与-im-接力-agent/dingtalk/",
: "/configuration/config-file/",
: "/configuration/config-file/",
},
sidebarTargets: {},
},
@ -211,7 +211,7 @@ export const docsContent = {
"Agent Config",
"Extension Mechanism",
"Bots And IM Agent Relay",
"Config File Location",
"Config Database Location",
],
active: "Provider Config",
},
@ -242,7 +242,7 @@ export const docsContent = {
WeCom: "/en/relay-agents-in-im-with-bots/wecom/",
Feishu: "/en/relay-agents-in-im-with-bots/feishu/",
DingTalk: "/en/relay-agents-in-im-with-bots/dingtalk/",
"Config File Location": "/en/configuration/configuration-file/",
"Config Database Location": "/en/configuration/configuration-file/",
},
sidebarTargets: {},
},

View file

@ -16,7 +16,7 @@ export function getStaticPaths() {
extensions: "扩展机制",
"bot-relay": "Bot 与 IM 接力 Agent",
"bot-setup": "配置步骤",
"config-file": "配置文件位置",
"config-file": "配置数据库位置",
};
return Object.entries(zhConfigurationDocs).map(([filePath, mod]) => {

View file

@ -16,7 +16,7 @@ export function getStaticPaths() {
extensions: "Extension Mechanism",
bots: "Bots And IM Agent Relay",
"bot-setup": "Setup",
"configuration-file": "Config File Location",
"configuration-file": "Config Database Location",
};
return Object.entries(enConfigurationDocs).map(([filePath, mod]) => {

View file

@ -45,6 +45,7 @@
"prepack": "npm run build:assets",
"prepublishOnly": "npm run typecheck",
"preview": "npm run build:assets && electron .",
"test": "node build/test.mjs && node build/run-tests.mjs",
"typecheck": "tsc --noEmit",
"rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3"
},

View file

@ -4,6 +4,7 @@ import { loadPersistedAppConfig, replacePersistedAppConfig } from "./app-config-
import { loadPersistedApiKeys, replacePersistedApiKeys } from "./api-key-store";
import { CONFIG_FILE, GATEWAY_CONFIG_FILE, LEGACY_CONFIG_FILE } from "./constants";
import { CLAUDE_CODE_DEFAULT_ENV, CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, OVERVIEW_WIDGET_SIZE_VALUES, ROUTER_FALLBACK_MAX_RETRY_COUNT, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS, enforceSingleEnabledGlobalProfilePerAgent } from "../shared/app";
import { createDefaultAppConfig } from "../shared/default-config";
import { findProviderPresetByBaseUrl, providerApiKeySafetyIssue, providerEndpointCanReceiveProviderApiKey } from "./presets";
import type {
AppConfig,
@ -81,15 +82,6 @@ type RawAppConfigLoadResult = {
value: Partial<AppConfig>;
};
const DEFAULT_PROXY_TARGETS: ProxyRouteTarget[] = [
{ host: "api.anthropic.com", paths: ["/v1/messages", "/v1/messages/count_tokens"] },
{ host: "api.openai.com", paths: ["/v1/chat/completions", "/v1/responses", "/v1/models"] },
{ host: "generativelanguage.googleapis.com", paths: ["/v1beta/models", "/v1/models"] },
{ host: "openrouter.ai", paths: ["/api/v1/chat/completions", "/api/v1/responses", "/api/v1/models"] },
{ host: "api.deepseek.com", paths: ["/chat/completions", "/v1/chat/completions", "/models", "/v1/models"] },
{ host: "api.mistral.ai", paths: ["/v1/chat/completions", "/v1/models"] }
];
const REMOVED_LEGACY_ROUTER_RULE_IDS = new Set([
"legacy-subagent",
"legacy-background",
@ -100,143 +92,10 @@ const REMOVED_LEGACY_ROUTER_RULE_IDS = new Set([
const INTERNAL_GATEWAY_CORE_HOST = "127.0.0.1";
const GENERATED_GATEWAY_API_KEY_ID = "local-gateway";
const DEFAULT_CONFIG: AppConfig = {
APIKEY: "",
APIKEYS: [],
API_TIMEOUT_MS: 600000,
CUSTOM_ROUTER_PATH: "",
HOST: "127.0.0.1",
PORT: 3456,
Providers: [],
Router: {
fallback: {
mode: "off",
models: [],
retryCount: 1
},
longContextThreshold: 200000,
rules: []
},
agent: {
mcpServers: []
},
autoStart: false,
botConfigs: [],
botGateway: {
acknowledgeEvents: false,
args: [],
authType: "",
autoStartIntegration: true,
command: "",
createIntegration: false,
credentials: {},
cwd: "",
enabled: false,
forwardAllAgentMessages: true,
handoff: {
enabled: false,
idleSeconds: 30,
phoneBluetoothTargets: [],
phoneWifiTargets: [],
screenLock: true,
userIdle: true
},
integrationConfig: {},
integrationId: "",
platform: "none",
pollIntervalMs: 2000,
requestTimeoutMs: 600000,
sourceDir: "",
startupTimeoutMs: 10000,
stateDir: "",
tenantId: "ccr"
},
gateway: {
coreHost: INTERNAL_GATEWAY_CORE_HOST,
corePort: 3457,
enabled: true,
generatedConfigFile: GATEWAY_CONFIG_FILE,
host: "127.0.0.1",
port: 3456
},
observability: {
agentAnalysis: false,
requestLogs: false
},
preferredProvider: "",
plugins: [],
overviewWidgets: DEFAULT_OVERVIEW_WIDGETS,
profile: {
claudeCode: {
enabled: true,
model: "",
settingsFile: "~/.claude/settings.json",
smallFastModel: ""
},
codex: {
cliMiddleware: true,
codexCliPath: "",
codexHome: "",
configFormat: "separate_profile_files",
configFile: "~/.codex/config.toml",
enabled: true,
model: "",
providerId: "claude-code-router",
providerName: "Claude Code Router",
showAllSessions: false
},
enabled: true,
profiles: [
{
agent: "claude-code",
enabled: true,
env: { ...CLAUDE_CODE_DEFAULT_ENV },
id: "default-claude-code",
model: "",
name: "Claude Code",
scope: "global",
settingsFile: "~/.claude/settings.json",
smallFastModel: "",
surface: "auto"
},
{
agent: "codex",
cliMiddleware: true,
codexCliPath: "",
codexHome: "",
configFormat: "separate_profile_files",
configFile: "~/.codex/config.toml",
enabled: true,
env: {},
id: "default-codex",
model: "",
name: "Codex",
providerId: "claude-code-router",
providerName: "Claude Code Router",
showAllSessions: false,
scope: "global",
surface: "auto"
}
]
},
proxy: {
browserMode: true,
captureNetwork: false,
enabled: false,
host: "127.0.0.1",
mode: "gateway",
port: 7890,
systemProxy: false,
targets: DEFAULT_PROXY_TARGETS
},
routerEndpoint: "http://127.0.0.1:3456",
theme: "system",
trayComponentVariants: DEFAULT_TRAY_COMPONENT_VARIANTS,
trayIcon: "random",
trayProgressTargetTokens: 100000,
trayWidgets: DEFAULT_TRAY_WIDGETS,
trayWindowModules: DEFAULT_TRAY_WINDOW_MODULES
};
const DEFAULT_CONFIG: AppConfig = createDefaultAppConfig({
coreHost: INTERNAL_GATEWAY_CORE_HOST,
generatedConfigFile: GATEWAY_CONFIG_FILE
});
function completeBotGatewayConfig(config: LoadedBotGatewayConfig | undefined): BotGatewayRuntimeConfig {
const platform = normalizeBotGatewayPlatform(config?.platform ?? DEFAULT_CONFIG.botGateway.platform);

View file

@ -24,7 +24,7 @@ import { getProfileOpenCommand, getProfileRuntimeStatus, openProfileFromCcr, sto
import { ensureProxyCertificateAuthority } from "../server/proxy/certificates";
import { proxyService } from "../server/proxy/service";
import { listMcpServerTools } from "../server/mcp/tool-discovery";
import { getAgentAnalysis, getAgentTracePayload, getRequestLogs } from "./request-log-store";
import { getAgentAnalysis, getAgentTracePayload, getRequestLogDetail, getRequestLogs } from "./request-log-store";
import trayController from "./tray-controller";
import { appUpdateService } from "./update-service";
import { getUsageStats } from "./usage-store";
@ -94,6 +94,7 @@ ipcMain.handle(IPC_CHANNELS.appGetProxyCertificateStatus, () => proxyService.get
ipcMain.handle(IPC_CHANNELS.appGetProxyNetworkCaptures, () => proxyService.getNetworkCaptures());
ipcMain.handle(IPC_CHANNELS.appGetProxyStatus, () => proxyService.getStatus());
ipcMain.handle(IPC_CHANNELS.appGetPluginMarketplace, () => pluginMarketplace);
ipcMain.handle(IPC_CHANNELS.appGetRequestLogDetail, (_event, request) => getRequestLogDetail(request));
ipcMain.handle(IPC_CHANNELS.appGetRequestLogs, (_event, filter?: RequestLogListFilter) => getRequestLogs(filter));
ipcMain.handle(IPC_CHANNELS.appGetUpdateStatus, () => appUpdateService.getStatus());
ipcMain.handle(IPC_CHANNELS.appGetUsageStats, (_event, range?: UsageStatsRange, filter?: UsageStatsFilter) => getUsageStats(range, filter));

View file

@ -51,7 +51,10 @@ function startPrimaryInstance(): void {
void startConfiguredServices("startup");
app.on("activate", () => {
windowsManager.showMainWindow();
const mainWindow = windowsManager.getWindow("main");
if (!trayController.consumeMainWindowActivationSuppression() && (!mainWindow || !mainWindow.isVisible())) {
windowsManager.showMainWindow();
}
queueEnsureConfiguredProxyModeActive("activate");
});
});

View file

@ -0,0 +1,45 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve as pathResolve } from "node:path";
export type LoadedModelCatalogPayload = {
loadedFrom: string;
payload: unknown;
};
export function loadModelCatalogPayload(): LoadedModelCatalogPayload | undefined {
for (const candidate of modelCatalogPathCandidates()) {
if (!existsSync(candidate)) {
continue;
}
return {
loadedFrom: candidate,
payload: JSON.parse(readFileSync(candidate, "utf8")) as unknown
};
}
return undefined;
}
export function modelCatalogPathCandidates(): string[] {
return uniqueStrings([
process.env.CCR_MODEL_CATALOG_PATH?.trim() || "",
process.env.CCR_MODELS_JSON_PATH?.trim() || "",
pathResolve(process.cwd(), "models.json"),
pathResolve(__dirname, "..", "models.json"),
pathResolve(__dirname, "..", "assets", "models.json"),
pathResolve(__dirname, "..", "..", "..", "models.json")
]);
}
function uniqueStrings(values: Array<string | undefined>): string[] {
const seen = new Set<string>();
const strings: string[] = [];
for (const value of values) {
const trimmed = value?.trim();
if (!trimmed || seen.has(trimmed)) {
continue;
}
seen.add(trimmed);
strings.push(trimmed);
}
return strings;
}

View file

@ -58,6 +58,8 @@ import type {
ProxyCertificateStatus,
ProxyNetworkSnapshot,
ProxyStatus,
RequestLogDetailRequest,
RequestLogEntry,
RequestLogListFilter,
RequestLogPage,
UsageStatsFilter,
@ -108,6 +110,7 @@ contextBridge.exposeInMainWorld("ccr", {
getProxyCertificateStatus: () => invoke(IPC_CHANNELS.appGetProxyCertificateStatus) as Promise<ProxyCertificateStatus>,
getProxyNetworkCaptures: () => invoke(IPC_CHANNELS.appGetProxyNetworkCaptures) as Promise<ProxyNetworkSnapshot>,
getProxyStatus: () => invoke(IPC_CHANNELS.appGetProxyStatus) as Promise<ProxyStatus>,
getRequestLogDetail: (request: RequestLogDetailRequest) => invoke(IPC_CHANNELS.appGetRequestLogDetail, request) as Promise<RequestLogEntry | undefined>,
getRequestLogs: (filter?: RequestLogListFilter) => invoke(IPC_CHANNELS.appGetRequestLogs, filter) as Promise<RequestLogPage>,
getUpdateStatus: () => invoke(IPC_CHANNELS.appGetUpdateStatus) as Promise<AppUpdateStatus>,
getUsageStats: (range?: UsageStatsRange, filter?: UsageStatsFilter) => invoke(IPC_CHANNELS.appGetUsageStats, range, filter) as Promise<UsageStatsSnapshot>,

View file

@ -1,7 +1,6 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve as pathResolve } from "node:path";
import type { ProviderCatalogModelsRequest, ProviderCatalogModelsResult } from "../shared/app";
import { providerUrlWithDefaultScheme } from "../shared/provider-url";
import { loadModelCatalogPayload } from "./model-catalog-file";
import { findProviderPreset, findProviderPresetByBaseUrl } from "./presets";
type CatalogProviderEntry = {
@ -121,17 +120,14 @@ function loadCatalogIndex(): CatalogIndex {
return catalogIndex;
}
for (const candidate of catalogPathCandidates()) {
if (!existsSync(candidate)) {
continue;
}
try {
const payload = JSON.parse(readFileSync(candidate, "utf8")) as unknown;
catalogIndex = buildCatalogIndex(payload, candidate);
try {
const loaded = loadModelCatalogPayload();
if (loaded) {
catalogIndex = buildCatalogIndex(loaded.payload, loaded.loadedFrom);
return catalogIndex;
} catch (error) {
console.warn(`Failed to load provider model catalog from ${candidate}:`, error);
}
} catch (error) {
console.warn("Failed to load provider model catalog:", error);
}
catalogIndex = {
@ -140,17 +136,6 @@ function loadCatalogIndex(): CatalogIndex {
return catalogIndex;
}
function catalogPathCandidates(): string[] {
return uniqueStrings([
process.env.CCR_MODEL_CATALOG_PATH?.trim() || "",
process.env.CCR_MODELS_JSON_PATH?.trim() || "",
pathResolve(process.cwd(), "models.json"),
pathResolve(__dirname, "..", "models.json"),
pathResolve(__dirname, "..", "assets", "models.json"),
pathResolve(__dirname, "..", "..", "..", "models.json")
]);
}
function buildCatalogIndex(payload: unknown, loadedFrom: string): CatalogIndex {
const providers = new Map<string, MutableCatalogProviderEntry>();
const models = isRecord(payload) && Array.isArray(payload.models) ? payload.models : [];

View file

@ -220,8 +220,10 @@ async function resolveGatewayProviderProbe(request: GatewayProviderProbeRequest)
const modelProbe = mode !== "models" || request.skipModelDiscovery
? { models: [] }
: await probeModels(parsed, request.apiKey, protocols);
const models = mode === "connectivity" && modelProbe.models.length > 0 ? modelProbe.models : typedModels;
const protocolResults = mode === "models" ? [] : await probeProtocols(parsed, request.apiKey, models, protocols, mode);
const models = (mode === "connectivity" || mode === "models") && modelProbe.models.length > 0
? modelProbe.models
: typedModels;
const protocolResults = await probeProtocols(parsed, request.apiKey, models, protocols, mode);
const detectedProtocol = detectProtocol(parsed, protocolResults, modelProbe.source, protocols);
return {
@ -466,7 +468,7 @@ async function probeProtocols(
results.push(
mode === "connectivity"
? await probeProtocolConnectivity(parsed, apiKey, models, protocol)
: await probeProtocolSupport(parsed, protocol)
: await probeProtocolSupport(parsed, apiKey, protocol)
);
}
@ -475,6 +477,7 @@ async function probeProtocols(
async function probeProtocolSupport(
parsed: ParsedProviderUrl,
apiKey: string | undefined,
protocol: GatewayProviderProtocol
): Promise<GatewayProviderProbeProtocolResult> {
const endpoints = endpointsForProtocol(parsed, protocol, undefined);
@ -482,7 +485,7 @@ async function probeProtocolSupport(
let firstResult: GatewayProviderProbeProtocolResult | undefined;
for (const candidate of endpoints) {
const result = await requestJson(candidate.endpoint, requestForProtocolSupport(protocol));
const result = await requestJson(candidate.endpoint, requestForProtocolSupport(protocol, apiKey));
const message = readResponseMessage(result);
const supported = isProtocolEndpointSupported(result.status, message);
const probeResult = {
@ -620,12 +623,12 @@ function requestForProtocol(protocol: GatewayProviderProtocol, model: string, ap
};
}
function requestForProtocolSupport(protocol: GatewayProviderProtocol): RequestInit {
function requestForProtocolSupport(protocol: GatewayProviderProtocol, apiKey: string | undefined): RequestInit {
return {
body: JSON.stringify({}),
headers: {
"content-type": "application/json",
...(protocol === "anthropic_messages" ? { "anthropic-version": "2023-06-01" } : {})
...headersForProtocol(protocol, apiKey)
},
method: "POST"
};
@ -686,10 +689,16 @@ function endpointsForProtocol(
}
if (protocol === "anthropic_messages") {
return parsed.anthropicBaseUrlCandidates.map((baseUrl) => ({
baseUrl,
endpoint: `${baseUrl}/v1/messages`
}));
return parsed.anthropicBaseUrlCandidates.flatMap((baseUrl) => uniqueProtocolEndpoints([
{
baseUrl,
endpoint: `${baseUrl}/v1/messages`
},
{
baseUrl,
endpoint: `${baseUrl}/messages`
}
]));
}
const encodedModel = encodeURIComponent(stripGeminiModelPrefix(model || "model"));
@ -734,6 +743,16 @@ function geminiHeaders(apiKey: string | undefined): Record<string, string> {
: {};
}
function headersForProtocol(protocol: GatewayProviderProtocol, apiKey: string | undefined): Record<string, string> {
if (protocol === "anthropic_messages") {
return anthropicHeaders(apiKey);
}
if (protocol === "gemini_generate_content") {
return geminiHeaders(apiKey);
}
return openAiHeaders(apiKey);
}
function parseModelList(payload: unknown, source: ModelSource): Pick<ModelFetchResult, "modelDisplayNames" | "models"> {
if (!isRecord(payload)) {
return {
@ -1067,6 +1086,20 @@ function uniqueProtocols(values: GatewayProviderProtocol[]): GatewayProviderProt
return values.filter((value, index) => values.indexOf(value) === index);
}
function uniqueProtocolEndpoints(values: ProtocolEndpoint[]): ProtocolEndpoint[] {
const seen = new Set<string>();
const result: ProtocolEndpoint[] = [];
for (const value of values) {
const key = `${value.baseUrl}\n${value.endpoint}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
result.push(value);
}
return result;
}
function uniqueModelSources(values: ModelSource[]): ModelSource[] {
return values.filter((value, index) => values.indexOf(value) === index);
}

View file

@ -30,6 +30,8 @@ import type {
AgentKind,
GatewayProviderProtocol,
RequestLogBody,
RequestLogDetailRequest,
RequestLogEntry,
RequestLogFilterOptions,
RequestLogListFilter,
RequestLogPage,
@ -195,6 +197,10 @@ const maxBodyBytes = 2 * 1024 * 1024;
const maxAgentAnalysisRows = 5000;
const maxAgentSessionDetailRequests = 250;
const maxTracePayloadPreviewChars = 1600;
const requestLogBodyMetadataSelect = `
'' AS request_body_text,
'' AS response_body_text
`;
const emptyAgentAnalysisTotals: AgentAnalysisTotals = {
avgDurationMs: 0,
cacheRatio: 0,
@ -227,10 +233,18 @@ const sensitiveHeaderNames = new Set([
"x-auth-sub"
]);
class RequestLogStore {
type AgentAnalysisCacheEntry = {
filterKey: string;
revision: number;
snapshot: AgentAnalysisSnapshot;
};
export class RequestLogStore {
private database?: SqlDatabase;
private initPromise?: Promise<SqlDatabase>;
private lastRetentionCleanupDay?: string;
private revision = 0;
private analysisCache?: AgentAnalysisCacheEntry;
constructor(private readonly dbFile: string) {}
@ -371,6 +385,7 @@ class RequestLogStore {
responseBody.truncated ? 1 : 0,
responseError ?? ""
);
this.revision += 1;
}
async updateFromRawTrace(input: RequestLogRawTraceUpdateInput): Promise<boolean> {
@ -520,6 +535,7 @@ class RequestLogStore {
}
database.prepare(`UPDATE request_logs SET ${sets.join(", ")} WHERE request_id = ?`).run(...params, requestId);
this.revision += 1;
return true;
}
@ -563,12 +579,11 @@ class RequestLogStore {
cost_usd,
request_headers,
response_headers,
request_body_text,
${requestLogBodyMetadataSelect},
request_body_encoding,
request_body_content_type,
request_body_size_bytes,
request_body_truncated,
response_body_text,
response_body_encoding,
response_body_content_type,
response_body_size_bytes,
@ -593,10 +608,26 @@ class RequestLogStore {
};
}
async getDetail(request: RequestLogDetailRequest): Promise<RequestLogEntry | undefined> {
const database = await this.getDatabase();
const requestLogId = normalizeCount(request.id);
if (requestLogId <= 0) {
return undefined;
}
return readRequestLogById(database, requestLogId);
}
async analyze(filter: AgentAnalysisFilter = {}): Promise<AgentAnalysisSnapshot> {
const database = await this.getDatabase();
this.pruneOldRequestLogs(database);
const now = new Date();
const filterKey = agentAnalysisCacheKey(filter);
if (this.analysisCache?.revision === this.revision && this.analysisCache.filterKey === filterKey) {
return {
...this.analysisCache.snapshot,
generatedAt: now.toISOString()
};
}
const range = normalizeAgentAnalysisRange(filter.range);
const since = getAgentAnalysisSince(range, now);
const rows = queryRows(
@ -665,7 +696,7 @@ class RequestLogStore {
? buildAgentSessionDetail(analysisRequests)
: undefined;
return {
const snapshot: AgentAnalysisSnapshot = {
agents: buildAgentRows(analysisRequests),
clients: buildAgentClientRows(analysisRequests),
concurrency: buildAgentConcurrencySeries(range, now, analysisRequests),
@ -682,6 +713,12 @@ class RequestLogStore {
tools: buildAgentToolRows(analysisRequests),
totals: buildAgentAnalysisTotals(analysisRequests)
};
this.analysisCache = {
filterKey,
revision: this.revision,
snapshot
};
return snapshot;
}
async getTracePayload(request: AgentAnalysisTracePayloadRequest): Promise<AgentAnalysisTracePayloadFullResult> {
@ -840,6 +877,15 @@ export async function getRequestLogs(filter?: RequestLogListFilter): Promise<Req
}
}
export async function getRequestLogDetail(request: RequestLogDetailRequest): Promise<RequestLogEntry | undefined> {
try {
return await requestLogStore.getDetail(request);
} catch (error) {
console.warn(`[request-log] Failed to read request log detail: ${formatError(error)}`);
throw error;
}
}
export async function getAgentAnalysis(filter?: AgentAnalysisFilter): Promise<AgentAnalysisSnapshot> {
try {
return await requestLogStore.analyze(filter);
@ -902,6 +948,15 @@ function toAnalyzedAgentRequest(entry: StoredRequestLogEntry): AnalyzedAgentRequ
};
}
function agentAnalysisCacheKey(filter: AgentAnalysisFilter): string {
return JSON.stringify({
agent: normalizeAgentFilter(filter.agent),
range: normalizeAgentAnalysisRange(filter.range),
sessionAgent: normalizeAgentFilter(filter.sessionAgent),
sessionId: normalizeFilterValue(filter.sessionId)
});
}
function extractAgentLogDetails(entry: StoredRequestLogEntry): AgentLogDetails {
const requestPayloads = parseLogBodyPayloads(entry.requestBody);
const responsePayloads = parseLogBodyPayloads(entry.responseBody);
@ -2632,6 +2687,11 @@ function ensureRequestLogSchema(database: SqlDatabase): void {
database.exec("CREATE INDEX IF NOT EXISTS request_logs_provider_idx ON request_logs(provider)");
database.exec("CREATE INDEX IF NOT EXISTS request_logs_source_usage_id_idx ON request_logs(source_usage_id)");
database.exec("CREATE INDEX IF NOT EXISTS request_logs_status_idx ON request_logs(ok, status_code)");
database.exec("CREATE INDEX IF NOT EXISTS request_logs_list_idx ON request_logs(source_usage_id, created_at DESC, id DESC)");
database.exec("CREATE INDEX IF NOT EXISTS request_logs_credential_created_at_idx ON request_logs(credential_id, created_at DESC)");
database.exec("CREATE INDEX IF NOT EXISTS request_logs_model_created_at_idx ON request_logs(model, created_at DESC)");
database.exec("CREATE INDEX IF NOT EXISTS request_logs_provider_created_at_idx ON request_logs(provider, created_at DESC)");
database.exec("CREATE INDEX IF NOT EXISTS request_logs_status_created_at_idx ON request_logs(ok, created_at DESC)");
}
function backfillRequestLogStreamFlags(database: SqlDatabase): void {

View file

@ -14,6 +14,7 @@ const popoverDetailGap = 12;
const popoverDetailTopOffset = 0;
const popoverDetailWidth = 420;
const popoverMargin = 8;
const trayActivationSuppressMs = 750;
const trayMenuBarIconSize = 20;
const trayWindowBackgroundColor = "#020617";
const trayTokenFallbackTitle = "0 tokens";
@ -38,6 +39,7 @@ class TrayController {
private randomTrayIconDateKey?: string;
private resolvedRandomTrayIcon?: TrayMascotIconId;
private refreshTimer?: NodeJS.Timeout;
private suppressMainWindowActivationUntil = 0;
private tray?: Tray;
private trayBalanceProgress?: TrayBalanceProgressConfig;
private trayIconPreference: TrayIconPreference = "random";
@ -52,8 +54,14 @@ class TrayController {
const icon = createTrayIcon(this.resolveTrayIconId("random"));
this.tray = new Tray(icon.isEmpty() ? nativeImage.createEmpty() : icon);
this.applyTrayTitle(trayTokenFallbackTitle);
this.tray.on("click", () => this.togglePopover());
this.tray.on("right-click", () => this.showContextMenu());
this.tray.on("click", () => {
this.suppressMainWindowActivation();
this.togglePopover();
});
this.tray.on("right-click", () => {
this.suppressMainWindowActivation();
this.showContextMenu();
});
void this.refreshIconFromConfig();
this.unsubscribeUsageUpdates = onUsageRecorded(() => {
@ -102,6 +110,14 @@ class TrayController {
void this.refreshTrayTitle();
}
consumeMainWindowActivationSuppression(): boolean {
if (process.platform !== "darwin" || Date.now() > this.suppressMainWindowActivationUntil) {
return false;
}
this.suppressMainWindowActivationUntil = 0;
return true;
}
async refreshIconFromConfig(config?: AppConfig): Promise<void> {
if (!supportsTrayPlatform() || !this.tray) {
return;
@ -140,6 +156,12 @@ class TrayController {
this.showPopover();
}
private suppressMainWindowActivation(): void {
if (process.platform === "darwin") {
this.suppressMainWindowActivationUntil = Date.now() + trayActivationSuppressMs;
}
}
private showPopover(): void {
const popover = this.ensurePopover();
this.clearDetailCloseTimer();

View file

@ -59,6 +59,11 @@ type UsageStatsQueryOptions = {
includeProxy?: boolean;
};
type UsageWhereClause = {
params: SqlValue[];
where: string;
};
type StoredUsageEvent = {
cacheReadTokens: number;
cacheWriteTokens: number;
@ -98,7 +103,7 @@ const emptyTotals: UsageTotals = {
totalTokens: 0
};
class UsageStore {
export class UsageStore {
private database?: SqlDatabase;
private initPromise?: Promise<SqlDatabase>;
@ -174,27 +179,23 @@ class UsageStore {
const database = await this.getDatabase();
const now = new Date();
const since = getRangeSince(range, now);
const query = buildUsageStatsQuery(since, filter);
const events = queryRows(database, query.sql, query.params).map(toStoredUsageEvent);
const query = buildUsageWhereClause(since, filter);
return {
clientModels: buildClientModelRows(events),
clientModels: readClientModelRows(database, query),
generatedAt: now.toISOString(),
models: buildModelRows(events),
providerModels: buildProviderModelRows(events),
models: readModelRows(database, query),
providerModels: readProviderModelRows(database, query),
range,
recentRequests: buildRecentRequestRows(events),
series: buildSeries(range, now, events),
totals: buildTotals(events)
recentRequests: readRecentRequestRows(database, query),
series: readUsageSeries(database, range, now, query),
totals: readUsageTotals(database, query)
};
}
async getTotalsSince(since: Date, filter: UsageStatsFilter = {}, options: UsageStatsQueryOptions = {}): Promise<UsageTotals> {
const database = await this.getDatabase();
const query = buildUsageStatsQuery(since, filter, options);
const events = queryRows(database, query.sql, query.params).map(toStoredUsageEvent);
return buildTotals(events);
return readUsageTotals(database, buildUsageWhereClause(since, filter, options));
}
private async getDatabase(): Promise<SqlDatabase> {
@ -277,6 +278,10 @@ function ensureUsageSchema(database: SqlDatabase): void {
database.exec("CREATE INDEX IF NOT EXISTS usage_events_credential_id_idx ON usage_events(credential_id)");
database.exec("CREATE INDEX IF NOT EXISTS usage_events_model_idx ON usage_events(model)");
database.exec("CREATE INDEX IF NOT EXISTS usage_events_path_idx ON usage_events(path)");
database.exec("CREATE INDEX IF NOT EXISTS usage_events_created_filter_idx ON usage_events(created_at, provider, model, credential_id)");
database.exec("CREATE INDEX IF NOT EXISTS usage_events_provider_created_at_idx ON usage_events(provider, created_at)");
database.exec("CREATE INDEX IF NOT EXISTS usage_events_model_created_at_idx ON usage_events(model, created_at)");
database.exec("CREATE INDEX IF NOT EXISTS usage_events_credential_created_at_idx ON usage_events(credential_id, created_at)");
}
export async function getUsageStats(range?: UsageStatsRange, filter?: UsageStatsFilter): Promise<UsageStatsSnapshot> {
@ -339,11 +344,11 @@ export async function recordGatewayUsageCapture(input: UsageCaptureInput): Promi
}
}
function buildUsageStatsQuery(
function buildUsageWhereClause(
since: Date,
filter: UsageStatsFilter,
options: UsageStatsQueryOptions = {}
): { params: SqlValue[]; sql: string } {
): UsageWhereClause {
const where = ["created_at >= ?"];
const params: SqlValue[] = [since.toISOString()];
const credential = normalizeFilterValue(filter.credential);
@ -368,30 +373,7 @@ function buildUsageStatsQuery(
return {
params,
sql: `
SELECT
id,
created_at,
request_id,
client,
method,
path,
model,
provider,
credential_id,
status_code,
duration_ms,
input_tokens,
output_tokens,
cache_read_tokens,
cache_write_tokens,
total_tokens,
cost_usd,
cost_source
FROM usage_events
WHERE ${where.join(" AND ")}
ORDER BY created_at ASC
`
where: where.join(" AND ")
};
}
@ -428,6 +410,220 @@ function toStoredUsageEvent(row: Record<string, SqlValue>): StoredUsageEvent {
};
}
const usageTotalsSelect = `
COUNT(*) AS request_count,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens,
COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens,
COALESCE(SUM(cache_write_tokens), 0) AS cache_write_tokens,
COALESCE(SUM(CASE
WHEN total_tokens > 0 THEN total_tokens
ELSE input_tokens + output_tokens + cache_read_tokens + cache_write_tokens
END), 0) AS computed_total_tokens,
COALESCE(SUM(COALESCE(cost_usd, 0)), 0) AS cost_usd,
COALESCE(SUM(duration_ms), 0) AS duration_ms,
COALESCE(SUM(CASE WHEN status_code >= 200 AND status_code < 400 THEN 1 ELSE 0 END), 0) AS success_count,
COALESCE(SUM(CASE
WHEN total_tokens - output_tokens > 0 THEN MAX(input_tokens, total_tokens - output_tokens)
ELSE input_tokens + cache_read_tokens + cache_write_tokens
END), 0) AS prompt_tokens
`;
function readUsageTotals(database: SqlDatabase, query: UsageWhereClause): UsageTotals {
const row = queryRows(
database,
`
SELECT
${usageTotalsSelect}
FROM usage_events
WHERE ${query.where}
`,
query.params
)[0];
return usageTotalsFromRow(row);
}
function readUsageSeries(
database: SqlDatabase,
range: UsageStatsRange,
now: Date,
query: UsageWhereClause
): UsageSeriesPoint[] {
const unit: "day" | "hour" = range === "today" || range === "24h" ? "hour" : "day";
const bucketExpression = unit === "hour"
? "strftime('%Y-%m-%d %H:00', created_at, 'localtime')"
: "strftime('%Y-%m-%d', created_at, 'localtime')";
const rows = queryRows(
database,
`
SELECT
${bucketExpression} AS bucket,
${usageTotalsSelect}
FROM usage_events
WHERE ${query.where}
GROUP BY bucket
`,
query.params
);
const totalsByBucket = new Map(rows.map((row) => [String(row.bucket ?? ""), usageTotalsFromRow(row)]));
return buildBuckets(range, now).map(({ key, label }) => ({
...(totalsByBucket.get(key) ?? { ...emptyTotals }),
bucket: key,
label
}));
}
function readModelRows(database: SqlDatabase, query: UsageWhereClause): UsageComparisonRow[] {
const rows = readUsageGroupRows(
database,
query,
"provider, model",
"provider, model, MAX(credential_id) AS credential_id",
8
).map((row) => ({
...usageTotalsFromRow(row),
caption: normalizeLabel(String(row.provider ?? ""), "unknown"),
credentialId: normalizeFilterValue(String(row.credential_id ?? "")),
key: `${normalizeLabel(String(row.provider ?? ""), "unknown")}::${normalizeLabel(String(row.model ?? ""), "unknown")}`,
label: normalizeLabel(String(row.model ?? ""), "unknown"),
maxShare: 0,
model: normalizeLabel(String(row.model ?? ""), "unknown"),
provider: normalizeLabel(String(row.provider ?? ""), "unknown")
}));
return applyMaxShare(rows, (row) => row.totalTokens || row.requestCount);
}
function readClientModelRows(database: SqlDatabase, query: UsageWhereClause): UsageComparisonRow[] {
const rows = readUsageGroupRows(
database,
query,
"client, provider, credential_id, model",
"client, provider, credential_id, model",
25
).map((row) => {
const client = normalizeLabel(String(row.client ?? ""), "unknown");
const model = normalizeLabel(String(row.model ?? ""), "unknown");
const provider = normalizeLabel(String(row.provider ?? ""), "unknown");
const credentialId = normalizeFilterValue(String(row.credential_id ?? "")) ?? "";
return {
...usageTotalsFromRow(row),
caption: credentialId ? `${provider} / ${credentialId} / ${model}` : `${provider} / ${model}`,
client,
credentialId: credentialId || undefined,
key: `${client}::${provider}::${credentialId}::${model}`,
label: client,
maxShare: 0,
model,
provider
};
});
return applyMaxShare(rows, (row) => row.totalTokens || row.requestCount);
}
function readProviderModelRows(database: SqlDatabase, query: UsageWhereClause): UsageComparisonRow[] {
const rows = readUsageGroupRows(
database,
query,
"provider, credential_id, model",
"provider, credential_id, model",
25
).map((row) => {
const model = normalizeLabel(String(row.model ?? ""), "unknown");
const provider = normalizeLabel(String(row.provider ?? ""), "unknown");
const credentialId = normalizeFilterValue(String(row.credential_id ?? "")) ?? "";
return {
...usageTotalsFromRow(row),
caption: credentialId ? `${credentialId} / ${model}` : model,
credentialId: credentialId || undefined,
key: `${provider}::${credentialId}::${model}`,
label: provider,
maxShare: 0,
model,
provider
};
});
return applyMaxShare(rows, (row) => row.totalTokens || row.requestCount);
}
function readUsageGroupRows(
database: SqlDatabase,
query: UsageWhereClause,
groupBy: string,
selectColumns: string,
limit: number
): Record<string, SqlValue>[] {
return queryRows(
database,
`
SELECT
${selectColumns},
${usageTotalsSelect}
FROM usage_events
WHERE ${query.where}
GROUP BY ${groupBy}
ORDER BY computed_total_tokens DESC, request_count DESC
LIMIT ?
`,
[...query.params, limit]
);
}
function readRecentRequestRows(database: SqlDatabase, query: UsageWhereClause): UsageComparisonRow[] {
const events = queryRows(
database,
`
SELECT
id,
created_at,
request_id,
client,
method,
path,
model,
provider,
credential_id,
status_code,
duration_ms,
input_tokens,
output_tokens,
cache_read_tokens,
cache_write_tokens,
total_tokens,
cost_usd,
cost_source
FROM usage_events
WHERE ${query.where}
ORDER BY created_at DESC, id DESC
LIMIT 10
`,
query.params
).map(toStoredUsageEvent).reverse();
return buildRecentRequestRows(events);
}
function usageTotalsFromRow(row: Record<string, SqlValue> | undefined): UsageTotals {
const requestCount = normalizeCount(row?.request_count);
if (requestCount === 0) {
return { ...emptyTotals };
}
const successfulRequests = normalizeCount(row?.success_count);
const promptTokens = normalizeCount(row?.prompt_tokens);
const cacheTokens = normalizeCount(row?.cache_read_tokens);
return {
avgDurationMs: Math.round(normalizeCount(row?.duration_ms) / requestCount),
cacheRatio: ratio(cacheTokens, promptTokens),
cacheTokens,
costUsd: normalizeCost(row?.cost_usd),
errorCount: requestCount - successfulRequests,
inputTokens: normalizeCount(row?.input_tokens),
outputTokens: normalizeCount(row?.output_tokens),
requestCount,
successRate: successfulRequests / requestCount,
totalTokens: normalizeCount(row?.computed_total_tokens)
};
}
function buildSeries(range: UsageStatsRange, now: Date, events: StoredUsageEvent[]): UsageSeriesPoint[] {
const buckets = buildBuckets(range, now);
const grouped = new Map<string, StoredUsageEvent[]>();

View file

@ -112,6 +112,7 @@ window.ccr = {
getProxyCertificateStatus: () => rpc("getProxyCertificateStatus") as ReturnType<NonNullable<typeof window.ccr>["getProxyCertificateStatus"]>,
getProxyNetworkCaptures: () => rpc("getProxyNetworkCaptures") as ReturnType<NonNullable<typeof window.ccr>["getProxyNetworkCaptures"]>,
getProxyStatus: () => rpc("getProxyStatus") as ReturnType<NonNullable<typeof window.ccr>["getProxyStatus"]>,
getRequestLogDetail: (request) => rpc("getRequestLogDetail", [request]) as ReturnType<NonNullable<typeof window.ccr>["getRequestLogDetail"]>,
getRequestLogs: (filter) => rpc("getRequestLogs", [filter]) as ReturnType<NonNullable<typeof window.ccr>["getRequestLogs"]>,
getUpdateStatus: () => rpc("getUpdateStatus") as ReturnType<NonNullable<typeof window.ccr>["getUpdateStatus"]>,
getUsageStats: (range, filter) => rpc("getUsageStats", [range, filter]) as ReturnType<NonNullable<typeof window.ccr>["getUsageStats"]>,

View file

@ -24,7 +24,7 @@ import { getProfileOpenCommand, getProfileRuntimeStatus, openProfileFromCcr, sto
import { ensureProxyCertificateAuthority } from "../server/proxy/certificates";
import { proxyService } from "../server/proxy/service";
import { listMcpServerTools } from "../server/mcp/tool-discovery";
import { getAgentAnalysis, getAgentTracePayload, getRequestLogs } from "./request-log-store";
import { getAgentAnalysis, getAgentTracePayload, getRequestLogDetail, getRequestLogs } from "./request-log-store";
import { getUsageStats } from "./usage-store";
import { gatewayService } from "../server/gateway/service";
import { getProviderAccountSnapshots, invalidateProviderAccountSnapshotCache, testProviderAccountConnector } from "./provider-account-service";
@ -57,6 +57,7 @@ import type {
ProviderCatalogModelsRequest,
ProviderIconDetectionRequest,
ProviderManifestFetchRequest,
RequestLogDetailRequest,
RequestLogListFilter,
UsageStatsFilter,
UsageStatsRange
@ -285,6 +286,7 @@ const rpcHandlers: Record<string, RpcHandler> = {
getProxyCertificateStatus: () => proxyService.getCertificateStatus(),
getProxyNetworkCaptures: () => proxyService.getNetworkCaptures(),
getProxyStatus: () => proxyService.getStatus(),
getRequestLogDetail: (request) => getRequestLogDetail(request as RequestLogDetailRequest),
getRequestLogs: (filter) => getRequestLogs(filter as RequestLogListFilter | undefined),
getUpdateStatus: () => unsupportedUpdateStatus,
getUsageStats: (range, filter) => getUsageStats(range as UsageStatsRange | undefined, filter as UsageStatsFilter | undefined),

View file

@ -11,7 +11,7 @@ import {
enforceSingleEnabledGlobalProfilePerAgent,
ExtensionConfigTarget, ExtensionDeleteTarget, ExtensionInstallDraft, ExtensionSource, fallbackAgentAnalysis, fallbackConfig,
fallbackGatewayStatus, fallbackInfo, fallbackProxyCertificateStatus, fallbackProxyNetworkSnapshot, fallbackProxyStatus, fallbackRequestLogPage,
fallbackUpdateStatus, fallbackUsageStats, formatAppError, formatJson, formatProxyCertificateInstallMessage, GatewayProviderConfig,
fallbackUpdateStatus, fallbackUsageStats, formatAppError, formatProxyCertificateInstallMessage, GatewayProviderConfig,
fusionCustomMcpServerFromDraft, fusionCustomToolConfigFromProfile,
GatewayProviderProbeResult, gatewayServiceMessage, GatewayStatus, getDefaultOnboardingStep, isClaudeDesignPluginConfig, isClaudeDesignRoutingDraftValid,
isCursorProxyPluginConfig, isMacPlatform, isPlainRecord, isProfileDraftSubmittable, isProviderNameDuplicate, isProviderProbeCandidateReady,
@ -34,6 +34,7 @@ import {
useMemo, useReducedMotion, useRef, useState, validateVirtualModelDraft, ViewId,
VirtualModelDraft, virtualModelProfileFromDraft
} from "./shared";
import { startVisiblePolling } from "./shared/polling";
import {
AppDialogStack, LightToast, MainLayout, OnboardingLayout
} from "./components";
@ -336,10 +337,9 @@ function App() {
void window.ccr?.getProxyStatus().then(setProxyStatus);
void refreshProfileRuntimeStatus();
};
refreshRuntimeStatus();
const timer = window.setInterval(refreshRuntimeStatus, 2000);
const stopPolling = startVisiblePolling(refreshRuntimeStatus, 2000);
return () => {
window.clearInterval(timer);
stopPolling();
unsubscribeOpenSettings();
unsubscribeOpenUpdate();
};
@ -435,11 +435,10 @@ function App() {
}
});
};
refreshUsageStats();
const timer = window.setInterval(refreshUsageStats, 5000);
const stopPolling = startVisiblePolling(refreshUsageStats, 5000);
return () => {
cancelled = true;
window.clearInterval(timer);
stopPolling();
};
}, [usageRange]);
@ -463,11 +462,10 @@ function App() {
}
});
};
refreshProviderAccounts();
const timer = window.setInterval(refreshProviderAccounts, 30000);
const stopPolling = startVisiblePolling(refreshProviderAccounts, 30000);
return () => {
cancelled = true;
window.clearInterval(timer);
stopPolling();
};
}, [draftConfig.Providers]);
@ -538,11 +536,11 @@ function App() {
});
};
const stopPolling = startVisiblePolling(() => refreshAgentAnalysis(), 5000, { immediate: false });
refreshAgentAnalysis(true);
const timer = window.setInterval(() => refreshAgentAnalysis(), 5000);
return () => {
cancelled = true;
window.clearInterval(timer);
stopPolling();
};
}, [activeView, agentAnalysisEnabled, agentAnalysisFilterKey]);
@ -587,11 +585,11 @@ function App() {
});
};
const stopPolling = startVisiblePolling(() => refreshRequestLogs(), 5000, { immediate: false });
refreshRequestLogs(true);
const timer = window.setInterval(() => refreshRequestLogs(), 5000);
return () => {
cancelled = true;
window.clearInterval(timer);
stopPolling();
};
}, [activeView, requestLogsEnabled, requestLogFilterKey]);
@ -612,15 +610,14 @@ function App() {
}
});
};
refreshNetworkCaptures();
const timer = window.setInterval(refreshNetworkCaptures, 1500);
const stopPolling = startVisiblePolling(refreshNetworkCaptures, 1500);
return () => {
cancelled = true;
window.clearInterval(timer);
stopPolling();
};
}, [activeView, draftConfig.proxy.captureNetwork]);
const dirty = useMemo(() => formatJson(savedConfig) !== formatJson(draftConfig), [draftConfig, savedConfig]);
const dirty = draftConfig !== savedConfig;
const apiKeys = useMemo(() => createApiKeyList(draftConfig), [draftConfig.APIKEY, draftConfig.APIKEYS]);
const apiKeyEditItem = apiKeyEditIndex === undefined ? undefined : apiKeys.find((apiKey) => apiKey.index === apiKeyEditIndex);
const providerDeleteItem = providerDeleteIndex === undefined ? undefined : draftConfig.Providers[providerDeleteIndex];

View file

@ -284,6 +284,9 @@ export function LogsView({
}) {
const t = useAppText();
const [expandedId, setExpandedId] = useState<number>();
const [detailById, setDetailById] = useState<Record<number, RequestLogEntry>>({});
const [detailErrorById, setDetailErrorById] = useState<Record<number, string>>({});
const [detailLoadingId, setDetailLoadingId] = useState<number>();
const firstItem = page.total === 0 ? 0 : (page.page - 1) * page.pageSize + 1;
const lastItem = Math.min(page.total, page.page * page.pageSize);
const hasAnyCredentialInfo = Boolean(filter.credential) ||
@ -292,9 +295,36 @@ export function LogsView({
const logTableGridClass = hasAnyCredentialInfo
? "grid-cols-[minmax(0,0.8fr)_minmax(92px,0.38fr)_minmax(98px,0.4fr)_minmax(0,0.78fr)_minmax(120px,0.42fr)_minmax(0,0.68fr)_82px]"
: "grid-cols-[minmax(0,0.8fr)_minmax(92px,0.38fr)_minmax(98px,0.4fr)_minmax(0,0.9fr)_minmax(0,0.74fr)_82px]";
const loadLogDetail = useCallback((id: number) => {
if (detailById[id] || detailLoadingId === id || !window.ccr?.getRequestLogDetail) {
return;
}
setDetailLoadingId(id);
setDetailErrorById((current) => ({ ...current, [id]: "" }));
void window.ccr.getRequestLogDetail({ id })
.then((detail) => {
if (detail) {
setDetailById((current) => ({ ...current, [id]: detail }));
return;
}
setDetailErrorById((current) => ({ ...current, [id]: t("Request log not found.") }));
})
.catch((error) => {
setDetailErrorById((current) => ({ ...current, [id]: error instanceof Error ? error.message : String(error) }));
})
.finally(() => {
setDetailLoadingId((current) => current === id ? undefined : current);
});
}, [detailById, detailLoadingId, t]);
const toggleExpandedLog = useCallback((id: number) => {
setExpandedId((current) => current === id ? undefined : id);
}, []);
setExpandedId((current) => {
const next = current === id ? undefined : id;
if (next !== undefined) {
loadLogDetail(next);
}
return next;
});
}, [loadLogDetail]);
useEffect(() => {
if (!expandedId || page.items.some((item) => item.id === expandedId)) {
@ -420,10 +450,12 @@ export function LogsView({
{page.items.map((item, index) => (
<LogRow
detailError={detailErrorById[item.id]}
detailLoading={detailLoadingId === item.id}
expanded={expandedId === item.id}
hasCredentialInfo={hasAnyCredentialInfo}
index={index}
item={item}
item={expandedId === item.id ? detailById[item.id] ?? item : item}
key={item.id}
logTableGridClass={logTableGridClass}
onToggle={toggleExpandedLog}
@ -438,6 +470,8 @@ export function LogsView({
}
const LogRow = memo(function LogRow({
detailError,
detailLoading,
expanded,
hasCredentialInfo,
index,
@ -445,6 +479,8 @@ const LogRow = memo(function LogRow({
logTableGridClass,
onToggle
}: {
detailError?: string;
detailLoading?: boolean;
expanded: boolean;
hasCredentialInfo: boolean;
index: number;
@ -491,12 +527,20 @@ const LogRow = memo(function LogRow({
<div className="network-row-secondary truncate px-2" title={tokenSummary}>{tokenSummary}</div>
<div className="network-row-secondary truncate px-2">{formatDuration(item.durationMs)}</div>
</button>
{expanded ? <LogExpandedDetails entry={item} /> : null}
{expanded ? <LogExpandedDetails detailError={detailError} detailLoading={detailLoading} entry={item} /> : null}
</div>
);
});
function LogExpandedDetails({ entry }: { entry: RequestLogEntry }) {
function LogExpandedDetails({
detailError,
detailLoading,
entry
}: {
detailError?: string;
detailLoading?: boolean;
entry: RequestLogEntry;
}) {
const t = useAppText();
const hasCredentialInfo = logHasCredentialInfo(entry);
@ -529,6 +573,11 @@ function LogExpandedDetails({ entry }: { entry: RequestLogEntry }) {
<LogMetric label={t("Cost")} value={formatUsdCost(entry.costUsd ?? 0)} />
</div>
{entry.retryAttempts.length > 0 ? <LogRetryAttempts attempts={entry.retryAttempts} /> : null}
{detailLoading || detailError ? (
<div className={cn("border-b px-3 py-2 text-[12px] font-semibold", detailError ? "network-error-box" : "network-body-meta")}>
{detailError || t("Loading full payload...")}
</div>
) : null}
<div className="network-detail-panes grid h-[440px] min-h-0 grid-cols-1 lg:grid-cols-2">
<LogJsonPanel body={entry.requestBody} headerEmptyLabel="No request headers" headers={entry.requestHeaders} title={t("请求")} />
<LogJsonPanel

View file

@ -1,10 +1,3 @@
import {
CLAUDE_CODE_DEFAULT_ENV,
DEFAULT_OVERVIEW_WIDGETS,
DEFAULT_TRAY_COMPONENT_VARIANTS,
DEFAULT_TRAY_WIDGETS,
DEFAULT_TRAY_WINDOW_MODULES
} from "../../../../shared/app";
import type {
AppConfig,
AppInfo,
@ -14,6 +7,7 @@ import type {
ProxyNetworkSnapshot,
ProxyStatus
} from "../../../../shared/app";
import { createDefaultAppConfig } from "../../../../shared/default-config";
export const fallbackInfo: AppInfo = {
appConfigDbFile: "Browser preview",
@ -38,152 +32,9 @@ export const fallbackUpdateStatus: AppUpdateStatus = {
supported: false
};
export const fallbackConfig: AppConfig = {
APIKEY: "",
APIKEYS: [],
API_TIMEOUT_MS: 600000,
CUSTOM_ROUTER_PATH: "",
HOST: "127.0.0.1",
PORT: 3456,
Providers: [],
Router: {
fallback: {
mode: "off",
models: [],
retryCount: 1
},
longContextThreshold: 200000,
rules: []
},
agent: {
mcpServers: []
},
autoStart: false,
botConfigs: [],
botGateway: {
acknowledgeEvents: false,
args: [],
authType: "",
autoStartIntegration: true,
command: "",
createIntegration: false,
credentials: {},
cwd: "",
enabled: false,
forwardAllAgentMessages: true,
handoff: {
enabled: false,
idleSeconds: 30,
phoneBluetoothTargets: [],
phoneWifiTargets: [],
screenLock: true,
userIdle: true
},
integrationConfig: {},
integrationId: "",
platform: "none",
pollIntervalMs: 2000,
requestTimeoutMs: 600000,
sourceDir: "",
startupTimeoutMs: 10000,
stateDir: "",
tenantId: "ccr"
},
gateway: {
coreHost: "127.0.0.1",
corePort: 3457,
enabled: true,
generatedConfigFile: "Browser preview",
host: "127.0.0.1",
port: 3456
},
observability: {
agentAnalysis: false,
requestLogs: false
},
preferredProvider: "",
plugins: [],
profile: {
claudeCode: {
enabled: true,
model: "",
settingsFile: "~/.claude/settings.json",
smallFastModel: ""
},
codex: {
cliMiddleware: true,
codexCliPath: "",
codexHome: "",
configFormat: "separate_profile_files",
configFile: "~/.codex/config.toml",
enabled: true,
model: "",
providerId: "claude-code-router",
providerName: "Claude Code Router",
showAllSessions: false
},
enabled: true,
profiles: [
{
agent: "claude-code",
enabled: true,
env: { ...CLAUDE_CODE_DEFAULT_ENV },
id: "default-claude-code",
model: "",
name: "Claude Code",
scope: "global",
settingsFile: "~/.claude/settings.json",
smallFastModel: "",
surface: "auto"
},
{
agent: "codex",
cliMiddleware: true,
codexCliPath: "",
codexHome: "",
configFormat: "separate_profile_files",
configFile: "~/.codex/config.toml",
enabled: true,
env: {},
id: "default-codex",
model: "",
name: "Codex",
providerId: "claude-code-router",
providerName: "Claude Code Router",
showAllSessions: false,
scope: "global",
surface: "auto"
}
]
},
proxy: {
browserMode: true,
captureNetwork: false,
enabled: false,
host: "127.0.0.1",
mode: "gateway",
port: 7890,
systemProxy: false,
targets: [
{ host: "api.anthropic.com", paths: ["/v1/messages", "/v1/messages/count_tokens"] },
{ host: "api.openai.com", paths: ["/v1/chat/completions", "/v1/responses", "/v1/models"] },
{ host: "generativelanguage.googleapis.com", paths: ["/v1beta/models", "/v1/models"] },
{ host: "openrouter.ai", paths: ["/api/v1/chat/completions", "/api/v1/responses", "/api/v1/models"] },
{ host: "api.deepseek.com", paths: ["/chat/completions", "/v1/chat/completions", "/models", "/v1/models"] },
{ host: "api.mistral.ai", paths: ["/v1/chat/completions", "/v1/models"] }
]
},
providerPlugins: [],
overviewWidgets: DEFAULT_OVERVIEW_WIDGETS,
routerEndpoint: "http://127.0.0.1:3456",
theme: "system",
trayComponentVariants: DEFAULT_TRAY_COMPONENT_VARIANTS,
trayIcon: "random",
trayProgressTargetTokens: 100000,
trayWidgets: DEFAULT_TRAY_WIDGETS,
trayWindowModules: DEFAULT_TRAY_WINDOW_MODULES,
virtualModelProfiles: []
};
export const fallbackConfig: AppConfig = createDefaultAppConfig({
generatedConfigFile: "Browser preview"
});
export const fallbackGatewayStatus: GatewayStatus = {
coreEndpoint: "http://127.0.0.1:3457",

View file

@ -0,0 +1,53 @@
type PollingRefresh = () => Promise<unknown> | unknown;
type PollingOptions = {
immediate?: boolean;
};
export function startVisiblePolling(refresh: PollingRefresh, intervalMs: number, options: PollingOptions = {}): () => void {
let cancelled = false;
let inFlight = false;
let pending = false;
const run = () => {
if (cancelled || document.hidden) {
pending = true;
return;
}
if (inFlight) {
pending = true;
return;
}
pending = false;
inFlight = true;
void Promise.resolve(refresh())
.catch(() => {
// Callers own user-facing error state.
})
.finally(() => {
inFlight = false;
if (pending) {
run();
}
});
};
const onVisibilityChange = () => {
if (!document.hidden) {
run();
}
};
if (options.immediate !== false) {
run();
}
const timer = window.setInterval(run, intervalMs);
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
cancelled = true;
window.clearInterval(timer);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}

View file

@ -57,6 +57,8 @@ import type {
ProxyCertificateStatus,
ProxyNetworkSnapshot,
ProxyStatus,
RequestLogDetailRequest,
RequestLogEntry,
RequestLogListFilter,
RequestLogPage,
UsageStatsFilter,
@ -95,6 +97,7 @@ declare global {
getProxyCertificateStatus: () => Promise<ProxyCertificateStatus>;
getProxyNetworkCaptures: () => Promise<ProxyNetworkSnapshot>;
getProxyStatus: () => Promise<ProxyStatus>;
getRequestLogDetail: (request: RequestLogDetailRequest) => Promise<RequestLogEntry | undefined>;
getRequestLogs: (filter?: RequestLogListFilter) => Promise<RequestLogPage>;
getUpdateStatus: () => Promise<AppUpdateStatus>;
getUsageStats: (range?: UsageStatsRange, filter?: UsageStatsFilter) => Promise<UsageStatsSnapshot>;

View file

@ -1,5 +1,4 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve as pathResolve } from "node:path";
import { loadModelCatalogPayload } from "../../main/model-catalog-file";
const claudeCodeDefaultContextTokens = 200_000;
let modelCatalogIndex: ModelCatalogIndex | undefined;
@ -94,17 +93,14 @@ function loadModelCatalogIndex(): ModelCatalogIndex {
return modelCatalogIndex;
}
for (const candidate of modelCatalogPathCandidates()) {
if (!existsSync(candidate)) {
continue;
}
try {
const parsed = JSON.parse(readFileSync(candidate, "utf8")) as unknown;
modelCatalogIndex = buildModelCatalogIndex(parsed, candidate);
try {
const loaded = loadModelCatalogPayload();
if (loaded) {
modelCatalogIndex = buildModelCatalogIndex(loaded.payload, loaded.loadedFrom);
return modelCatalogIndex;
} catch (error) {
console.warn(`Failed to load model catalog from ${candidate}:`, error);
}
} catch (error) {
console.warn("Failed to load model catalog:", error);
}
modelCatalogIndex = {
@ -114,17 +110,6 @@ function loadModelCatalogIndex(): ModelCatalogIndex {
return modelCatalogIndex;
}
function modelCatalogPathCandidates(): string[] {
return uniqueStrings([
process.env.CCR_MODEL_CATALOG_PATH?.trim() || "",
process.env.CCR_MODELS_JSON_PATH?.trim() || "",
pathResolve(process.cwd(), "models.json"),
pathResolve(__dirname, "..", "models.json"),
pathResolve(__dirname, "..", "assets", "models.json"),
pathResolve(__dirname, "..", "..", "..", "models.json")
]);
}
function buildModelCatalogIndex(payload: unknown, loadedFrom: string): ModelCatalogIndex {
const byKey = new Map<string, ModelCatalogEntry>();
const byModelKey = new Map<string, ModelCatalogEntry | undefined>();

View file

@ -1436,6 +1436,10 @@ export type RequestLogListFilter = {
status?: RequestLogStatusFilter;
};
export type RequestLogDetailRequest = {
id: number;
};
export type RequestLogBody = ProxyNetworkBody;
export type RequestLogRetryAttempt = {

View file

@ -0,0 +1,166 @@
import {
CLAUDE_CODE_DEFAULT_ENV,
DEFAULT_OVERVIEW_WIDGETS,
DEFAULT_TRAY_COMPONENT_VARIANTS,
DEFAULT_TRAY_WIDGETS,
DEFAULT_TRAY_WINDOW_MODULES,
type AppConfig,
type ProxyRouteTarget
} from "./app";
export const DEFAULT_PROXY_TARGETS: ProxyRouteTarget[] = [
{ host: "api.anthropic.com", paths: ["/v1/messages", "/v1/messages/count_tokens"] },
{ host: "api.openai.com", paths: ["/v1/chat/completions", "/v1/responses", "/v1/models"] },
{ host: "generativelanguage.googleapis.com", paths: ["/v1beta/models", "/v1/models"] },
{ host: "openrouter.ai", paths: ["/api/v1/chat/completions", "/api/v1/responses", "/api/v1/models"] },
{ host: "api.deepseek.com", paths: ["/chat/completions", "/v1/chat/completions", "/models", "/v1/models"] },
{ host: "api.mistral.ai", paths: ["/v1/chat/completions", "/v1/models"] }
];
export type DefaultAppConfigOptions = {
coreHost?: string;
generatedConfigFile: string;
};
export function createDefaultAppConfig(options: DefaultAppConfigOptions): AppConfig {
const coreHost = options.coreHost ?? "127.0.0.1";
return {
APIKEY: "",
APIKEYS: [],
API_TIMEOUT_MS: 600000,
CUSTOM_ROUTER_PATH: "",
HOST: "127.0.0.1",
PORT: 3456,
Providers: [],
Router: {
fallback: {
mode: "off",
models: [],
retryCount: 1
},
longContextThreshold: 200000,
rules: []
},
agent: {
mcpServers: []
},
autoStart: false,
botConfigs: [],
botGateway: {
acknowledgeEvents: false,
args: [],
authType: "",
autoStartIntegration: true,
command: "",
createIntegration: false,
credentials: {},
cwd: "",
enabled: false,
forwardAllAgentMessages: true,
handoff: {
enabled: false,
idleSeconds: 30,
phoneBluetoothTargets: [],
phoneWifiTargets: [],
screenLock: true,
userIdle: true
},
integrationConfig: {},
integrationId: "",
platform: "none",
pollIntervalMs: 2000,
requestTimeoutMs: 600000,
sourceDir: "",
startupTimeoutMs: 10000,
stateDir: "",
tenantId: "ccr"
},
gateway: {
coreHost,
corePort: 3457,
enabled: true,
generatedConfigFile: options.generatedConfigFile,
host: "127.0.0.1",
port: 3456
},
observability: {
agentAnalysis: false,
requestLogs: false
},
preferredProvider: "",
plugins: [],
profile: {
claudeCode: {
enabled: true,
model: "",
settingsFile: "~/.claude/settings.json",
smallFastModel: ""
},
codex: {
cliMiddleware: true,
codexCliPath: "",
codexHome: "",
configFormat: "separate_profile_files",
configFile: "~/.codex/config.toml",
enabled: true,
model: "",
providerId: "claude-code-router",
providerName: "Claude Code Router",
showAllSessions: false
},
enabled: true,
profiles: [
{
agent: "claude-code",
enabled: true,
env: { ...CLAUDE_CODE_DEFAULT_ENV },
id: "default-claude-code",
model: "",
name: "Claude Code",
scope: "global",
settingsFile: "~/.claude/settings.json",
smallFastModel: "",
surface: "auto"
},
{
agent: "codex",
cliMiddleware: true,
codexCliPath: "",
codexHome: "",
configFormat: "separate_profile_files",
configFile: "~/.codex/config.toml",
enabled: true,
env: {},
id: "default-codex",
model: "",
name: "Codex",
providerId: "claude-code-router",
providerName: "Claude Code Router",
showAllSessions: false,
scope: "global",
surface: "auto"
}
]
},
proxy: {
browserMode: true,
captureNetwork: false,
enabled: false,
host: "127.0.0.1",
mode: "gateway",
port: 7890,
systemProxy: false,
targets: DEFAULT_PROXY_TARGETS
},
providerPlugins: [],
overviewWidgets: DEFAULT_OVERVIEW_WIDGETS,
routerEndpoint: "http://127.0.0.1:3456",
theme: "system",
trayComponentVariants: DEFAULT_TRAY_COMPONENT_VARIANTS,
trayIcon: "random",
trayProgressTargetTokens: 100000,
trayWidgets: DEFAULT_TRAY_WIDGETS,
trayWindowModules: DEFAULT_TRAY_WINDOW_MODULES,
virtualModelProfiles: []
};
}

View file

@ -19,6 +19,7 @@ export const IPC_CHANNELS = {
appGetProxyCertificateStatus: "ccr:app:get-proxy-certificate-status",
appGetProxyNetworkCaptures: "ccr:app:get-proxy-network-captures",
appGetProxyStatus: "ccr:app:get-proxy-status",
appGetRequestLogDetail: "ccr:app:get-request-log-detail",
appGetRequestLogs: "ccr:app:get-request-logs",
appGetUpdateStatus: "ccr:app:get-update-status",
appGetUsageStats: "ccr:app:get-usage-stats",

View file

@ -0,0 +1,111 @@
import assert from "node:assert/strict";
import test from "node:test";
import { botGatewayProfileEnv } from "../src/main/bot-gateway-env.ts";
function botGateway(overrides = {}) {
return {
acknowledgeEvents: true,
args: ["--stdio"],
authType: "token",
autoStartIntegration: true,
command: "bot-gateway",
createIntegration: true,
credentials: { sendMode: "legacy", token: "secret", webhookUrl: "https://old.example.com" },
cwd: "/tmp",
enabled: true,
forwardAllAgentMessages: true,
handoff: {
enabled: true,
idleSeconds: 45,
phoneBluetoothTargets: ["bt-phone"],
phoneWifiTargets: ["wifi-phone"],
screenLock: false,
userIdle: true
},
integrationConfig: { sendMode: "webhook", team: "T1", transport: "http" },
integrationId: "integration-1",
platform: "slack",
pollIntervalMs: 2500,
requestTimeoutMs: 600000,
sourceDir: "",
startupTimeoutMs: 15000,
stateDir: "",
tenantId: "tenant-1",
...overrides
};
}
const profile = {
agent: "codex",
botConfigId: "saved-bot",
enabled: true,
id: "codex-main",
model: "provider,model",
name: "Codex Main",
surface: "app"
};
test("botGatewayProfileEnv disables bot gateway outside app surface", () => {
const env = botGatewayProfileEnv({ botConfigs: [], botGateway: botGateway() }, profile, "cli");
assert.deepEqual(env, {
CCR_BOT_GATEWAY_ENABLED: "false",
CODEXL_BOT_GATEWAY_ENABLED: "false"
});
});
test("botGatewayProfileEnv merges saved config and normalizes websocket integration", () => {
const env = botGatewayProfileEnv(
{
botConfigs: [
{
botGateway: botGateway({
authType: "appsecret",
credentials: { appId: "app-1", webhookSecret: "drop-me" },
integrationConfig: { appId: "app-1", transport: "http" },
platform: "lark",
stateDir: "~/bot-state"
}),
id: "saved-bot",
name: "Saved Bot"
}
],
botGateway: botGateway({ enabled: false, platform: "none" })
},
profile,
"app"
);
assert.equal(env.CCR_BOT_GATEWAY_ENABLED, "true");
assert.equal(env.CCR_BOT_GATEWAY_PLATFORM, "feishu");
assert.equal(env.CCR_BOT_GATEWAY_AUTH_TYPE, "app_secret");
assert.equal(env.CCR_BOT_GATEWAY_CREATE_INTEGRATION, "true");
assert.equal(env.CCR_BOT_GATEWAY_STATE_DIR, `${process.env.HOME}/bot-state`);
assert.equal(env.CCR_BOT_PROFILE_ID, "codex-main");
assert.equal(env.CCR_BOT_PROFILE_NAME, "Codex Main");
const credentials = JSON.parse(env.CCR_BOT_GATEWAY_CREDENTIALS_JSON);
assert.deepEqual(credentials, { appId: "app-1", token: "secret" });
const integrationConfig = JSON.parse(env.CCR_BOT_GATEWAY_CONFIG_JSON);
assert.deepEqual(integrationConfig, { appId: "app-1", team: "T1", transport: "websocket" });
});
test("botGatewayProfileEnv disables create integration for QR login platforms", () => {
const env = botGatewayProfileEnv(
{
botConfigs: [],
botGateway: botGateway({
authType: "qr",
platform: "weixin"
})
},
{ ...profile, botConfigId: undefined },
"app"
);
assert.equal(env.CCR_BOT_GATEWAY_PLATFORM, "weixin-ilink");
assert.equal(env.CCR_BOT_GATEWAY_AUTH_TYPE, "qr_login");
assert.equal(env.CCR_BOT_GATEWAY_CREATE_INTEGRATION, "false");
assert.equal(JSON.parse(env.CCR_BOT_GATEWAY_CONFIG_JSON).transport, "websocket");
});

126
tests/deep-link.test.mjs Normal file
View file

@ -0,0 +1,126 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createProviderDeepLinkRequest,
isAppDeepLinkUrl,
parseProviderDeepLinkPayload,
parseProviderManifestDeepLinkPayload,
parseProviderManifestPayload
} from "../src/shared/deep-link.ts";
function base64UrlJson(value) {
return Buffer.from(JSON.stringify(value), "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
test("parseProviderDeepLinkPayload reads payload JSON, models, display names, and usage account mapping", () => {
const payload = {
account: {
connectors: {
auth: "provider-api-key",
endpoint: "https://usage.example.com/balance",
mapping: { meters: [{ id: "balance", kind: "balance", remaining: "$.balance" }] },
type: "http-json"
},
enabled: true,
refreshIntervalMs: 60000
},
api_key: "sk-test",
base_url: "https://api.example.com/v1",
fetch_usage: true,
model_display_names: {
"model-a": "Model A"
},
models: [
{ displayName: "Model B", id: "model-b" },
"model-a,model-c"
],
name: "Example AI",
protocol: "openai_chat_completions",
source: "https://example.com/install"
};
const parsed = parseProviderDeepLinkPayload(`ccr://provider?payload=${base64UrlJson(payload)}`);
assert.equal(parsed.name, "Example AI");
assert.equal(parsed.baseUrl, "https://api.example.com/v1");
assert.equal(parsed.apiKey, "sk-test");
assert.equal(parsed.protocol, "openai_chat_completions");
assert.deepEqual(parsed.models, ["model-b", "model-a", "model-c"]);
assert.deepEqual(parsed.modelDisplayNames, {
"model-a": "Model A",
"model-b": "Model B"
});
assert.equal(parsed.account?.enabled, true);
assert.equal(parsed.account?.refreshIntervalMs, 60000);
assert.equal(parsed.account?.connectors?.[0]?.type, "http-json");
});
test("parseProviderDeepLinkPayload builds usage account config from query params", () => {
const usageHeaders = encodeURIComponent(JSON.stringify({ "x-usage": "yes", ignored: 123 }));
const parsed = parseProviderDeepLinkPayload(
[
"ccr://provider?name=Query%20AI",
"base_url=https%3A%2F%2Fapi.example.com%2Fv1",
"models=model-a%2Cmodel-b",
"models=model-b%0Amodel-c",
"fetch_usage=true",
"usage_url=https%3A%2F%2Fusage.example.com%2Fme",
"usage_method=post",
`usage_headers=${usageHeaders}`,
"balance=%24.balance.remaining",
"balance_unit=CNY",
"subscription=%24.quota.remaining",
"subscription_limit=%24.quota.limit"
].join("&")
);
assert.deepEqual(parsed.models, ["model-a", "model-b", "model-c"]);
const connector = parsed.account?.connectors?.[0];
assert.equal(connector?.type, "http-json");
assert.equal(connector?.method, "POST");
assert.deepEqual(connector?.headers, { "x-usage": "yes" });
assert.equal(connector?.mapping.meters.length, 2);
assert.equal(connector?.mapping.meters[0].unit, "CNY");
});
test("provider deeplink manifest parsing accepts only HTTPS manifest URLs", () => {
assert.equal(isAppDeepLinkUrl(" ccr://provider?base_url=https://api.example.com "), true);
assert.deepEqual(
parseProviderManifestDeepLinkPayload("ccr://provider?manifest=https%3A%2F%2Fexample.com%2Fccr.json"),
{ url: "https://example.com/ccr.json" }
);
assert.throws(
() => parseProviderManifestDeepLinkPayload("ccr://provider?manifest=http%3A%2F%2Fexample.com%2Fccr.json"),
/must use https/
);
});
test("createProviderDeepLinkRequest captures parsing errors without throwing", () => {
const request = createProviderDeepLinkRequest("https://example.com/not-ccr", new Date("2026-06-30T00:00:00.000Z"));
assert.equal(request.rawUrl, "https://example.com/not-ccr");
assert.equal(request.receivedAt, "2026-06-30T00:00:00.000Z");
assert.match(request.error ?? "", /Unsupported link protocol/);
});
test("parseProviderManifestPayload accepts provider wrappers and source fallback", () => {
const parsed = parseProviderManifestPayload(
{
provider: {
base_url: "https://api.example.com/v1",
models: [{ display_name: "Display Model", id: "display-model" }],
name: "Manifest AI"
}
},
"https://example.com/manifest.json"
);
assert.equal(parsed.name, "Manifest AI");
assert.equal(parsed.source, "https://example.com/manifest.json");
assert.deepEqual(parsed.models, ["display-model"]);
assert.deepEqual(parsed.modelDisplayNames, { "display-model": "Display Model" });
});

View file

@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { loadModelCatalogPayload, modelCatalogPathCandidates } from "../src/main/model-catalog-file.ts";
test("modelCatalogPathCandidates prefers env paths and removes duplicates", () => {
const previousCatalogPath = process.env.CCR_MODEL_CATALOG_PATH;
const previousModelsPath = process.env.CCR_MODELS_JSON_PATH;
try {
process.env.CCR_MODEL_CATALOG_PATH = "/tmp/ccr-models.json";
process.env.CCR_MODELS_JSON_PATH = "/tmp/ccr-models.json";
const candidates = modelCatalogPathCandidates();
assert.equal(candidates[0], "/tmp/ccr-models.json");
assert.equal(candidates.filter((candidate) => candidate === "/tmp/ccr-models.json").length, 1);
assert.ok(candidates.some((candidate) => candidate.endsWith("models.json")));
} finally {
if (previousCatalogPath === undefined) {
delete process.env.CCR_MODEL_CATALOG_PATH;
} else {
process.env.CCR_MODEL_CATALOG_PATH = previousCatalogPath;
}
if (previousModelsPath === undefined) {
delete process.env.CCR_MODELS_JSON_PATH;
} else {
process.env.CCR_MODELS_JSON_PATH = previousModelsPath;
}
}
});
test("loadModelCatalogPayload reads the first configured existing catalog", () => {
const previousCatalogPath = process.env.CCR_MODEL_CATALOG_PATH;
const previousModelsPath = process.env.CCR_MODELS_JSON_PATH;
const dir = mkdtempSync(path.join(tmpdir(), "ccr-model-catalog-test-"));
try {
const catalogFile = path.join(dir, "models.json");
writeFileSync(catalogFile, JSON.stringify({ models: [{ id: "test-model" }] }), "utf8");
process.env.CCR_MODEL_CATALOG_PATH = path.join(dir, "missing.json");
process.env.CCR_MODELS_JSON_PATH = catalogFile;
const loaded = loadModelCatalogPayload();
assert.equal(loaded?.loadedFrom, catalogFile);
assert.deepEqual(loaded?.payload, { models: [{ id: "test-model" }] });
} finally {
rmSync(dir, { force: true, recursive: true });
if (previousCatalogPath === undefined) {
delete process.env.CCR_MODEL_CATALOG_PATH;
} else {
process.env.CCR_MODEL_CATALOG_PATH = previousCatalogPath;
}
if (previousModelsPath === undefined) {
delete process.env.CCR_MODELS_JSON_PATH;
} else {
process.env.CCR_MODELS_JSON_PATH = previousModelsPath;
}
}
});

View file

@ -0,0 +1,104 @@
import assert from "node:assert/strict";
import path from "node:path";
import test from "node:test";
import {
buildProfileLaunchPlan,
ccrManagedProfileDir,
findProfileForOpen,
profileOpenCommand,
profileOpenSurfaces,
resolveClaudeCodeSettingsFile,
resolveCodexConfigFile,
resolveProfileOpenSurface
} from "../src/main/profile-launch-core.ts";
const claudeProfile = {
agent: "claude-code",
enabled: true,
id: "claude-main",
model: "provider,model",
name: "Claude Main",
scope: "ccr",
surface: "auto"
};
const codexProfile = {
agent: "codex",
enabled: true,
id: "codex-main",
model: "provider,model",
name: "Codex Main",
providerId: "openai-codex",
scope: "ccr",
surface: "auto"
};
test("findProfileForOpen resolves enabled profiles and reports ambiguous names", () => {
const config = {
profile: {
profiles: [
claudeProfile,
{ ...claudeProfile, enabled: false, id: "disabled", name: "Disabled" },
{ ...codexProfile, id: "duplicate-a", name: "Duplicate Name" },
{ ...codexProfile, id: "duplicate-b", name: "duplicate name" }
]
}
};
assert.equal(findProfileForOpen(config, "claude-main").id, "claude-main");
assert.equal(findProfileForOpen(config, "claude main").id, "claude-main");
assert.throws(() => findProfileForOpen(config, "duplicate name"), /ambiguous/);
assert.throws(() => findProfileForOpen(config, "Disabled"), /not found or is disabled/);
});
test("profile open surfaces enforce agent capabilities", () => {
assert.deepEqual(profileOpenSurfaces(claudeProfile), ["cli", "app"]);
assert.deepEqual(profileOpenSurfaces({ ...claudeProfile, surface: "cli" }), ["cli"]);
assert.deepEqual(profileOpenSurfaces({ ...codexProfile, agent: "zcode" }), ["app"]);
assert.equal(resolveProfileOpenSurface(codexProfile, "app"), "app");
assert.throws(() => resolveProfileOpenSurface({ ...claudeProfile, surface: "cli" }, "app"), /does not support APP/);
});
test("buildProfileLaunchPlan creates CCR-managed launcher paths", () => {
const configDir = path.join(path.sep, "tmp", "ccr-config");
const codexPlan = buildProfileLaunchPlan(configDir, codexProfile, "app");
const claudePlan = buildProfileLaunchPlan(configDir, claudeProfile, "cli", ["--debug"]);
assert.equal(codexPlan.surface, "app");
assert.deepEqual(codexPlan.args, ["app"]);
assert.equal(path.basename(codexPlan.command), process.platform === "win32" ? "ccr-codex-cli-stdio-codex-main.cmd" : "ccr-codex-cli-stdio-codex-main");
assert.equal(codexPlan.env.CCR_PROFILE_SURFACE, "app");
assert.equal(claudePlan.surface, "cli");
assert.deepEqual(claudePlan.args, ["--debug"]);
assert.equal(path.basename(claudePlan.command), process.platform === "win32" ? "ccr-claude-code-wrapper-claude-main.cmd" : "ccr-claude-code-wrapper-claude-main");
assert.equal(claudePlan.env.CCR_PROFILE_SURFACE, "cli");
assert.match(claudePlan.env.CLAUDE_CONFIG_DIR, /claude$/);
assert.throws(() => buildProfileLaunchPlan(configDir, claudeProfile, "app"), /Claude App opening/);
});
test("profile config paths honor CCR, custom, and global scopes", () => {
const configDir = path.join(path.sep, "tmp", "ccr-config");
const customProfile = { ...codexProfile, id: "Custom Profile", scope: "custom" };
const globalCodex = { ...codexProfile, codexHome: "~/codex-home", scope: "global" };
assert.equal(
ccrManagedProfileDir(configDir, customProfile),
path.join(configDir, "profiles", "custom-profile", "custom")
);
assert.equal(
resolveClaudeCodeSettingsFile(configDir, claudeProfile),
path.join(configDir, "profiles", "claude-main", "claude", "settings.json")
);
assert.equal(
resolveCodexConfigFile(configDir, customProfile),
path.join(configDir, "profiles", "custom-profile", "custom", "codex", "config.toml")
);
assert.equal(resolveCodexConfigFile(configDir, globalCodex), path.join(process.env.HOME, "codex-home", "config.toml"));
});
test("profileOpenCommand quotes profile references for shell usage", () => {
assert.match(profileOpenCommand(claudeProfile, "cli", "ccr", "Claude Main"), /Claude/);
assert.match(profileOpenCommand(claudeProfile, "cli", "ccr", "Claude Main"), /Main/);
});

View file

@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
findProviderPresetByBaseUrlInList,
findProviderPresetByIdentityInList,
providerApiKeySafetyIssueInList,
providerEndpointCanReceiveProviderApiKeyInList,
providerIdentitySafetyIssueInList,
providerPresetMatchesBaseUrl
} from "../src/shared/provider-preset-utils.ts";
const openAiPreset = {
aliases: ["OpenAI", "ChatGPT"],
endpoints: [{ baseUrl: "https://api.openai.com/v1", protocols: ["openai_chat_completions"] }],
id: "openai",
name: "OpenAI",
officialApiKeyPatterns: [{ source: "^sk-openai-" }]
};
const anthropicPreset = {
aliases: ["Claude"],
endpoints: [{ baseUrl: "https://api.anthropic.com", protocols: ["anthropic_messages"] }],
id: "anthropic",
name: "Anthropic",
officialApiKeyPatterns: [{ source: "^sk-ant-" }]
};
const presets = [openAiPreset, anthropicPreset];
test("provider preset matching accepts endpoint subpaths but rejects different hosts", () => {
assert.equal(providerPresetMatchesBaseUrl(openAiPreset, "https://api.openai.com/v1/chat/completions"), true);
assert.equal(providerPresetMatchesBaseUrl(openAiPreset, "https://api.openai.com"), true);
assert.equal(providerPresetMatchesBaseUrl(openAiPreset, "https://proxy.example.com/v1"), false);
assert.equal(findProviderPresetByBaseUrlInList(presets, "api.anthropic.com/v1/messages")?.id, "anthropic");
});
test("provider identity lookup normalizes aliases and punctuation", () => {
assert.equal(findProviderPresetByIdentityInList(presets, "my ChatGPT gateway")?.id, "openai");
assert.equal(findProviderPresetByIdentityInList(presets, "Claude Provider")?.id, "anthropic");
});
test("provider identity safety allows loopback but warns on branded third-party endpoints", () => {
assert.equal(
providerIdentitySafetyIssueInList(presets, {
baseUrl: "http://127.0.0.1:3456/v1",
name: "OpenAI local test"
}),
undefined
);
assert.match(
providerIdentitySafetyIssueInList(presets, {
baseUrl: "https://proxy.example.com/v1",
name: "OpenAI proxy"
})?.message ?? "",
/Provider identity looks like OpenAI/
);
});
test("provider API key safety blocks official-looking keys on untrusted endpoints", () => {
assert.match(
providerApiKeySafetyIssueInList(presets, {
apiKey: "sk-openai-test",
baseUrl: "https://proxy.example.com/v1",
name: "neutral proxy"
})?.message ?? "",
/official OpenAI key/
);
assert.equal(
providerApiKeySafetyIssueInList(presets, {
apiKey: "sk-openai-test",
baseUrl: "https://api.openai.com/v1"
}),
undefined
);
assert.match(
providerEndpointCanReceiveProviderApiKeyInList(presets, {
apiKey: "sk-ant-test",
endpoint: "https://proxy.example.com/anthropic",
providerName: "Anthropic"
})?.message ?? "",
/official Anthropic key/
);
});

View file

@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
normalizeProviderBaseUrl,
parseProviderBaseUrl,
providerBaseUrlForProtocol,
providerUrlWithDefaultScheme
} from "../src/shared/provider-url.ts";
test("provider URL parsing strips endpoint paths and unsafe URL parts", () => {
const parsed = parseProviderBaseUrl("https://user:secret@api.example.com/v1/chat/completions?token=secret#section");
assert.equal(parsed.normalizedInputBaseUrl, "https://api.example.com/v1");
assert.equal(parsed.rootBaseUrl, "https://api.example.com");
assert.equal(providerBaseUrlForProtocol(parsed, "openai_chat_completions"), "https://api.example.com/v1");
assert.equal(providerBaseUrlForProtocol(parsed, "openai_responses"), "https://api.example.com/v1");
assert.equal(providerBaseUrlForProtocol(parsed, "anthropic_messages"), "https://api.example.com");
assert.equal(providerBaseUrlForProtocol(parsed, "gemini_generate_content"), "https://api.example.com");
});
test("provider URL parsing handles local and Gemini endpoint variants", () => {
const parsed = parseProviderBaseUrl("localhost:8787/v1beta/models/gemini-2.5-pro:generateContent");
assert.equal(parsed.normalizedInputBaseUrl, "http://localhost:8787/v1beta");
assert.equal(parsed.rootBaseUrl, "http://localhost:8787");
assert.equal(parsed.geminiBaseUrl, "http://localhost:8787");
});
test("provider URL normalization chooses protocol-specific bases", () => {
assert.equal(providerUrlWithDefaultScheme("127.0.0.1:3456/v1"), "http://127.0.0.1:3456/v1");
assert.equal(providerUrlWithDefaultScheme("api.example.com/v1"), "https://api.example.com/v1");
assert.equal(
normalizeProviderBaseUrl("api.example.com/v1/messages", "anthropic_messages"),
"https://api.example.com"
);
assert.equal(
normalizeProviderBaseUrl("api.example.com/v1/chat/completions", "openai_chat_completions"),
"https://api.example.com/v1"
);
});

View file

@ -0,0 +1,275 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { RequestLogStore } from "../src/main/request-log-store.ts";
test("RequestLogStore keeps list rows lightweight and detail rows complete", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-test-"));
try {
const store = new RequestLogStore(path.join(dir, "request-logs.sqlite"));
const body = JSON.stringify({ messages: [{ content: "hello", role: "user" }], model: "request-model" });
const response = JSON.stringify({
model: "response-model",
usage: {
input_tokens: 3,
output_tokens: 4,
total_tokens: 7
}
});
await store.record({
completedAt: new Date().toISOString(),
durationMs: 42,
method: "POST",
path: "/v1/messages",
providerName: "test-provider",
requestBody: Buffer.from(body, "utf8"),
requestHeaders: { "content-type": "application/json" },
requestId: "request-log-test",
responseBodyText: response,
responseHeaders: { "content-type": "application/json" },
startedAt: new Date().toISOString(),
statusCode: 200,
url: "http://127.0.0.1:3456/v1/messages"
});
const page = await store.list({ pageSize: 25 });
assert.equal(page.items.length, 1);
assert.equal(page.items[0].requestBody.text, "");
assert.equal(page.items[0].responseBody?.text, "");
assert.equal(page.items[0].requestBody.sizeBytes, Buffer.byteLength(body));
assert.equal(page.items[0].responseBody?.sizeBytes, Buffer.byteLength(response));
const detail = await store.getDetail({ id: page.items[0].id });
assert.ok(detail);
assert.match(detail.requestBody.text, /request-model/);
assert.match(detail.responseBody?.text ?? "", /response-model/);
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("RequestLogStore redacts secrets and records CCR metadata", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-metadata-test-"));
try {
const store = new RequestLogStore(path.join(dir, "request-logs.sqlite"));
const startedAt = new Date().toISOString();
await store.record({
completedAt: startedAt,
durationMs: 75,
method: "POST",
path: "/v1/chat/completions",
providerName: "openai-like",
providerProtocol: "openai_chat_completions",
requestBody: Buffer.from(JSON.stringify({ model: "gpt-test", stream: true }), "utf8"),
requestHeaders: {
accept: "text/event-stream",
authorization: "Bearer request-secret",
cookie: "session=request-secret",
"content-type": "application/json",
"x-ccr-provider-credential-chain": "cred-a, cred-b",
"x-ccr-provider-credential-id": "cred-a"
},
requestId: "request-log-metadata-test",
responseBodyText: "",
responseHeaders: {
"content-type": "text/event-stream",
"x-api-key": "response-secret",
"x-ccr-provider-credential-saturated": "true",
"x-gateway-billing-cache-read-tokens": "10",
"x-gateway-billing-input-tokens": "100",
"x-gateway-billing-output-tokens": "20",
"x-gateway-billing-total-tokens": "130"
},
startedAt,
statusCode: 200,
url: "http://127.0.0.1:3456/v1/chat/completions"
});
const page = await store.list({ pageSize: 25 });
const detail = await store.getDetail({ id: page.items[0].id });
assert.ok(detail);
assert.equal(detail.requestHeaders.authorization, "[redacted]");
assert.equal(detail.requestHeaders.cookie, "[redacted]");
assert.equal(detail.responseHeaders["x-api-key"], "[redacted]");
assert.equal(detail.credentialId, "cred-a");
assert.deepEqual(detail.credentialChain, ["cred-a", "cred-b"]);
assert.equal(detail.credentialSaturated, true);
assert.equal(detail.isStream, true);
assert.equal(detail.inputTokens, 90);
assert.equal(detail.cacheReadTokens, 10);
assert.equal(detail.outputTokens, 20);
assert.equal(detail.totalTokens, 130);
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("RequestLogStore applies raw trace updates to existing request logs", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-raw-trace-test-"));
try {
const store = new RequestLogStore(path.join(dir, "request-logs.sqlite"));
const startedAt = new Date().toISOString();
const errorStream = [
"event: error",
'data: {"error":{"type":"rate_limit_error","message":"quota exceeded"}}',
""
].join("\n");
await store.record({
completedAt: startedAt,
durationMs: 20,
method: "POST",
path: "/v1/messages",
providerName: "before-provider",
requestBody: Buffer.from(JSON.stringify({ model: "before-model" }), "utf8"),
requestHeaders: { "content-type": "application/json" },
requestId: "raw-trace-request",
responseBodyText: "{}",
responseHeaders: { "content-type": "application/json" },
startedAt,
statusCode: 200,
url: "http://127.0.0.1:3456/v1/messages"
});
const applied = await store.updateFromRawTrace({
isStream: true,
model: "trace-model",
provider: "trace-provider",
requestHeaders: { "x-client-name": "codex-cli" },
requestId: "raw-trace-request",
responseBodyContentType: "text/event-stream",
responseBodyText: errorStream,
responseHeaders: { "content-type": "text/event-stream" },
statusCode: 200
});
assert.equal(applied, true);
const page = await store.list({ query: "quota exceeded" });
assert.equal(page.items.length, 1);
const detail = await store.getDetail({ id: page.items[0].id });
assert.ok(detail);
assert.equal(detail.model, "trace-model");
assert.equal(detail.provider, "trace-provider");
assert.equal(detail.ok, false);
assert.equal(detail.isStream, true);
assert.match(detail.error, /rate_limit_error: quota exceeded/);
assert.match(detail.responseBody?.text ?? "", /quota exceeded/);
assert.equal(detail.requestHeaders["x-client-name"], "codex-cli");
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("RequestLogStore analyzes agent sessions and exposes trace payloads", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-agent-test-"));
try {
const store = new RequestLogStore(path.join(dir, "request-logs.sqlite"));
const startedAt = new Date(Date.now() - 1000).toISOString();
const completedAt = new Date().toISOString();
const requestBody = {
messages: [
{ content: "inspect repo", role: "user" },
{
content: JSON.stringify({ ok: true, files: ["README.md"] }),
role: "tool",
tool_call_id: "call-read"
}
],
model: "gpt-test",
session_id: "session-1"
};
const responseBody = {
choices: [
{
message: {
role: "assistant",
tool_calls: [
{
function: {
arguments: JSON.stringify({ path: "README.md" }),
name: "read_file"
},
id: "call-read",
type: "function"
}
]
}
}
],
model: "gpt-test",
usage: {
completion_tokens: 3,
prompt_tokens: 7,
total_tokens: 10
}
};
await store.record({
completedAt,
durationMs: 150,
method: "POST",
path: "/v1/chat/completions",
providerName: "test-provider",
providerProtocol: "openai_chat_completions",
requestBody: Buffer.from(JSON.stringify(requestBody), "utf8"),
requestHeaders: {
"content-type": "application/json",
"user-agent": "openai-codex test",
"x-ccr-route-reason": "default",
"x-codex-session-id": "session-1"
},
requestId: "agent-request-1",
responseBodyText: JSON.stringify(responseBody),
responseHeaders: { "content-type": "application/json" },
startedAt,
statusCode: 200,
url: "http://127.0.0.1:3456/v1/chat/completions"
});
const page = await store.list({ pageSize: 25 });
const detail = await store.getDetail({ id: page.items[0].id });
assert.ok(detail);
const analysis = await store.analyze({ range: "30d" });
assert.equal(analysis.scannedRequestCount, 1);
assert.equal(analysis.totals.requestCount, 1);
assert.equal(analysis.agents[0]?.agent, "codex");
assert.equal(analysis.sessions[0]?.id, "session-1");
assert.equal(analysis.tools[0]?.name, "read_file");
const selected = await store.analyze({
range: "30d",
sessionAgent: "codex",
sessionId: "session-1"
});
assert.equal(selected.selectedSession?.trace.toolRunCount, 1);
assert.equal(selected.selectedSession?.trace.llmRunCount, 1);
assert.equal(selected.selectedSession?.trace.runs.some((run) => run.toolName === "read_file"), true);
const inputPayload = await store.getTracePayload({
callId: "call-read",
part: "tool-input",
requestLogId: detail.id
});
assert.equal(inputPayload.found, true);
assert.equal(inputPayload.kind, "json");
assert.match(inputPayload.content, /README\.md/);
const resultPayload = await store.getTracePayload({
callId: "call-read",
part: "tool-result",
requestLogId: detail.id
});
assert.equal(resultPayload.found, true);
assert.equal(resultPayload.kind, "json");
assert.match(resultPayload.content, /files/);
} finally {
rmSync(dir, { force: true, recursive: true });
}
});

View file

@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildTokenActivity, activityDateKey } from "../src/renderer/lib/usage-activity.ts";
test("buildTokenActivity summarizes observed token days and streaks", () => {
const summary = buildTokenActivity(
[
{ bucket: "2026-06-02", totalTokens: 10 },
{ bucket: "2026-06-03", totalTokens: 30 },
{ bucket: "2026-06-04", totalTokens: 60 },
{ bucket: "2026-06-05", totalTokens: -20 },
{ bucket: "not-a-date", totalTokens: 999 }
],
{ minWeeks: 2 }
);
assert.equal(summary.totalTokens, 100);
assert.equal(summary.activeDays, 3);
assert.equal(summary.dayCount, 4);
assert.equal(summary.longestStreak, 3);
assert.equal(summary.maxTokens, 60);
assert.equal(summary.weekCount, 2);
assert.equal(summary.cells.length, 14);
const cellsByDate = new Map(summary.cells.map((cell) => [cell.dateKey, cell]));
assert.equal(cellsByDate.get("2026-06-02")?.intensity, 1);
assert.equal(cellsByDate.get("2026-06-03")?.intensity, 3);
assert.equal(cellsByDate.get("2026-06-04")?.intensity, 4);
assert.equal(cellsByDate.get("2026-06-05")?.intensity, 0);
});
test("activityDateKey formats local calendar dates", () => {
assert.equal(activityDateKey(new Date(2026, 0, 5, 14, 30)), "2026-01-05");
});

View file

@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import test from "node:test";
import { normalizeUsageInputTokens } from "../src/main/usage-normalization.ts";
test("normalizeUsageInputTokens subtracts cache tokens for OpenAI-compatible protocols", () => {
const usage = normalizeUsageInputTokens(
{
cacheReadTokens: 20,
cacheWriteTokens: 5,
inputTokens: 100,
outputTokens: 12
},
{ providerProtocol: "openai_chat_completions" }
);
assert.deepEqual(usage, {
cacheReadTokens: 20,
cacheWriteTokens: 5,
inputTokens: 75,
outputTokens: 12
});
});
test("normalizeUsageInputTokens keeps Anthropic input tokens unchanged", () => {
const usage = normalizeUsageInputTokens(
{
cacheReadTokens: 20,
cacheWriteTokens: 5,
inputTokens: 100
},
{ providerProtocol: "anthropic_messages" }
);
assert.deepEqual(usage, {
cacheReadTokens: 20,
cacheWriteTokens: 5,
inputTokens: 100
});
});
test("normalizeUsageInputTokens falls back to path and usage hints", () => {
assert.equal(
normalizeUsageInputTokens(
{ cacheReadTokens: 8, inputTokens: 50 },
{ path: "/v1/responses" }
)?.inputTokens,
42
);
assert.equal(
normalizeUsageInputTokens(
{ cacheReadTokens: 8, inputTokens: 50 },
{ usageHint: { inputIncludesCacheTokens: false } }
)?.inputTokens,
50
);
});

105
tests/usage-store.test.mjs Normal file
View file

@ -0,0 +1,105 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { UsageStore } from "../src/main/usage-store.ts";
test("UsageStore aggregates stats in SQLite without loading all events", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "ccr-usage-test-"));
try {
const store = new UsageStore(path.join(dir, "usage.sqlite"));
const now = new Date();
const earlier = new Date(now.getTime() - 60_000);
await store.record({
createdAt: earlier.toISOString(),
durationMs: 120,
method: "POST",
model: "alpha-model",
path: "/v1/messages",
provider: "alpha",
requestId: "req-1",
statusCode: 200,
usage: {
cacheReadTokens: 2,
inputTokens: 10,
outputTokens: 5,
totalTokens: 17
}
});
await store.record({
createdAt: now.toISOString(),
durationMs: 80,
method: "POST",
model: "beta-model",
path: "/v1/messages",
provider: "beta",
requestId: "req-2",
statusCode: 500,
usage: {
inputTokens: 4,
outputTokens: 6
}
});
const stats = await store.getStats("30d", { includeProxy: true });
assert.equal(stats.totals.requestCount, 2);
assert.equal(stats.totals.errorCount, 1);
assert.equal(stats.totals.totalTokens, 27);
assert.equal(stats.totals.inputTokens, 14);
assert.equal(stats.totals.outputTokens, 11);
assert.equal(stats.recentRequests.length, 2);
assert.equal(stats.models[0]?.requestCount, 1);
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("UsageStore excludes proxy rows by default and includes them on request", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "ccr-usage-proxy-test-"));
try {
const store = new UsageStore(path.join(dir, "usage.sqlite"));
const createdAt = new Date().toISOString();
await store.record({
createdAt,
durationMs: 10,
method: "POST",
model: "direct/model-a",
path: "/v1/messages",
requestId: "direct-1",
statusCode: 200,
usage: {
inputTokens: 5,
outputTokens: 7
}
});
await store.record({
createdAt,
durationMs: 10,
method: "POST",
model: "proxy-model",
path: "/v1/messages",
provider: "proxy",
requestId: "proxy-1",
statusCode: 200,
usage: {
inputTokens: 100,
outputTokens: 200
}
});
const defaultStats = await store.getStats("30d");
assert.equal(defaultStats.totals.requestCount, 1);
assert.equal(defaultStats.totals.totalTokens, 12);
assert.equal(defaultStats.providerModels[0]?.provider, "direct");
assert.equal(defaultStats.providerModels[0]?.model, "model-a");
const withProxy = await store.getStats("30d", { includeProxy: true });
assert.equal(withProxy.totals.requestCount, 2);
assert.equal(withProxy.totals.totalTokens, 312);
} finally {
rmSync(dir, { force: true, recursive: true });
}
});