From a784eba8370730e45b173a74037d5c08c4703d60 Mon Sep 17 00:00:00 2001 From: musi Date: Tue, 30 Jun 2026 09:19:53 +0800 Subject: [PATCH] Document SQLite config migration and add test script --- README.md | 8 +- README_zh.md | 8 +- build/run-tests.mjs | 33 +++ build/test.mjs | 44 +++ docs/src/content/docs/en/configuration.md | 4 +- .../en/configuration/configuration-file.md | 15 +- .../docs/en/configuration/extensions.md | 4 +- docs/src/content/docs/en/index.md | 2 +- docs/src/content/docs/zh/configuration.md | 4 +- .../docs/zh/configuration/config-file.md | 15 +- .../docs/zh/configuration/extensions.md | 4 +- docs/src/content/docs/zh/index.md | 2 +- docs/src/i18n/content.ts | 8 +- docs/src/pages/configuration/[slug].astro | 2 +- docs/src/pages/en/configuration/[slug].astro | 2 +- package.json | 1 + src/main/config.ts | 151 +--------- src/main/ipc.ts | 3 +- src/main/main-app.ts | 5 +- src/main/model-catalog-file.ts | 45 +++ src/main/preload.ts | 3 + src/main/provider-model-catalog.ts | 29 +- src/main/provider-probe.ts | 53 +++- src/main/request-log-store.ts | 68 ++++- src/main/tray-controller.ts | 26 +- src/main/usage-store.ts | 274 ++++++++++++++--- src/main/web-client-bridge.ts | 1 + src/main/web-management-server.ts | 4 +- src/renderer/pages/home/App.tsx | 33 +-- .../pages/home/components/network-logs.tsx | 59 +++- src/renderer/pages/home/shared/fallbacks.ts | 157 +--------- src/renderer/pages/home/shared/polling.ts | 53 ++++ src/renderer/types/electron.d.ts | 3 + src/server/gateway/model-catalog.ts | 29 +- src/shared/app.ts | 4 + src/shared/default-config.ts | 166 +++++++++++ src/shared/ipc-channels.ts | 1 + tests/bot-gateway-env.test.mjs | 111 +++++++ tests/deep-link.test.mjs | 126 ++++++++ tests/model-catalog-file.test.mjs | 61 ++++ tests/profile-launch-core.test.mjs | 104 +++++++ tests/provider-preset-utils.test.mjs | 83 ++++++ tests/provider-url.test.mjs | 40 +++ tests/request-log-store.test.mjs | 275 ++++++++++++++++++ tests/usage-activity.test.mjs | 34 +++ tests/usage-normalization.test.mjs | 56 ++++ tests/usage-store.test.mjs | 105 +++++++ 47 files changed, 1858 insertions(+), 460 deletions(-) create mode 100644 build/run-tests.mjs create mode 100644 build/test.mjs create mode 100644 src/main/model-catalog-file.ts create mode 100644 src/renderer/pages/home/shared/polling.ts create mode 100644 src/shared/default-config.ts create mode 100644 tests/bot-gateway-env.test.mjs create mode 100644 tests/deep-link.test.mjs create mode 100644 tests/model-catalog-file.test.mjs create mode 100644 tests/profile-launch-core.test.mjs create mode 100644 tests/provider-preset-utils.test.mjs create mode 100644 tests/provider-url.test.mjs create mode 100644 tests/request-log-store.test.mjs create mode 100644 tests/usage-activity.test.mjs create mode 100644 tests/usage-normalization.test.mjs create mode 100644 tests/usage-store.test.mjs diff --git a/README.md b/README.md index 941b7ad5..255c5565 100644 --- a/README.md +++ b/README.md @@ -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_.exe` - Linux: `Claude Code Router_.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: diff --git a/README_zh.md b/README_zh.md index b450f389..de8ee689 100644 --- a/README_zh.md +++ b/README_zh.md @@ -67,9 +67,11 @@ Web 管理端默认监听 `http://127.0.0.1:3458`。可以用 `ccr start --host - Windows:`Claude Code Router_.exe` - Linux:`Claude Code Router_.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 会启动两个本地服务: diff --git a/build/run-tests.mjs b/build/run-tests.mjs new file mode 100644 index 00000000..086d390c --- /dev/null +++ b/build/run-tests.mjs @@ -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); +}); diff --git a/build/test.mjs b/build/test.mjs new file mode 100644 index 00000000..09e17ddb --- /dev/null +++ b/build/test.mjs @@ -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(); +} diff --git a/docs/src/content/docs/en/configuration.md b/docs/src/content/docs/en/configuration.md index 7f6c4e79..dd828f84 100644 --- a/docs/src/content/docs/en/configuration.md +++ b/docs/src/content/docs/en/configuration.md @@ -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 diff --git a/docs/src/content/docs/en/configuration/configuration-file.md b/docs/src/content/docs/en/configuration/configuration-file.md index a4f0f4fd..5543fc14 100644 --- a/docs/src/content/docs/en/configuration/configuration-file.md +++ b/docs/src/content/docs/en/configuration/configuration-file.md @@ -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. diff --git a/docs/src/content/docs/en/configuration/extensions.md b/docs/src/content/docs/en/configuration/extensions.md index ceccdeaf..a5e79126 100644 --- a/docs/src/content/docs/en/configuration/extensions.md +++ b/docs/src/content/docs/en/configuration/extensions.md @@ -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: diff --git a/docs/src/content/docs/en/index.md b/docs/src/content/docs/en/index.md index 0091b7d3..a6284322 100644 --- a/docs/src/content/docs/en/index.md +++ b/docs/src/content/docs/en/index.md @@ -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. diff --git a/docs/src/content/docs/zh/configuration.md b/docs/src/content/docs/zh/configuration.md index 26b0a781..8cc3c8e2 100644 --- a/docs/src/content/docs/zh/configuration.md +++ b/docs/src/content/docs/zh/configuration.md @@ -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 配置数据库位置 | ## 内容关系 diff --git a/docs/src/content/docs/zh/configuration/config-file.md b/docs/src/content/docs/zh/configuration/config-file.md index c60f8088..fe346779 100644 --- a/docs/src/content/docs/zh/configuration/config-file.md +++ b/docs/src/content/docs/zh/configuration/config-file.md @@ -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` 辅助文件。 diff --git a/docs/src/content/docs/zh/configuration/extensions.md b/docs/src/content/docs/zh/configuration/extensions.md index 5fdf8524..56dce99f 100644 --- a/docs/src/content/docs/zh/configuration/extensions.md +++ b/docs/src/content/docs/zh/configuration/extensions.md @@ -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/)。 本地目录选择器会按顺序识别这些入口信息: diff --git a/docs/src/content/docs/zh/index.md b/docs/src/content/docs/zh/index.md index 698cb077..00aef301 100644 --- a/docs/src/content/docs/zh/index.md +++ b/docs/src/content/docs/zh/index.md @@ -26,7 +26,7 @@ CCR 默认监听本机地址 `http://localhost:8080`。Agent 只要指向这个 | --- | --- | | [文档](./) | 产品定位、架构概览、阅读路径 | | [快速开始](guides/) | 从安装、接供应商,到接入 Agent 的上手流程 | -| [详细配置](configuration/) | 供应商、路由、配置、Fusion、Bot 和配置文件位置 | +| [详细配置](configuration/) | 供应商、路由、配置、Fusion、Bot 和配置数据库位置 | | [Q&A](troubleshooting/) | 请求日志、观测面板和常见问题 | Bot 平台教程是「详细配置」分类下的子页面,每个平台有独立页面,方便逐步补齐平台后台字段、回调 URL、签名和 FAQ。 diff --git a/docs/src/i18n/content.ts b/docs/src/i18n/content.ts index 5148abf6..be1739bc 100644 --- a/docs/src/i18n/content.ts +++ b/docs/src/i18n/content.ts @@ -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: {}, }, diff --git a/docs/src/pages/configuration/[slug].astro b/docs/src/pages/configuration/[slug].astro index 5e056e90..92538646 100644 --- a/docs/src/pages/configuration/[slug].astro +++ b/docs/src/pages/configuration/[slug].astro @@ -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]) => { diff --git a/docs/src/pages/en/configuration/[slug].astro b/docs/src/pages/en/configuration/[slug].astro index 3c7bd972..4cd93246 100644 --- a/docs/src/pages/en/configuration/[slug].astro +++ b/docs/src/pages/en/configuration/[slug].astro @@ -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]) => { diff --git a/package.json b/package.json index cc38e7cd..0f930d86 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/main/config.ts b/src/main/config.ts index 15de3cf4..efaca140 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -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; }; -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); diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 94b24b07..feb36f52 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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)); diff --git a/src/main/main-app.ts b/src/main/main-app.ts index 72f63d5e..3fedf93b 100644 --- a/src/main/main-app.ts +++ b/src/main/main-app.ts @@ -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"); }); }); diff --git a/src/main/model-catalog-file.ts b/src/main/model-catalog-file.ts new file mode 100644 index 00000000..68b5c86b --- /dev/null +++ b/src/main/model-catalog-file.ts @@ -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[] { + const seen = new Set(); + 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; +} diff --git a/src/main/preload.ts b/src/main/preload.ts index c7d117d1..53677e9b 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -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, getProxyNetworkCaptures: () => invoke(IPC_CHANNELS.appGetProxyNetworkCaptures) as Promise, getProxyStatus: () => invoke(IPC_CHANNELS.appGetProxyStatus) as Promise, + getRequestLogDetail: (request: RequestLogDetailRequest) => invoke(IPC_CHANNELS.appGetRequestLogDetail, request) as Promise, getRequestLogs: (filter?: RequestLogListFilter) => invoke(IPC_CHANNELS.appGetRequestLogs, filter) as Promise, getUpdateStatus: () => invoke(IPC_CHANNELS.appGetUpdateStatus) as Promise, getUsageStats: (range?: UsageStatsRange, filter?: UsageStatsFilter) => invoke(IPC_CHANNELS.appGetUsageStats, range, filter) as Promise, diff --git a/src/main/provider-model-catalog.ts b/src/main/provider-model-catalog.ts index 70e9b489..87519c6f 100644 --- a/src/main/provider-model-catalog.ts +++ b/src/main/provider-model-catalog.ts @@ -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(); const models = isRecord(payload) && Array.isArray(payload.models) ? payload.models : []; diff --git a/src/main/provider-probe.ts b/src/main/provider-probe.ts index 48032f66..91ed2d43 100644 --- a/src/main/provider-probe.ts +++ b/src/main/provider-probe.ts @@ -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 { 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 { : {}; } +function headersForProtocol(protocol: GatewayProviderProtocol, apiKey: string | undefined): Record { + 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 { 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(); + 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); } diff --git a/src/main/request-log-store.ts b/src/main/request-log-store.ts index 1ebfea55..25c735f5 100644 --- a/src/main/request-log-store.ts +++ b/src/main/request-log-store.ts @@ -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; 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 { @@ -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 { + const database = await this.getDatabase(); + const requestLogId = normalizeCount(request.id); + if (requestLogId <= 0) { + return undefined; + } + return readRequestLogById(database, requestLogId); + } + async analyze(filter: AgentAnalysisFilter = {}): Promise { 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 { @@ -840,6 +877,15 @@ export async function getRequestLogs(filter?: RequestLogListFilter): Promise { + 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 { 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 { diff --git a/src/main/tray-controller.ts b/src/main/tray-controller.ts index 0bcf23f6..11d5b1ec 100644 --- a/src/main/tray-controller.ts +++ b/src/main/tray-controller.ts @@ -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 { 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(); diff --git a/src/main/usage-store.ts b/src/main/usage-store.ts index 351d8c51..45cd1f93 100644 --- a/src/main/usage-store.ts +++ b/src/main/usage-store.ts @@ -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; @@ -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 { 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 { @@ -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 { @@ -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): 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[] { + 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 | 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(); diff --git a/src/main/web-client-bridge.ts b/src/main/web-client-bridge.ts index a9096f26..e5bf48c7 100644 --- a/src/main/web-client-bridge.ts +++ b/src/main/web-client-bridge.ts @@ -112,6 +112,7 @@ window.ccr = { getProxyCertificateStatus: () => rpc("getProxyCertificateStatus") as ReturnType["getProxyCertificateStatus"]>, getProxyNetworkCaptures: () => rpc("getProxyNetworkCaptures") as ReturnType["getProxyNetworkCaptures"]>, getProxyStatus: () => rpc("getProxyStatus") as ReturnType["getProxyStatus"]>, + getRequestLogDetail: (request) => rpc("getRequestLogDetail", [request]) as ReturnType["getRequestLogDetail"]>, getRequestLogs: (filter) => rpc("getRequestLogs", [filter]) as ReturnType["getRequestLogs"]>, getUpdateStatus: () => rpc("getUpdateStatus") as ReturnType["getUpdateStatus"]>, getUsageStats: (range, filter) => rpc("getUsageStats", [range, filter]) as ReturnType["getUsageStats"]>, diff --git a/src/main/web-management-server.ts b/src/main/web-management-server.ts index 4af4773f..40d7f2b6 100644 --- a/src/main/web-management-server.ts +++ b/src/main/web-management-server.ts @@ -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 = { 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), diff --git a/src/renderer/pages/home/App.tsx b/src/renderer/pages/home/App.tsx index df5de237..633aea5c 100644 --- a/src/renderer/pages/home/App.tsx +++ b/src/renderer/pages/home/App.tsx @@ -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]; diff --git a/src/renderer/pages/home/components/network-logs.tsx b/src/renderer/pages/home/components/network-logs.tsx index 35ee0dad..da6e0600 100644 --- a/src/renderer/pages/home/components/network-logs.tsx +++ b/src/renderer/pages/home/components/network-logs.tsx @@ -284,6 +284,9 @@ export function LogsView({ }) { const t = useAppText(); const [expandedId, setExpandedId] = useState(); + const [detailById, setDetailById] = useState>({}); + const [detailErrorById, setDetailErrorById] = useState>({}); + const [detailLoadingId, setDetailLoadingId] = useState(); 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) => ( {tokenSummary}
{formatDuration(item.durationMs)}
- {expanded ? : null} + {expanded ? : null} ); }); -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 }) { {entry.retryAttempts.length > 0 ? : null} + {detailLoading || detailError ? ( +
+ {detailError || t("Loading full payload...")} +
+ ) : null}
Promise | 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); + }; +} diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index 4935b748..3ac4e52e 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -57,6 +57,8 @@ import type { ProxyCertificateStatus, ProxyNetworkSnapshot, ProxyStatus, + RequestLogDetailRequest, + RequestLogEntry, RequestLogListFilter, RequestLogPage, UsageStatsFilter, @@ -95,6 +97,7 @@ declare global { getProxyCertificateStatus: () => Promise; getProxyNetworkCaptures: () => Promise; getProxyStatus: () => Promise; + getRequestLogDetail: (request: RequestLogDetailRequest) => Promise; getRequestLogs: (filter?: RequestLogListFilter) => Promise; getUpdateStatus: () => Promise; getUsageStats: (range?: UsageStatsRange, filter?: UsageStatsFilter) => Promise; diff --git a/src/server/gateway/model-catalog.ts b/src/server/gateway/model-catalog.ts index a176913a..5220e95f 100644 --- a/src/server/gateway/model-catalog.ts +++ b/src/server/gateway/model-catalog.ts @@ -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(); const byModelKey = new Map(); diff --git a/src/shared/app.ts b/src/shared/app.ts index 5d2ea3a7..2e838285 100644 --- a/src/shared/app.ts +++ b/src/shared/app.ts @@ -1436,6 +1436,10 @@ export type RequestLogListFilter = { status?: RequestLogStatusFilter; }; +export type RequestLogDetailRequest = { + id: number; +}; + export type RequestLogBody = ProxyNetworkBody; export type RequestLogRetryAttempt = { diff --git a/src/shared/default-config.ts b/src/shared/default-config.ts new file mode 100644 index 00000000..1e01a9b3 --- /dev/null +++ b/src/shared/default-config.ts @@ -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: [] + }; +} diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index f88003a9..1bfe2969 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -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", diff --git a/tests/bot-gateway-env.test.mjs b/tests/bot-gateway-env.test.mjs new file mode 100644 index 00000000..ac3e3841 --- /dev/null +++ b/tests/bot-gateway-env.test.mjs @@ -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"); +}); diff --git a/tests/deep-link.test.mjs b/tests/deep-link.test.mjs new file mode 100644 index 00000000..2a9c63b3 --- /dev/null +++ b/tests/deep-link.test.mjs @@ -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" }); +}); diff --git a/tests/model-catalog-file.test.mjs b/tests/model-catalog-file.test.mjs new file mode 100644 index 00000000..8a93289d --- /dev/null +++ b/tests/model-catalog-file.test.mjs @@ -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; + } + } +}); diff --git a/tests/profile-launch-core.test.mjs b/tests/profile-launch-core.test.mjs new file mode 100644 index 00000000..2659aa4a --- /dev/null +++ b/tests/profile-launch-core.test.mjs @@ -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/); +}); diff --git a/tests/provider-preset-utils.test.mjs b/tests/provider-preset-utils.test.mjs new file mode 100644 index 00000000..7ee4b505 --- /dev/null +++ b/tests/provider-preset-utils.test.mjs @@ -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/ + ); +}); diff --git a/tests/provider-url.test.mjs b/tests/provider-url.test.mjs new file mode 100644 index 00000000..b7768762 --- /dev/null +++ b/tests/provider-url.test.mjs @@ -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" + ); +}); diff --git a/tests/request-log-store.test.mjs b/tests/request-log-store.test.mjs new file mode 100644 index 00000000..30a9cb1a --- /dev/null +++ b/tests/request-log-store.test.mjs @@ -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 }); + } +}); diff --git a/tests/usage-activity.test.mjs b/tests/usage-activity.test.mjs new file mode 100644 index 00000000..a3c0e2a3 --- /dev/null +++ b/tests/usage-activity.test.mjs @@ -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"); +}); diff --git a/tests/usage-normalization.test.mjs b/tests/usage-normalization.test.mjs new file mode 100644 index 00000000..65dfc03d --- /dev/null +++ b/tests/usage-normalization.test.mjs @@ -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 + ); +}); diff --git a/tests/usage-store.test.mjs b/tests/usage-store.test.mjs new file mode 100644 index 00000000..0cae4ff1 --- /dev/null +++ b/tests/usage-store.test.mjs @@ -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 }); + } +});