mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-09 17:18:24 +00:00
Document SQLite config migration and add test script
This commit is contained in:
parent
608915c037
commit
a784eba837
47 changed files with 1858 additions and 460 deletions
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
33
build/run-tests.mjs
Normal 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
44
build/test.mjs
Normal 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();
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 配置数据库位置 |
|
||||
|
||||
## 内容关系
|
||||
|
||||
|
|
|
|||
|
|
@ -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` 辅助文件。
|
||||
|
|
|
|||
|
|
@ -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/)。
|
||||
|
||||
本地目录选择器会按顺序识别这些入口信息:
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ CCR 默认监听本机地址 `http://localhost:8080`。Agent 只要指向这个
|
|||
| --- | --- |
|
||||
| [文档](./) | 产品定位、架构概览、阅读路径 |
|
||||
| [快速开始](guides/) | 从安装、接供应商,到接入 Agent 的上手流程 |
|
||||
| [详细配置](configuration/) | 供应商、路由、配置、Fusion、Bot 和配置文件位置 |
|
||||
| [详细配置](configuration/) | 供应商、路由、配置、Fusion、Bot 和配置数据库位置 |
|
||||
| [Q&A](troubleshooting/) | 请求日志、观测面板和常见问题 |
|
||||
|
||||
Bot 平台教程是「详细配置」分类下的子页面,每个平台有独立页面,方便逐步补齐平台后台字段、回调 URL、签名和 FAQ。
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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]) => {
|
||||
|
|
|
|||
|
|
@ -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]) => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
45
src/main/model-catalog-file.ts
Normal file
45
src/main/model-catalog-file.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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 : [];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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[]>();
|
||||
|
|
|
|||
|
|
@ -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"]>,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
53
src/renderer/pages/home/shared/polling.ts
Normal file
53
src/renderer/pages/home/shared/polling.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
3
src/renderer/types/electron.d.ts
vendored
3
src/renderer/types/electron.d.ts
vendored
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -1436,6 +1436,10 @@ export type RequestLogListFilter = {
|
|||
status?: RequestLogStatusFilter;
|
||||
};
|
||||
|
||||
export type RequestLogDetailRequest = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
export type RequestLogBody = ProxyNetworkBody;
|
||||
|
||||
export type RequestLogRetryAttempt = {
|
||||
|
|
|
|||
166
src/shared/default-config.ts
Normal file
166
src/shared/default-config.ts
Normal 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: []
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
111
tests/bot-gateway-env.test.mjs
Normal file
111
tests/bot-gateway-env.test.mjs
Normal 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
126
tests/deep-link.test.mjs
Normal 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" });
|
||||
});
|
||||
61
tests/model-catalog-file.test.mjs
Normal file
61
tests/model-catalog-file.test.mjs
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
104
tests/profile-launch-core.test.mjs
Normal file
104
tests/profile-launch-core.test.mjs
Normal 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/);
|
||||
});
|
||||
83
tests/provider-preset-utils.test.mjs
Normal file
83
tests/provider-preset-utils.test.mjs
Normal 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/
|
||||
);
|
||||
});
|
||||
40
tests/provider-url.test.mjs
Normal file
40
tests/provider-url.test.mjs
Normal 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"
|
||||
);
|
||||
});
|
||||
275
tests/request-log-store.test.mjs
Normal file
275
tests/request-log-store.test.mjs
Normal 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 });
|
||||
}
|
||||
});
|
||||
34
tests/usage-activity.test.mjs
Normal file
34
tests/usage-activity.test.mjs
Normal 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");
|
||||
});
|
||||
56
tests/usage-normalization.test.mjs
Normal file
56
tests/usage-normalization.test.mjs
Normal 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
105
tests/usage-store.test.mjs
Normal 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 });
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue