diff --git a/.github/pr-assets/issue-1118/index.html b/.github/pr-assets/issue-1118/index.html new file mode 100644 index 00000000..eb4c4875 --- /dev/null +++ b/.github/pr-assets/issue-1118/index.html @@ -0,0 +1,191 @@ + + + + + + Issue 1118 Evidence - AI Providers model rules + + + +
+
+

Issue 1118 Evidence

+

+ AI Providers route should tolerate plain OpenAI-compatible model rules + where an alias is omitted. +

+
+ +
+
+

Before: installed dev build

+

+ The current installed CLI dashboard API crashes while reading the + local AI provider config. +

+
HTTP 500
+
before: HTTP 500
+{
+  "error": "Cannot read properties of undefined (reading 'trim')"
+}
+
+ +
+

After: patched worktree build

+

+ The same local config now loads, and omitted aliases normalize to an + empty string. +

+
HTTP 200
+
after: HTTP 200
+{
+  "familyCount": 5,
+  "openaiCompatibilityEntryCount": 1,
+  "normalizedModelAliases": [
+    {
+      "hasName": true,
+      "alias": ""
+    }
+  ]
+}
+
+
+ +
+

Validation

+ +
+
+ + diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index d3fb654c..56dc29c6 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -10,9 +10,33 @@ concurrency: cancel-in-progress: false jobs: + guard: + if: ${{ github.ref == 'refs/heads/dev' }} + runs-on: [self-hosted, linux, x64] + outputs: + should_release: ${{ steps.release-guard.outputs.should_release }} + + steps: + - name: Check release recursion guard + id: release-guard + shell: bash + run: | + SHOULD_RELEASE=true + if [[ "$GITHUB_EVENT_NAME" == "push" ]]; then + HEAD_SUBJECT=$(jq -r '.head_commit.message // "" | split("\n")[0]' "$GITHUB_EVENT_PATH") + case "$HEAD_SUBJECT" in + "chore(release): "*) + SHOULD_RELEASE=false + echo "Skipping generated dev release commit: $HEAD_SUBJECT" + ;; + esac + fi + echo "should_release=$SHOULD_RELEASE" >> "$GITHUB_OUTPUT" + release: - if: ${{ github.ref == 'refs/heads/dev' && (github.event_name != 'push' || !contains(github.event.head_commit.message, '[skip ci]')) }} - runs-on: ubuntu-latest + needs: guard + if: ${{ needs.guard.outputs.should_release == 'true' }} + runs-on: [self-hosted, linux, x64] permissions: contents: write diff --git a/docs/code-standards.md b/docs/code-standards.md index de84eebe..6c71122f 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -201,7 +201,7 @@ Resolves which adapter to use via `resolveTargetType()`: 2. explicit runtime entrypoint (`CCS_INTERNAL_ENTRY_TARGET`): - ccs-droid / ccsd → droid - ccs-codex / ccsx → codex - - ccsxp → codex (provider shortcut) + - ccsxp → codex (native cliproxy shortcut) ↓ 3. argv[0] detection (runtime alias pattern / custom alias map): - ccs-droid → droid diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index de9627cb..5d203769 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -1,8 +1,8 @@ # CCS Codebase Summary -Last Updated: 2026-04-07 +Last Updated: 2026-04-26 -Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, account-context validation hardening, Official Claude Channels runtime support, and native Codex runtime target support. +Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, account-context validation hardening, Official Claude Channels runtime support, native Codex runtime target support, native Codex/Droid usage collectors, and models.dev-backed model pricing metadata. ## Repository Structure @@ -38,7 +38,7 @@ src/ ├── bin/ # Dedicated runtime entrypoints │ ├── droid-runtime.ts # Forces droid target for ccs-droid / ccsd package bins │ ├── codex-runtime.ts # Forces codex target for ccs-codex / ccsx package bins -│ └── ccsxp-runtime.ts # Forces built-in codex profile + codex target for ccsxp +│ └── ccsxp-runtime.ts # Forces codex target + native cliproxy override for ccsxp ├── types/ # TypeScript type definitions │ ├── index.ts # Barrel export (aggregates all types) │ ├── cli.ts # CLI types (ParsedArgs, ExitCode) @@ -212,14 +212,20 @@ src/ │ └── [more routes...] ├── health/ # Health check system │ └── index.ts - ├── usage/ # Usage analytics module + ├── usage/ # Usage analytics module (default Claude, CCS instances, native Codex/Droid, CLIProxy snapshots) │ ├── index.ts │ ├── handlers.ts # Request handlers (633 lines) │ ├── aggregator.ts # Data aggregation (538 lines) + │ ├── codex-native-usage-collector.ts # Native Codex rollout JSONL collector + │ ├── droid-native-usage-collector.ts # Native Droid SQLite collector │ └── data-aggregator.ts + ├── models-dev/ # Cached models.dev metadata/pricing registry integration + │ ├── registry-cache.ts + │ ├── pricing-resolver.ts + │ └── types.ts ├── services/ # Shared services │ └── index.ts - └── model-pricing.ts # Model cost definitions (676 lines) + └── model-pricing.ts # Static pricing fallback + models.dev resolver ``` ### Module Categories @@ -267,7 +273,7 @@ src/ ### Native Codex Runtime Target - Dedicated runtime entrypoints: `ccs-codex` and `ccsx` resolve through `src/bin/codex-runtime.ts`, while `ccsxp` resolves through `src/bin/ccsxp-runtime.ts`; all three set `CCS_INTERNAL_ENTRY_TARGET=codex` before delegating to `src/targets/target-resolver.ts`. -- Provider shortcut behavior: `ccsxp` also strips user-supplied `--target` overrides and rewrites argv to `ccs codex --target codex ...`, so it always lands on the built-in Codex-via-CLIProxy route. +- Provider shortcut behavior: `ccsxp` strips user-supplied `--target` overrides and prepends `--config model_provider="cliproxy"` so it behaves like native Codex plus the CLIProxy provider recipe. The stricter CCS-managed bridge remains available explicitly through `ccs codex --target codex`. It pins `CODEX_HOME` to native `~/.codex` by default so inherited launcher state does not send history/config writes to a nonstandard Codex root; `CCSXP_CODEX_HOME` is the explicit override. - `argv[0]` alias mapping still exists in `src/targets/target-resolver.ts` for same-binary/custom alias scenarios, but the built-in npm bins above do not depend on that map at runtime. - Metadata boundary: `src/targets/target-metadata.ts` keeps Codex runtime-only in v1, so persisted default targets remain `claude | droid`. - Compatibility guardrails: `src/targets/target-runtime-compatibility.ts` centralizes which profile types can execute on Codex. @@ -548,7 +554,7 @@ ui/src/ | File | Lines | Status | |------|-------|--------| -| model-pricing.ts | 676 | Data file - acceptable | +| model-pricing.ts | 920 | Static pricing fallback and resolver entrypoint | | glmt-proxy.ts | 675 | Legacy internal compatibility path - acceptable for now | | cliproxy-executor.ts | 666 | Core logic - acceptable | | cliproxy-command.ts | 634 | Could split if needed | diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index a07dcae8..5f9265c8 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-04-21 +Last Updated: 2026-04-28 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -41,6 +41,8 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-04-28**: **#1123** CLIProxy quota failover now uses the dashboard/manual pause mechanism for all quota-visible OAuth providers with CCS quota fetchers: Antigravity, Claude, Codex, Gemini CLI, and GitHub Copilot. When a healthy fallback exists, CCS moves the exhausted account token out of the live `auth/` folder into `auth-paused/`, marks the account paused for dashboard visibility, persists the cooldown for auto-resume, and still avoids self-pausing the last usable account. +- **2026-04-28**: **#1115** CCS now exposes upstream CLIProxy session affinity as a first-class local managed setting. Users can inspect and toggle local `session-affinity` plus TTL from `ccs cliproxy routing affinity`, from the `/cliproxy` dashboard routing card, and through the local dashboard API. The generated local CLIProxy config now persists `routing.session-affinity` and `routing.session-affinity-ttl`, help/copy explains that CLIProxy prefers explicit session or thread identifiers before falling back to prompt-history hashing, and remote session-affinity management stays explicitly unsupported until upstream management APIs expose more than `routing.strategy`. - **2026-04-24**: **#1065** Local CLIProxy Plus is available again as an explicit opt-in backend through the community-maintained `kaitranntt/CLIProxyAPIPlus` fork. CCS keeps `original` as the default backend, no longer downgrades saved `backend: plus` configs to `original`, updates Plus release lookups to the maintained fork, and documents Plus as a targeted path for plus-only providers. - **2026-04-21**: CLIProxy quota failover now quarantines exhausted Claude and Antigravity accounts out of live rotation when a healthy fallback exists. CCS persists those quota-triggered pauses across launches, automatically resumes them after the configured cooldown window, and deliberately avoids auto-pausing the last available account so single-account setups still degrade gracefully instead of hard-locking themselves. - **2026-04-20**: **#1051** Browser automation now defaults safe-off for new installs and upgrades that do not already carry explicit browser settings. CCS changes both Claude Browser Attach and Codex Browser Tools to start with `enabled: false` and `policy: manual`, normalizes missing browser policies on upgrade back to `manual`, preserves explicit existing enablement, and updates status/help/docs so browser tooling is never implied to auto-expose unless users opt in. @@ -80,6 +82,7 @@ All major modularization work is complete. The codebase evolved from monolithic - **#724**: Codex startup is now free-plan safe. CCS defaults new Codex sessions to a cross-plan model and uses runtime fallback handling for unsupported paid-only models without rewriting the saved dashboard settings. - **#737**: Dashboard model pickers in Cursor, Copilot, and CLIProxy now use a searchable combobox with autofocus and explicit no-results states for large model catalogs. - **#736**: `ccs config` now supports explicit dashboard bind hosts via `--host`, and surfaces remote-access warnings plus reachable URLs when the effective bind is non-loopback. +- **#1121**: Usage analytics pricing now refreshes cached models.dev metadata before cost derivation, keeps CCS static pricing as the offline fallback, and carries provider identity through CLIProxy/native runtime breakdowns so subscription-backed providers do not inherit paid API pricing. ### Maintainability Hardening Kickoff diff --git a/docs/system-architecture/index.md b/docs/system-architecture/index.md index 0201ec3e..475718ba 100644 --- a/docs/system-architecture/index.md +++ b/docs/system-architecture/index.md @@ -101,7 +101,7 @@ ccs-droid → Target: droid (explicit alias) ccsd → Target: droid (legacy shortcut) ccs-codex → Target: codex (explicit alias) ccsx → Target: codex (short alias) -ccsxp → Target: codex (provider shortcut; rewrites argv to `ccs codex --target codex`) +ccsxp → Target: codex (native cliproxy shortcut; prepends `--config model_provider="cliproxy"`) ``` For details on the adapter architecture, see [Target Adapters](./target-adapters.md). diff --git a/docs/system-architecture/provider-flows.md b/docs/system-architecture/provider-flows.md index 9caf91c7..e408c2c1 100644 --- a/docs/system-architecture/provider-flows.md +++ b/docs/system-architecture/provider-flows.md @@ -261,7 +261,7 @@ async function checkRemoteProxyHealth(config: ResolvedProxyConfig): Promise Check isPaused flag --> Skip if paused | | | +---> Fetch quota from provider API - | | - Gemini: /models endpoint - | | - Codex: /api/v1/account - | | - Kiro: /api/usage + | | - Antigravity: fetchAvailableModels + | | - Claude: policy limits endpoint + | | - Codex: ChatGPT usage windows + | | - Gemini CLI: Code Assist quota buckets + | | - GitHub Copilot: copilot_internal/user snapshots | | | +---> Detect tier (free/paid/unknown) | | diff --git a/docs/system-architecture/target-adapters.md b/docs/system-architecture/target-adapters.md index 10a37254..aa6349d6 100644 --- a/docs/system-architecture/target-adapters.md +++ b/docs/system-architecture/target-adapters.md @@ -88,7 +88,7 @@ CCS resolves which adapter to use via priority-ordered checks: 2. Explicit runtime entrypoint (`CCS_INTERNAL_ENTRY_TARGET`) — dedicated bin shims └─ ccs-droid / ccsd → droid └─ ccs-codex / ccsx → codex - └─ ccsxp → codex, then rewrites argv to `ccs codex --target codex ...` + └─ ccsxp → codex, then prepends `--config model_provider="cliproxy"` 3. argv[0] detection (runtime alias pattern) — binary name mapping for same-binary/custom aliases └─ ccs-droid (explicit alias) → droid @@ -514,7 +514,8 @@ ccsx ccsxp → dist/bin/ccsxp-runtime.js → CCS_INTERNAL_ENTRY_TARGET=codex -→ injects built-in codex profile shortcut +→ injects native `model_provider="cliproxy"` override +→ pins CODEX_HOME to native `~/.codex` unless `CCSXP_CODEX_HOME` is set ``` If a user launches CCS through a custom shim instead of the built-in package bins, target diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index 1cc92e38..a4234c78 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -298,7 +298,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, }, additionalProperties: false, @@ -314,7 +315,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, }, additionalProperties: false, @@ -330,7 +332,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, }, additionalProperties: false, @@ -346,7 +349,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, url: { type: 'string', @@ -367,7 +371,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, selector: { type: 'string', @@ -376,11 +381,13 @@ function getTools() { nth: { type: 'integer', minimum: 0, - description: 'Optional zero-based match index for selectors returning multiple elements.', + description: + 'Optional zero-based match index for selectors returning multiple elements.', }, frameSelector: { type: 'string', - description: 'Optional CSS selector for an iframe whose document should be used as the query root.', + description: + 'Optional CSS selector for an iframe whose document should be used as the query root.', }, pierceShadow: { type: 'boolean', @@ -388,11 +395,13 @@ function getTools() { }, offsetX: { type: 'number', - description: "Optional horizontal offset in CSS pixels from the target element's left edge.", + description: + "Optional horizontal offset in CSS pixels from the target element's left edge.", }, offsetY: { type: 'number', - description: "Optional vertical offset in CSS pixels from the target element's top edge.", + description: + "Optional vertical offset in CSS pixels from the target element's top edge.", }, button: { type: 'string', @@ -419,7 +428,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, selector: { type: 'string', @@ -448,7 +458,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, key: { type: 'string', @@ -482,7 +493,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, selector: { type: 'string', @@ -490,7 +502,8 @@ function getTools() { }, frameSelector: { type: 'string', - description: 'Optional CSS selector for an iframe whose document should be used as the query root.', + description: + 'Optional CSS selector for an iframe whose document should be used as the query root.', }, pierceShadow: { type: 'boolean', @@ -516,7 +529,8 @@ function getTools() { }, { name: TOOL_SELECT_PAGE, - description: 'Select the current page target by page index or page id for subsequent browser tool calls.', + description: + 'Select the current page target by page index or page id for subsequent browser tool calls.', inputSchema: { type: 'object', properties: { @@ -539,7 +553,8 @@ function getTools() { }, { name: TOOL_CLOSE_PAGE, - description: 'Close a browser page target by page index or page id. Defaults to the selected page.', + description: + 'Close a browser page target by page index or page id. Defaults to the selected page.', inputSchema: { type: 'object', properties: { @@ -769,7 +784,8 @@ function getTools() { }, { name: TOOL_START_RECORDING, - description: 'Start recording real browser interactions on the selected page and store them as structured steps.', + description: + 'Start recording real browser interactions on the selected page and store them as structured steps.', inputSchema: { type: 'object', properties: { @@ -781,7 +797,8 @@ function getTools() { }, { name: TOOL_STOP_RECORDING, - description: 'Stop the active browser recording session and keep the recorded result in session-local state.', + description: + 'Stop the active browser recording session and keep the recorded result in session-local state.', inputSchema: { type: 'object', properties: {}, @@ -808,7 +825,8 @@ function getTools() { }, { name: TOOL_START_REPLAY, - description: 'Start replaying a sequence of structured Browser MCP steps on the selected page.', + description: + 'Start replaying a sequence of structured Browser MCP steps on the selected page.', inputSchema: { type: 'object', properties: { @@ -840,7 +858,8 @@ function getTools() { }, { name: TOOL_START_ORCHESTRATION, - description: 'Start an orchestration session that runs fixed browser workflow blocks on the selected page.', + description: + 'Start an orchestration session that runs fixed browser workflow blocks on the selected page.', inputSchema: { type: 'object', properties: { @@ -854,7 +873,8 @@ function getTools() { }, { name: TOOL_GET_ORCHESTRATION, - description: 'Read the current orchestration status and result summary from session-local state.', + description: + 'Read the current orchestration status and result summary from session-local state.', inputSchema: { type: 'object', properties: {}, @@ -863,7 +883,8 @@ function getTools() { }, { name: TOOL_CANCEL_ORCHESTRATION, - description: 'Cancel the active orchestration session and keep the summary in session-local state.', + description: + 'Cancel the active orchestration session and keep the summary in session-local state.', inputSchema: { type: 'object', properties: {}, @@ -872,7 +893,8 @@ function getTools() { }, { name: TOOL_EXPORT_ARTIFACT, - description: 'Export the current recording, replay, or orchestration artifact to a local JSON file.', + description: + 'Export the current recording, replay, or orchestration artifact to a local JSON file.', inputSchema: { type: 'object', properties: { @@ -885,7 +907,8 @@ function getTools() { }, { name: TOOL_IMPORT_ARTIFACT, - description: 'Import a recording, replay, or orchestration artifact from a local JSON file into session-local state.', + description: + 'Import a recording, replay, or orchestration artifact from a local JSON file into session-local state.', inputSchema: { type: 'object', properties: { @@ -926,7 +949,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, fullPage: { type: 'boolean', @@ -946,7 +970,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, selector: { type: 'string', @@ -955,11 +980,13 @@ function getTools() { nth: { type: 'integer', minimum: 0, - description: 'Optional zero-based match index for selectors returning multiple elements.', + description: + 'Optional zero-based match index for selectors returning multiple elements.', }, frameSelector: { type: 'string', - description: 'Optional CSS selector for an iframe whose document should be used as the query root.', + description: + 'Optional CSS selector for an iframe whose document should be used as the query root.', }, pierceShadow: { type: 'boolean', @@ -994,7 +1021,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, expression: { type: 'string', @@ -1020,7 +1048,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, selector: { type: 'string', @@ -1028,7 +1057,8 @@ function getTools() { }, frameSelector: { type: 'string', - description: 'Optional CSS selector for an iframe whose document should be used as the query root.', + description: + 'Optional CSS selector for an iframe whose document should be used as the query root.', }, pierceShadow: { type: 'boolean', @@ -1049,7 +1079,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, selector: { type: 'string', @@ -1058,11 +1089,13 @@ function getTools() { nth: { type: 'integer', minimum: 0, - description: 'Optional zero-based match index for selectors returning multiple elements.', + description: + 'Optional zero-based match index for selectors returning multiple elements.', }, frameSelector: { type: 'string', - description: 'Optional CSS selector for an iframe whose document should be used as the query root.', + description: + 'Optional CSS selector for an iframe whose document should be used as the query root.', }, pierceShadow: { type: 'boolean', @@ -1088,7 +1121,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, selector: { type: 'string', @@ -1096,7 +1130,8 @@ function getTools() { }, frameSelector: { type: 'string', - description: 'Optional CSS selector for an iframe whose document should be used as the query root.', + description: + 'Optional CSS selector for an iframe whose document should be used as the query root.', }, pierceShadow: { type: 'boolean', @@ -1116,7 +1151,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, timeoutMs: { type: 'integer', @@ -1135,14 +1171,25 @@ function getTools() { ]; } -async function fetchJson(url) { - const response = await fetch(url); +async function fetchJson(url, options = undefined) { + const response = await fetch(url, options); if (!response.ok) { throw new Error(`HTTP ${response.status} for ${url}`); } return await response.json(); } +function isUsablePageTarget(target) { + if (!target || typeof target !== 'object' || target.type !== 'page') { + return false; + } + const url = typeof target.url === 'string' ? target.url : ''; + if (url.startsWith('chrome://omnibox-popup') || url.startsWith('chrome://top-chrome')) { + return false; + } + return true; +} + function getHttpUrl() { const value = process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL; if (!value) { @@ -1157,16 +1204,14 @@ async function listPageTargets() { throw new Error('Browser MCP received an invalid /json/list response.'); } - return targets - .filter((target) => target && typeof target === 'object' && target.type === 'page') - .map((target) => ({ - id: typeof target.id === 'string' ? target.id : '', - title: typeof target.title === 'string' ? target.title : '', - url: typeof target.url === 'string' ? target.url : '', - type: typeof target.type === 'string' ? target.type : 'page', - webSocketDebuggerUrl: - typeof target.webSocketDebuggerUrl === 'string' ? target.webSocketDebuggerUrl : '', - })); + return targets.filter(isUsablePageTarget).map((target) => ({ + id: typeof target.id === 'string' ? target.id : '', + title: typeof target.title === 'string' ? target.title : '', + url: typeof target.url === 'string' ? target.url : '', + type: typeof target.type === 'string' ? target.type : 'page', + webSocketDebuggerUrl: + typeof target.webSocketDebuggerUrl === 'string' ? target.webSocketDebuggerUrl : '', + })); } function parsePageIndex(toolArgs) { @@ -1314,9 +1359,13 @@ function parseOptionalHeaderMatchers(value) { } const name = requireNonEmptyString(entry.name, 'headerMatchers.name'); const valueIncludes = - entry.valueIncludes === undefined ? '' : requireNonEmptyString(entry.valueIncludes, 'headerMatchers.valueIncludes'); + entry.valueIncludes === undefined + ? '' + : requireNonEmptyString(entry.valueIncludes, 'headerMatchers.valueIncludes'); const valueRegex = - entry.valueRegex === undefined ? '' : requireNonEmptyString(entry.valueRegex, 'headerMatchers.valueRegex'); + entry.valueRegex === undefined + ? '' + : requireNonEmptyString(entry.valueRegex, 'headerMatchers.valueRegex'); if (!valueIncludes && !valueRegex) { throw new Error('headerMatchers entry must include valueIncludes or valueRegex'); } @@ -1342,7 +1391,14 @@ function validateInterceptMatcherSet({ if (urlPattern && urlRegex) { throw new Error('urlPattern and urlRegex cannot be used together'); } - if (!urlIncludes && !method && !resourceType && !urlPattern && !urlRegex && headerMatchers.length === 0) { + if ( + !urlIncludes && + !method && + !resourceType && + !urlPattern && + !urlRegex && + headerMatchers.length === 0 + ) { throw new Error('at least one matching condition is required'); } } @@ -1505,7 +1561,9 @@ function resolveTargetPage(pages, toolArgs, defaultSelectedId = selectedPageId, const selectedIndex = findPageIndexById(pages, defaultSelectedId); if (selectedIndex === -1) { if (!allowImplicitFallback && defaultSelectedId) { - throw new Error('Selected page is no longer available; specify pageIndex or pageId explicitly.'); + throw new Error( + 'Selected page is no longer available; specify pageIndex or pageId explicitly.' + ); } const fallbackPage = pages[0]; if (!fallbackPage) { @@ -1526,7 +1584,9 @@ async function getSelectedPage(toolArgs) { const pageIndex = parsePageIndex(toolArgs); const page = pages[pageIndex]; if (!page) { - throw new Error(`Browser MCP page index ${pageIndex} is out of range (found ${pages.length} pages).`); + throw new Error( + `Browser MCP page index ${pageIndex} is out of range (found ${pages.length} pages).` + ); } if (!page.webSocketDebuggerUrl) { throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); @@ -1655,7 +1715,11 @@ async function getBrowserTarget() { const browserTarget = Array.isArray(targets) ? targets.find((target) => target && typeof target === 'object' && target.type === 'browser') : null; - if (!browserTarget || typeof browserTarget.webSocketDebuggerUrl !== 'string' || !browserTarget.webSocketDebuggerUrl) { + if ( + !browserTarget || + typeof browserTarget.webSocketDebuggerUrl !== 'string' || + !browserTarget.webSocketDebuggerUrl + ) { throw new Error('browser-level download events are unavailable'); } return browserTarget; @@ -1795,11 +1859,15 @@ async function ensureBrowserDownloadSession() { receivedBytes: Number(message.params?.receivedBytes || 0), totalBytes: Number(message.params?.totalBytes || 0), savedPath: typeof message.params?.filePath === 'string' ? message.params.filePath : '', - finishedAt: status === 'completed' || status === 'canceled' ? new Date().toISOString() : '', + finishedAt: + status === 'completed' || status === 'canceled' ? new Date().toISOString() : '', }); } })(); - activityChain = activityChain.catch(() => {}).then(() => activity).catch(() => {}); + activityChain = activityChain + .catch(() => {}) + .then(() => activity) + .catch(() => {}); void activity.catch(() => {}); }); @@ -1836,7 +1904,7 @@ async function evaluateInPage(page, kind) { throw new Error(result.description || 'DevTools evaluation returned an error.'); } - return typeof result.value === 'string' ? result.value : result.value ?? ''; + return typeof result.value === 'string' ? result.value : (result.value ?? ''); } async function evaluateExpression(page, expression) { @@ -1854,7 +1922,7 @@ async function evaluateExpression(page, expression) { throw new Error(result.description || 'DevTools evaluation returned an error.'); } - return typeof result.value === 'string' ? result.value : result.value ?? ''; + return typeof result.value === 'string' ? result.value : (result.value ?? ''); } async function withPageCommandSession(page, callback) { @@ -2084,7 +2152,10 @@ function requireOptionalStringArray(value, label, allowedValues) { if (value === undefined) { return []; } - if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || item.trim() === '')) { + if ( + !Array.isArray(value) || + value.some((item) => typeof item !== 'string' || item.trim() === '') + ) { throw new Error(`${label} must be an array of non-empty strings`); } const normalized = value.map((item) => item.trim()); @@ -2282,7 +2353,10 @@ function buildScopedMatchesExpression(selector, nth, frameSelector, pierceShadow } async function getScopedDiagnostics(page, selector, nth, frameSelector, pierceShadow) { - const raw = await evaluateExpression(page, buildScopedMatchesExpression(selector, nth, frameSelector, pierceShadow)); + const raw = await evaluateExpression( + page, + buildScopedMatchesExpression(selector, nth, frameSelector, pierceShadow) + ); return JSON.parse(raw); } @@ -2573,9 +2647,7 @@ function readArtifactFile(filePath) { throw new Error('artifact file is not a regular file'); } if (stat.size > MAX_ARTIFACT_FILE_BYTES) { - throw new Error( - `artifact file exceeds maximum size of ${MAX_ARTIFACT_FILE_BYTES} bytes` - ); + throw new Error(`artifact file exceeds maximum size of ${MAX_ARTIFACT_FILE_BYTES} bytes`); } const raw = fs.readFileSync(filePath, 'utf8'); let parsed; @@ -2590,7 +2662,11 @@ function readArtifactFile(filePath) { if (parsed.version !== 1) { throw new Error('unsupported artifact version'); } - if (typeof parsed.kind !== 'string' || typeof parsed.name !== 'string' || !('payload' in parsed)) { + if ( + typeof parsed.kind !== 'string' || + typeof parsed.name !== 'string' || + !('payload' in parsed) + ) { throw new Error('invalid artifact payload'); } return parsed; @@ -2667,10 +2743,7 @@ function formatOrchestrationSummary(session) { } async function waitForSessionToSettle(task) { - await Promise.race([ - task.then(() => undefined), - sleep(SESSION_START_SETTLE_WINDOW_MS), - ]); + await Promise.race([task.then(() => undefined), sleep(SESSION_START_SETTLE_WINDOW_MS)]); } function createReplaySession(page, pageIndex, steps) { @@ -2961,7 +3034,12 @@ function validateSequenceStep(step) { } function requireCrossPageRunBlock(block) { - if (!block.args || typeof block.args !== 'object' || !block.args.run || typeof block.args.run !== 'object') { + if ( + !block.args || + typeof block.args !== 'object' || + !block.args.run || + typeof block.args.run !== 'object' + ) { throw new Error('cross-page run block is required'); } if (CROSS_PAGE_BLOCK_TYPES.has(block.args.run.type)) { @@ -3117,7 +3195,8 @@ function matchesObservedEvent(event, observed) { if (event.kind === 'download') { return ( (!event.urlIncludes || String(observed.url || '').includes(event.urlIncludes)) && - (!event.suggestedFilenameIncludes || String(observed.suggestedFilename || '').includes(event.suggestedFilenameIncludes)) + (!event.suggestedFilenameIncludes || + String(observed.suggestedFilename || '').includes(event.suggestedFilenameIncludes)) ); } return false; @@ -3128,7 +3207,10 @@ function sleep(ms) { } async function getNavigationState(page) { - const raw = await evaluateExpression(page, `JSON.stringify({ href: location.href, readyState: document.readyState })`); + const raw = await evaluateExpression( + page, + `JSON.stringify({ href: location.href, readyState: document.readyState })` + ); const parsed = JSON.parse(raw); return { href: typeof parsed.href === 'string' ? parsed.href : '', @@ -3207,10 +3289,16 @@ async function handleClick(toolArgs) { const targetIndex = nth ?? 0; const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector); const pierceShadow = toolArgs.pierceShadow === true; - const offsetX = toolArgs.offsetX === undefined ? undefined : requireFiniteNumber(toolArgs.offsetX, 'offsetX'); - const offsetY = toolArgs.offsetY === undefined ? undefined : requireFiniteNumber(toolArgs.offsetY, 'offsetY'); - const button = requireEnumString(toolArgs.button, 'button', ['left', 'middle', 'right']) || 'left'; - const clickCount = toolArgs.clickCount === undefined ? 1 : requirePositiveInteger(toolArgs.clickCount, 'clickCount'); + const offsetX = + toolArgs.offsetX === undefined ? undefined : requireFiniteNumber(toolArgs.offsetX, 'offsetX'); + const offsetY = + toolArgs.offsetY === undefined ? undefined : requireFiniteNumber(toolArgs.offsetY, 'offsetY'); + const button = + requireEnumString(toolArgs.button, 'button', ['left', 'middle', 'right']) || 'left'; + const clickCount = + toolArgs.clickCount === undefined + ? 1 + : requirePositiveInteger(toolArgs.clickCount, 'clickCount'); const expression = `(() => { const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); @@ -3445,7 +3533,8 @@ async function handleType(toolArgs) { const raw = await evaluateExpression(page, expression); const parsed = JSON.parse(raw); - const typedLength = typeof parsed.typedLength === 'number' ? parsed.typedLength : String(parsed.value || '').length; + const typedLength = + typeof parsed.typedLength === 'number' ? parsed.typedLength : String(parsed.value || '').length; return `pageIndex: ${pageIndex}\nselector: ${selector}\ntypedLength: ${typedLength}\nstatus: typed`; } @@ -3458,7 +3547,8 @@ async function handlePressKey(toolArgs) { 'Meta', 'Shift', ]); - const repeat = toolArgs.repeat === undefined ? 1 : requirePositiveInteger(toolArgs.repeat, 'repeat'); + const repeat = + toolArgs.repeat === undefined ? 1 : requirePositiveInteger(toolArgs.repeat, 'repeat'); const modifierMask = (modifiers.includes('Alt') ? 1 : 0) | (modifiers.includes('Control') ? 2 : 0) | @@ -3626,8 +3716,19 @@ async function handleScroll(toolArgs) { return lines.join('\n'); } +async function ensureDrawableViewport(page) { + const metrics = await sendCdpCommand(page, 'Page.getLayoutMetrics'); + const viewport = metrics.cssVisualViewport || metrics.visualViewport || {}; + const width = Number(viewport.clientWidth || 0); + const height = Number(viewport.clientHeight || 0); + if (width <= 0 || height <= 0) { + throw new Error('page has no drawable viewport for screenshot'); + } +} + async function handleScreenshot(toolArgs) { const { page, pageIndex } = await getSelectedPage(toolArgs); + await ensureDrawableViewport(page); const fullPage = toolArgs.fullPage === true; const response = await sendCdpCommand(page, 'Page.captureScreenshot', { format: 'png', @@ -3642,11 +3743,23 @@ async function handleScreenshot(toolArgs) { return `pageIndex: ${pageIndex}\nformat: png\nfullPage: ${fullPage ? 'true' : 'false'}\ndata: ${data}`; } -async function getElementDiagnostics(page, selector, nth, frameSelector = '', pierceShadow = false) { +async function getElementDiagnostics( + page, + selector, + nth, + frameSelector = '', + pierceShadow = false +) { return await getScopedDiagnostics(page, selector, nth, frameSelector, pierceShadow); } -async function getScrolledElementStateAt(page, selector, nth, frameSelector = '', pierceShadow = false) { +async function getScrolledElementStateAt( + page, + selector, + nth, + frameSelector = '', + pierceShadow = false +) { const expression = `(() => { const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); const nth = ${nth === undefined ? 'undefined' : String(nth)}; @@ -3860,7 +3973,9 @@ function formatQueryResponse(pageIndex, selector, nth, diagnostics, fields) { } if (diagnostics.targetMissing) { if (hasTargetSpecificQueryField(fields)) { - throw new Error(`element index ${diagnostics.targetIndex} is out of range for selector: ${selector}`); + throw new Error( + `element index ${diagnostics.targetIndex} is out of range for selector: ${selector}` + ); } for (const field of fields) { if (field === 'exists') { @@ -3897,7 +4012,11 @@ async function handleWaitFor(toolArgs) { const nth = requireOptionalNonNegativeInteger(toolArgs.nth, 'nth'); const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector); const pierceShadow = toolArgs.pierceShadow === true; - const timeoutMs = requirePositiveIntegerOrDefault(toolArgs.timeoutMs, 'timeoutMs', DEFAULT_WAIT_TIMEOUT_MS); + const timeoutMs = requirePositiveIntegerOrDefault( + toolArgs.timeoutMs, + 'timeoutMs', + DEFAULT_WAIT_TIMEOUT_MS + ); const pollIntervalMs = requirePositiveIntegerOrDefault( toolArgs.pollIntervalMs, 'pollIntervalMs', @@ -3993,7 +4112,14 @@ function interpolatePoints(sourcePoint, targetPoint, steps) { return points; } -async function resolveElementCenterPoint(page, selector, nth, frameSelector, pierceShadow, missingLabel) { +async function resolveElementCenterPoint( + page, + selector, + nth, + frameSelector, + pierceShadow, + missingLabel +) { const state = await getScrolledElementStateAt(page, selector, nth, frameSelector, pierceShadow); if (!state.exists) { throw new Error(missingLabel); @@ -4041,7 +4167,10 @@ async function handlePointerAction(toolArgs) { } if (action.type === 'pause') { - const durationMs = action.durationMs === undefined ? 0 : requireNonNegativeInteger(action.durationMs, 'durationMs'); + const durationMs = + action.durationMs === undefined + ? 0 + : requireNonNegativeInteger(action.durationMs, 'durationMs'); if (durationMs > 0) { await sleep(durationMs); } @@ -4070,7 +4199,14 @@ async function handlePointerAction(toolArgs) { pointerX = point.x; pointerY = point.y; - await dispatchMousePointerEvent(page, 'mouseMoved', pointerX, pointerY, pressedButton || 'left', Boolean(pressedButton)); + await dispatchMousePointerEvent( + page, + 'mouseMoved', + pointerX, + pointerY, + pressedButton || 'left', + Boolean(pressedButton) + ); continue; } @@ -4082,21 +4218,36 @@ async function handlePointerAction(toolArgs) { if (pressedButton) { throw new Error('pointer state error'); } - pressedButton = requireEnumString(action.button, 'button', ['left', 'middle', 'right']) || 'left'; - await dispatchMousePointerEvent(page, 'mousePressed', pointerX, pointerY, pressedButton, true); + pressedButton = + requireEnumString(action.button, 'button', ['left', 'middle', 'right']) || 'left'; + await dispatchMousePointerEvent( + page, + 'mousePressed', + pointerX, + pointerY, + pressedButton, + true + ); continue; } if (!pressedButton) { throw new Error('pointer state error'); } - const releaseButton = - requireEnumString(action.button, 'button', ['left', 'middle', 'right']) || pressedButton; - if (releaseButton !== pressedButton) { - throw new Error('pointer state error'); - } - await dispatchMousePointerEvent(page, 'mouseReleased', pointerX, pointerY, releaseButton, false); - pressedButton = null; + const releaseButton = + requireEnumString(action.button, 'button', ['left', 'middle', 'right']) || pressedButton; + if (releaseButton !== pressedButton) { + throw new Error('pointer state error'); + } + await dispatchMousePointerEvent( + page, + 'mouseReleased', + pointerX, + pointerY, + releaseButton, + false + ); + pressedButton = null; } if (pressedButton) { @@ -4161,14 +4312,24 @@ async function handleDragElement(toolArgs) { y: requireFiniteNumber(toolArgs.targetY, 'targetY'), }; - const steps = toolArgs.steps === undefined ? DEFAULT_DRAG_STEPS : requirePositiveInteger(toolArgs.steps, 'steps'); + const steps = + toolArgs.steps === undefined + ? DEFAULT_DRAG_STEPS + : requirePositiveInteger(toolArgs.steps, 'steps'); await dispatchMousePointerEvent(page, 'mouseMoved', sourcePoint.x, sourcePoint.y, 'left', false); await dispatchMousePointerEvent(page, 'mousePressed', sourcePoint.x, sourcePoint.y, 'left', true); for (const point of interpolatePoints(sourcePoint, targetPoint, steps)) { await dispatchMousePointerEvent(page, 'mouseMoved', point.x, point.y, 'left', true); } - await dispatchMousePointerEvent(page, 'mouseReleased', targetPoint.x, targetPoint.y, 'left', false); + await dispatchMousePointerEvent( + page, + 'mouseReleased', + targetPoint.x, + targetPoint.y, + 'left', + false + ); return [ `pageIndex: ${pageIndex}`, @@ -4197,7 +4358,14 @@ async function handleHover(toolArgs) { throw new Error(`element is hidden or not interactable for selector: ${selector}`); } - await dispatchMousePointerEvent(page, 'mouseMoved', state.centerPoint.x, state.centerPoint.y, 'left', false); + await dispatchMousePointerEvent( + page, + 'mouseMoved', + state.centerPoint.x, + state.centerPoint.y, + 'left', + false + ); return `pageIndex: ${pageIndex}\nselector: ${selector}${formatScopedSelectorSuffix(frameSelector, pierceShadow)}\nstatus: hovered`; } @@ -4231,6 +4399,7 @@ async function handleElementScreenshot(toolArgs) { if (state.visibleClip.width <= 0 || state.visibleClip.height <= 0) { throw new Error(`element has empty bounds for selector: ${selector}`); } + await ensureDrawableViewport(page); const response = await sendCdpCommand(page, 'Page.captureScreenshot', { format: 'png', @@ -4363,7 +4532,11 @@ async function waitForBrowserDownloadEvent(page, timeoutMs, event) { const browserTarget = Array.isArray(targets) ? targets.find((target) => target && typeof target === 'object' && target.type === 'browser') : null; - if (!browserTarget || typeof browserTarget.webSocketDebuggerUrl !== 'string' || !browserTarget.webSocketDebuggerUrl) { + if ( + !browserTarget || + typeof browserTarget.webSocketDebuggerUrl !== 'string' || + !browserTarget.webSocketDebuggerUrl + ) { throw new Error('browser-level download events are unavailable'); } @@ -4449,7 +4622,11 @@ async function waitForMatchingEvent({ page, timeoutMs, event }) { async function handleWaitForEvent(toolArgs) { const { page, pageIndex } = await getSelectedPage(toolArgs); - const timeoutMs = requirePositiveIntegerOrDefault(toolArgs.timeoutMs, 'timeoutMs', DEFAULT_WAIT_TIMEOUT_MS); + const timeoutMs = requirePositiveIntegerOrDefault( + toolArgs.timeoutMs, + 'timeoutMs', + DEFAULT_WAIT_TIMEOUT_MS + ); const event = parseEventCondition(toolArgs.event); const observed = await waitForMatchingEvent({ page, pageIndex, timeoutMs, event }); return `pageIndex: ${pageIndex}\nevent: ${event.kind}\nstatus: observed\ndetail: ${JSON.stringify(observed)}`; @@ -4628,7 +4805,9 @@ async function ensureInterceptSession(page) { return; } const paused = message.params || {}; - const matchedRule = getRulesForMatching(page.id).find((rule) => matchesInterceptRule(rule, paused)); + const matchedRule = getRulesForMatching(page.id).find((rule) => + matchesInterceptRule(rule, paused) + ); const action = matchedRule ? matchedRule.action : 'continue'; if (action === 'fail') { ws.send( @@ -4671,7 +4850,10 @@ async function ensureInterceptSession(page) { statusCode: action === 'fulfill' ? matchedRule.statusCode : 0, }); })(); - activityChain = activityChain.catch(() => {}).then(() => activity).catch(() => {}); + activityChain = activityChain + .catch(() => {}) + .then(() => activity) + .catch(() => {}); void activity.catch(() => {}); }); @@ -4716,7 +4898,7 @@ async function handleOpenPage(toolArgs) { const query = toolArgs?.url ? `?${new URLSearchParams({ url: requireValidHttpUrl(toolArgs.url) }).toString()}` : ''; - const createdTarget = await fetchJson(`${getHttpUrl()}/json/new${query}`); + const createdTarget = await fetchJson(`${getHttpUrl()}/json/new${query}`, { method: 'PUT' }); const pageId = typeof createdTarget?.id === 'string' ? createdTarget.id : ''; if (!pageId) { throw new Error('Browser MCP failed to create a new page target.'); @@ -4763,7 +4945,10 @@ async function handleClosePage(toolArgs) { if (findPageIndexById(remainingPages, previousSelectedPageId) !== -1) { selectedPageId = previousSelectedPageId; } else { - selectedPageId = resolveFallbackSelectedPageId(remainingPages, pageIndex > 0 ? pageIndex - 1 : 0); + selectedPageId = resolveFallbackSelectedPageId( + remainingPages, + pageIndex > 0 ? pageIndex - 1 : 0 + ); } const selectedIndex = findPageIndexById(remainingPages, selectedPageId); return [ @@ -4805,7 +4990,9 @@ async function handleAddInterceptRule(toolArgs) { }); const statusCode = parseOptionalStatusCode(toolArgs.statusCode); const contentType = - toolArgs.contentType === undefined ? '' : requireNonEmptyString(toolArgs.contentType, 'contentType'); + toolArgs.contentType === undefined + ? '' + : requireNonEmptyString(toolArgs.contentType, 'contentType'); const body = parseOptionalBody(toolArgs.body); const responseHeaders = parseOptionalResponseHeaders(toolArgs.responseHeaders, contentType); const rule = { @@ -4900,7 +5087,8 @@ async function handleListRequests(toolArgs) { async function handleSetDownloadBehavior(toolArgs) { const behavior = requireEnumString(toolArgs.behavior, 'behavior', ['accept', 'deny']); const downloadPath = parseOptionalNonEmptyString(toolArgs.downloadPath); - const eventsEnabled = toolArgs.eventsEnabled === undefined ? true : toolArgs.eventsEnabled === true; + const eventsEnabled = + toolArgs.eventsEnabled === undefined ? true : toolArgs.eventsEnabled === true; if (behavior === 'deny' && downloadPath) { throw new Error('downloadPath is only allowed when behavior=accept'); @@ -5172,7 +5360,9 @@ async function finalizeRecordingCapture(session) { const warnings = Array.isArray(value.warnings) ? value.warnings : []; session.rawEvents = rawEvents; - session.steps = rawEvents.map((event) => normalizeRecordedEvent(session.pageId, event)).filter(Boolean); + session.steps = rawEvents + .map((event) => normalizeRecordedEvent(session.pageId, event)) + .filter(Boolean); session.warnings = warnings.map((warning) => String(warning.message || warning)); } @@ -5225,9 +5415,7 @@ async function handleStopRecording() { await finalizeRecordingCapture(session); } catch (error) { finalizeError = error instanceof Error ? error : new Error(String(error)); - session.warnings.push( - `recording capture finalization failed: ${finalizeError.message}` - ); + session.warnings.push(`recording capture finalization failed: ${finalizeError.message}`); } session.status = 'stopped'; session.stoppedAt = new Date().toISOString(); @@ -5325,7 +5513,9 @@ function buildReplayToolArgs(step, replayPage) { nth: step.nth, frameSelector: step.frameSelector, pierceShadow: step.pierceShadow, - ...(step.args?.targetSelector !== undefined ? { targetSelector: step.args.targetSelector } : {}), + ...(step.args?.targetSelector !== undefined + ? { targetSelector: step.args.targetSelector } + : {}), ...(step.args?.targetNth !== undefined ? { targetNth: step.args.targetNth } : {}), ...(step.args?.targetX !== undefined ? { targetX: step.args.targetX } : {}), ...(step.args?.targetY !== undefined ? { targetY: step.args.targetY } : {}), @@ -5430,9 +5620,7 @@ async function handleStartReplay(toolArgs) { latestReplaySession = session; activeReplaySession = session; - const task = Promise.resolve().then(() => - runReplaySession(session, { manageActiveState: true }) - ); + const task = Promise.resolve().then(() => runReplaySession(session, { manageActiveState: true })); activeReplayTask = task; task.catch(() => { // Session state is recorded in runReplaySession. @@ -5718,9 +5906,7 @@ async function handleExportArtifact(toolArgs) { const artifact = buildArtifact(kind, name, payload); const serialized = JSON.stringify(artifact, null, 2); if (Buffer.byteLength(serialized, 'utf8') > MAX_ARTIFACT_FILE_BYTES) { - throw new Error( - `artifact file exceeds maximum size of ${MAX_ARTIFACT_FILE_BYTES} bytes` - ); + throw new Error(`artifact file exceeds maximum size of ${MAX_ARTIFACT_FILE_BYTES} bytes`); } fs.writeFileSync(filePath, serialized, { encoding: 'utf8', mode: 0o600 }); return `name: ${name}\nkind: ${kind}\npath: ${filePath}\nstatus: exported`; @@ -5736,12 +5922,14 @@ async function handleListArtifacts() { return 'artifacts: []'; } return entries - .map((entry) => [ - `name: ${entry.name}`, - `kind: ${entry.kind}`, - `createdAt: ${entry.createdAt}`, - `path: ${getArtifactPath(entry.name)}`, - ].join('\n')) + .map((entry) => + [ + `name: ${entry.name}`, + `kind: ${entry.kind}`, + `createdAt: ${entry.createdAt}`, + `path: ${getArtifactPath(entry.name)}`, + ].join('\n') + ) .join('\n---\n'); } diff --git a/package.json b/package.json index e2fbffd8..8b6fc021 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.74.0", + "version": "7.74.0-dev.21", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", diff --git a/scripts/dev-release.sh b/scripts/dev-release.sh index 5b372216..de01bde0 100755 --- a/scripts/dev-release.sh +++ b/scripts/dev-release.sh @@ -42,12 +42,14 @@ STABLE=${STABLE_TAG#v} CURRENT_VERSION=$(jq -r '.version' package.json) HEAD_SUBJECT=$(git log -1 --pretty=%s 2>/dev/null || echo "") DEV_VERSION_REGEX="^${STABLE//./\\.}-dev\\.[0-9]+$" +CURRENT_RELEASE_SUBJECT="chore(release): ${CURRENT_VERSION}" +LEGACY_CURRENT_RELEASE_SUBJECT="${CURRENT_RELEASE_SUBJECT} [skip ci]" PREVIOUS_DEV_TAG="" RECOVERY_MODE=false log_info "Current stable version: ${STABLE}" -if [[ "$CURRENT_VERSION" =~ $DEV_VERSION_REGEX ]] && [[ "$HEAD_SUBJECT" == "chore(release): ${CURRENT_VERSION} [skip ci]" ]]; then +if [[ "$CURRENT_VERSION" =~ $DEV_VERSION_REGEX ]] && { [[ "$HEAD_SUBJECT" == "$CURRENT_RELEASE_SUBJECT" ]] || [[ "$HEAD_SUBJECT" == "$LEGACY_CURRENT_RELEASE_SUBJECT" ]]; }; then VERSION="$CURRENT_VERSION" CURRENT_TAG="v${VERSION}" PREVIOUS_DEV_TAG=$(git tag -l "v${STABLE}-dev.*" --sort=-v:refname | grep -vx "$CURRENT_TAG" | head -1 || echo "") @@ -101,7 +103,7 @@ else # Commit version change git add package.json - git commit -m "chore(release): ${VERSION} [skip ci]" + git commit -m "chore(release): ${VERSION}" log_info "Created release commit" # Create tag diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index a39fdce9..b7c00ac7 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -91,7 +91,8 @@ function createSettingsFile( baseUrl: string, apiKey: string, models: ModelMapping, - provider?: string + provider?: string, + extraModels?: string[] ): string { const ccsDir = getCcsDir(); const settingsPath = path.join(ccsDir, `${name}.settings.json`); @@ -117,6 +118,9 @@ function createSettingsFile( ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus, ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, + ...(extraModels && extraModels.length > 0 + ? { ANTHROPIC_EXTRA_MODELS: extraModels.join(',') } + : {}), CCS_DROID_PROVIDER: droidProvider, }, }; @@ -179,7 +183,8 @@ function createApiProfileUnified( apiKey: string, models: ModelMapping, target: TargetType = 'claude', - provider?: string + provider?: string, + extraModels?: string[] ): void { const ccsDir = getCcsDir(); const settingsFile = `${name}.settings.json`; @@ -204,6 +209,9 @@ function createApiProfileUnified( ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus, ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, + ...(extraModels && extraModels.length > 0 + ? { ANTHROPIC_EXTRA_MODELS: extraModels.join(',') } + : {}), CCS_DROID_PROVIDER: droidProvider, }, }; @@ -240,7 +248,8 @@ export function createApiProfile( apiKey: string, models: ModelMapping, target: TargetType = 'claude', - provider?: string + provider?: string, + extraModels?: string[] ): CreateApiProfileResult { try { const deniedReason = getDeniedModelReason(baseUrl, models); @@ -251,9 +260,9 @@ export function createApiProfile( const settingsFile = `~/.ccs/${name}.settings.json`; if (isUnifiedMode()) { - createApiProfileUnified(name, baseUrl, apiKey, models, target, provider); + createApiProfileUnified(name, baseUrl, apiKey, models, target, provider, extraModels); } else { - createSettingsFile(name, baseUrl, apiKey, models, provider); + createSettingsFile(name, baseUrl, apiKey, models, provider, extraModels); updateLegacyConfig(name, target); } diff --git a/src/bin/ccsxp-runtime.ts b/src/bin/ccsxp-runtime.ts index f488e5fb..3e314e34 100644 --- a/src/bin/ccsxp-runtime.ts +++ b/src/bin/ccsxp-runtime.ts @@ -1,12 +1,84 @@ +const os = require('os'); +const path = require('path'); const { stripTargetFlag } = require('../targets/target-resolver'); +const { expandPath } = require('../utils/helpers'); const { fail } = require('../utils/ui'); process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex'; +const CCSXP_CLIPROXY_OVERRIDE = 'model_provider="cliproxy"'; +const DISALLOWED_CCSXP_CONFIG_KEY_REGEX = + /^(model_provider|local_provider|profile)\s*=|^model_providers\./i; -// ccsxp is an opinionated shortcut for the built-in Codex-on-Codex route. -// Strip user-supplied target overrides before forcing the shortcut target. +function isDisallowedCcsxpConfigOverride(value: unknown) { + return typeof value === 'string' && DISALLOWED_CCSXP_CONFIG_KEY_REGEX.test(value.trim()); +} + +function findDisallowedCcsxpFlags(args: string[]) { + const disallowed = new Set(); + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--') { + break; + } + + if (arg === '-p' || arg === '--profile' || arg.startsWith('--profile=')) { + disallowed.add('--profile/-p'); + continue; + } + + if (arg === '--oss') { + disallowed.add('--oss'); + continue; + } + + if (arg === '--local-provider' || arg.startsWith('--local-provider=')) { + disallowed.add('--local-provider'); + continue; + } + + if (arg === '-c' || arg === '--config') { + const value = args[index + 1]; + if (isDisallowedCcsxpConfigOverride(value)) { + disallowed.add('--config/-c provider override'); + } + index += 1; + continue; + } + + if (arg.startsWith('--config=')) { + const value = arg.slice('--config='.length); + if (isDisallowedCcsxpConfigOverride(value)) { + disallowed.add('--config/-c provider override'); + } + } + } + + return [...disallowed]; +} + +function resolveCcsxpCodexHome() { + const configuredHome = process.env.CCSXP_CODEX_HOME?.trim(); + if (configuredHome) { + return path.resolve(expandPath(configuredHome)); + } + + return path.join(os.homedir(), '.codex'); +} + +process.env.CODEX_HOME = resolveCcsxpCodexHome(); + +// ccsxp is the Codex + cliproxy shortcut. Keep the native Codex history root, +// strip conflicting target overrides, and prepend the native cliproxy provider +// override so the runtime stays as close to plain Codex as possible. const forwardedArgs = (() => { try { + const disallowedFlags = findDisallowedCcsxpFlags(process.argv.slice(2)); + if (disallowedFlags.length > 0) { + throw new Error( + `ccsxp does not allow ${disallowedFlags.join(', ')} because the native cliproxy shortcut owns provider selection. Remove native Codex provider-selection flags and retry.` + ); + } return stripTargetFlag(process.argv.slice(2)); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -15,5 +87,12 @@ const forwardedArgs = (() => { } })(); -process.argv.splice(2, process.argv.length - 2, 'codex', '--target', 'codex', ...forwardedArgs); +process.argv.splice( + 2, + process.argv.length - 2, + '--config', + CCSXP_CLIPROXY_OVERRIDE, + ...forwardedArgs +); + require('../ccs'); diff --git a/src/ccs.ts b/src/ccs.ts index 23389316..22eee96f 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -82,6 +82,7 @@ import { tryHandleRootCommand } from './commands/root-command-router'; // Import extracted utility functions import { execClaude, stripAnthropicRoutingEnv, stripBrowserEnv } from './utils/shell-executor'; import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation'; +import { createOpenAICompatLaunchSettings } from './utils/openai-compat-launch-settings'; import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning'; import { createLogger } from './services/logging'; import { buildCodexBrowserMcpOverrides } from './utils/browser-codex-overrides'; @@ -1459,10 +1460,11 @@ async function main(): Promise { ), }; delete proxyEnv.ANTHROPIC_API_KEY; + const launchSettings = createOpenAICompatLaunchSettings(expandedSettingsPath, settings); const launchArgs = [ '--settings', - expandedSettingsPath, + launchSettings.settingsPath, ...appendThirdPartyWebSearchToolArgs(browserArgs), ]; const traceEnv = createWebSearchTraceContext({ @@ -1473,7 +1475,7 @@ async function main(): Promise { settingsPath: expandedSettingsPath, }); - execClaude(claudeCli, launchArgs, { ...proxyEnv, ...traceEnv }); + execClaude(claudeCli, launchArgs, { ...proxyEnv, ...traceEnv }, launchSettings.cleanup); return; } const launchArgs = [ diff --git a/src/cliproxy/account-safety.ts b/src/cliproxy/account-safety.ts index d7b26607..b1435913 100644 --- a/src/cliproxy/account-safety.ts +++ b/src/cliproxy/account-safety.ts @@ -690,6 +690,9 @@ export async function handleQuotaExhaustion( // Dynamic imports to avoid circular dependencies const { applyCooldown, findHealthyAccount } = await import('./quota-manager'); const { setDefaultAccount, touchAccount } = await import('./account-manager'); + const { loadOrCreateUnifiedConfig } = await import('../config/unified-config-loader'); + const config = loadOrCreateUnifiedConfig(); + const threshold = config.quota_management?.auto?.exhaustion_threshold ?? 5; // Apply cooldown to exhausted account applyCooldown(provider, accountId, cooldownMinutes); @@ -698,7 +701,9 @@ export async function handleQuotaExhaustion( const alternative = await findHealthyAccount(provider, [accountId]); if (alternative) { - pauseAccountForQuotaCooldown(provider, accountId, cooldownMinutes); + if (alternative.lastQuota !== null && alternative.lastQuota >= threshold) { + pauseAccountForQuotaCooldown(provider, accountId, cooldownMinutes); + } setDefaultAccount(provider, alternative.id); touchAccount(provider, alternative.id); writeQuotaExhausted(accountId, alternative.id, cooldownMinutes); diff --git a/src/cliproxy/ai-providers/service.ts b/src/cliproxy/ai-providers/service.ts index 12a5e496..d35982f4 100644 --- a/src/cliproxy/ai-providers/service.ts +++ b/src/cliproxy/ai-providers/service.ts @@ -36,11 +36,20 @@ function toHeaderPairs( return Object.entries(headers || {}).map(([key, value]) => ({ key, value })); } -function normalizeModelAliases(models: AiProviderModelAlias[] | undefined): AiProviderModelAlias[] { - return (models || []) +function readModelRulePart(model: unknown, key: keyof AiProviderModelAlias) { + if (!model || typeof model !== 'object') { + return ''; + } + + const value = (model as Partial>)[key]; + return typeof value === 'string' ? value.trim() : ''; +} + +function normalizeModelAliases(models: unknown): AiProviderModelAlias[] { + return (Array.isArray(models) ? models : []) .map((model) => ({ - name: model.name.trim(), - alias: model.alias.trim(), + name: readModelRulePart(model, 'name'), + alias: readModelRulePart(model, 'alias'), })) .filter((model) => model.name.length > 0 || model.alias.length > 0); } diff --git a/src/cliproxy/binary/lifecycle.ts b/src/cliproxy/binary/lifecycle.ts index 24e64d5c..81e0d677 100644 --- a/src/cliproxy/binary/lifecycle.ts +++ b/src/cliproxy/binary/lifecycle.ts @@ -57,7 +57,8 @@ function clampToMaxStable(version: string | undefined, verbose: boolean): string async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean): Promise { const backend: CLIProxyBackend = config.backend ?? DEFAULT_BACKEND; const backendLabel = getBackendLabel(backend); - const updateResult = await checkForUpdates(config.binPath, config.version, verbose, backend); + const checkFn = config.checkForUpdatesFn ?? checkForUpdates; + const updateResult = await checkFn(config.binPath, config.version, verbose, backend); const currentVersion = updateResult.currentVersion; const latestVersion = updateResult.latestVersion; diff --git a/src/cliproxy/codex-plan-compatibility.ts b/src/cliproxy/codex-plan-compatibility.ts index 5276f970..73cd82c8 100644 --- a/src/cliproxy/codex-plan-compatibility.ts +++ b/src/cliproxy/codex-plan-compatibility.ts @@ -18,6 +18,7 @@ const KNOWN_CODEX_MODELS = new Set( ); const FREE_PLAN_FALLBACKS = new Map([ + ['gpt-5.5', FREE_SAFE_DEFAULT_MODEL], ['gpt-5.3-codex', FREE_SAFE_DEFAULT_MODEL], ['gpt-5.3-codex-spark', FREE_SAFE_FAST_MODEL], ]); diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index 43ce4672..3b4f7803 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -40,8 +40,9 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs'; * v15: Prune stale generated Antigravity Gemini preview aliases during regeneration * v16: Narrow stale Gemini alias cleanup to broad multi-version guessed ranges * v17: Persist routing.strategy from CCS unified config + * v18: Persist routing.session-affinity and routing.session-affinity-ttl from CCS unified config */ -export const CLIPROXY_CONFIG_VERSION = 17; +export const CLIPROXY_CONFIG_VERSION = 18; interface RegenerateConfigOptions { configPath?: string; @@ -98,6 +99,8 @@ const MIN_STALE_HIGH_ONLY_GEMINI_MINOR_VERSIONS = 3; const MIN_STALE_GUESSED_GEMINI_AVERAGE_VARIANTS_PER_MINOR = 2; const LEGACY_GEMINI_STALE_ALIAS_MIGRATION_VERSION = 16; const MAX_LEGACY_MANUAL_GEMINI_MINOR_VERSION = 2; +const GO_DURATION_SEGMENT = String.raw`(?:\d+(?:\.\d+)?(?:ns|us|µs|μs|ms|s|m|h))`; +const GO_DURATION_PATTERN = new RegExp(`^${GO_DURATION_SEGMENT}+$`); /** * Get provider configuration @@ -132,6 +135,27 @@ function getRoutingStrategy(): 'round-robin' | 'fill-first' { return config.cliproxy?.routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin'; } +function getSessionAffinityEnabled(): boolean { + return loadOrCreateUnifiedConfig().cliproxy?.routing?.session_affinity ?? false; +} + +function getSessionAffinityTtl(): string { + const ttl = loadOrCreateUnifiedConfig().cliproxy?.routing?.session_affinity_ttl?.trim(); + return ttl && GO_DURATION_PATTERN.test(ttl) && hasPositiveDuration(ttl) ? ttl : '1h'; +} + +function hasPositiveDuration(value: string): boolean { + const segments = value.match(new RegExp(GO_DURATION_SEGMENT, 'g')); + if (!segments) { + return false; + } + + return segments.some((segment) => { + const numeric = parseFloat(segment); + return Number.isFinite(numeric) && numeric > 0; + }); +} + function sanitizeYamlScalar(rawValue: string): string { const trimmed = rawValue.trim(); if ( @@ -552,6 +576,8 @@ function generateUnifiedConfigContent( // Get logging settings from user config (disabled by default) const { loggingToFile, requestLog } = getLoggingSettings(); const routingStrategy = getRoutingStrategy(); + const sessionAffinityEnabled = getSessionAffinityEnabled(); + const sessionAffinityTtl = getSessionAffinityTtl(); // Get effective auth tokens (respects user customization) const effectiveApiKey = getEffectiveApiKey(); @@ -627,6 +653,8 @@ quota-exceeded: # Credential selection strategy when multiple matching accounts are available routing: strategy: ${routingStrategy} + session-affinity: ${sessionAffinityEnabled} + session-affinity-ttl: "${sessionAffinityTtl}" # ============================================================================= # Authentication diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 90db6209..f673bcb5 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -106,6 +106,7 @@ import { handleTokenExpiration, handleQuotaCheck, } from './retry-handler'; +import { MANAGED_QUOTA_PROVIDERS, type ManagedQuotaProvider } from '../quota-manager'; import { checkOrJoinProxy, registerProxySession, setupCleanupHandlers } from './session-bridge'; import { parseThinkingOverride } from './thinking-arg-parser'; import { @@ -128,6 +129,25 @@ import { } from './thinking-override-resolver'; import { shouldStartHttpsTunnel } from './https-tunnel-policy'; +function resolveRuntimeQuotaMonitorProviders( + provider: CLIProxyProvider, + compositeProviders: CLIProxyProvider[] +): ManagedQuotaProvider[] { + const candidates = compositeProviders.length > 0 ? compositeProviders : [provider]; + const resolved: ManagedQuotaProvider[] = []; + + for (const candidate of candidates) { + if ( + MANAGED_QUOTA_PROVIDERS.includes(candidate as ManagedQuotaProvider) && + !resolved.includes(candidate as ManagedQuotaProvider) + ) { + resolved.push(candidate as ManagedQuotaProvider); + } + } + + return resolved; +} + /** Default executor configuration */ const DEFAULT_CONFIG: ExecutorConfig = { port: CLIPROXY_DEFAULT_PORT, @@ -888,8 +908,7 @@ export async function execClaudeWithCLIProxy( if (!skipLocalAuth) { // Multi-tier quota check for composite variants (check if any tier uses a managed provider) if (compositeProviders.length > 0) { - const managedQuotaProviders = ['agy', 'claude'] as const; - for (const managedProvider of managedQuotaProviders) { + for (const managedProvider of MANAGED_QUOTA_PROVIDERS) { if (compositeProviders.includes(managedProvider)) { await handleQuotaCheck(managedProvider); } @@ -1376,9 +1395,14 @@ export async function execClaudeWithCLIProxy( // 12b. Start runtime quota monitor (adaptive polling during session) if (!skipLocalAuth) { const { startQuotaMonitor } = await import('../quota-manager'); - const monitorAccount = getDefaultAccount(provider); - if (monitorAccount) { - startQuotaMonitor(provider, monitorAccount.id); + for (const monitorProvider of resolveRuntimeQuotaMonitorProviders( + provider, + compositeProviders + )) { + const monitorAccount = getDefaultAccount(monitorProvider); + if (monitorAccount) { + startQuotaMonitor(monitorProvider, monitorAccount.id); + } } } @@ -1397,4 +1421,8 @@ export async function execClaudeWithCLIProxy( // Re-export utility functions for backwards compatibility export { isPortAvailable, findAvailablePort } from './lifecycle-manager'; +export const __testExports = { + resolveRuntimeQuotaMonitorProviders, +}; + export default execClaudeWithCLIProxy; diff --git a/src/cliproxy/executor/retry-handler.ts b/src/cliproxy/executor/retry-handler.ts index e7cb50f9..7135a9d0 100644 --- a/src/cliproxy/executor/retry-handler.ts +++ b/src/cliproxy/executor/retry-handler.ts @@ -80,9 +80,9 @@ export async function handleTokenExpiration( * Handle quota check and auto-switching for providers with quota-based rotation. */ export async function handleQuotaCheck(provider: CLIProxyProvider): Promise { - if (provider !== 'agy' && provider !== 'claude') return; + const { isManagedQuotaProvider, preflightCheck } = await import('../quota-manager'); + if (!isManagedQuotaProvider(provider)) return; - const { preflightCheck } = await import('../quota-manager'); const preflight = await preflightCheck(provider); if (!preflight.proceed) { diff --git a/src/cliproxy/management-api-client.ts b/src/cliproxy/management-api-client.ts index dc9de153..f7d5c9d7 100644 --- a/src/cliproxy/management-api-client.ts +++ b/src/cliproxy/management-api-client.ts @@ -20,6 +20,7 @@ import type { CliproxyRoutingStrategy } from './types'; /** Default timeout for management operations (longer than health check) */ const DEFAULT_TIMEOUT_MS = 5000; +const ROUTING_STRATEGY_PATH = '/v0/management/routing/strategy'; /** Default port for HTTPS protocol */ const DEFAULT_HTTPS_PORT = 443; @@ -232,7 +233,7 @@ export class ManagementApiClient { * Get the global credential routing strategy from CLIProxy. */ async getRoutingStrategy(): Promise { - const response = await this.request<{ strategy?: string }>('GET', '/routing/strategy'); + const response = await this.request<{ strategy?: string }>('GET', ROUTING_STRATEGY_PATH); return response.data?.strategy === 'fill-first' ? 'fill-first' : 'round-robin'; } @@ -240,7 +241,7 @@ export class ManagementApiClient { * Update the global credential routing strategy on CLIProxy. */ async putRoutingStrategy(strategy: CliproxyRoutingStrategy): Promise { - await this.request('PUT', '/routing/strategy', { value: strategy }); + await this.request('PUT', ROUTING_STRATEGY_PATH, { value: strategy }); return strategy; } diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index ee1032f5..1f37537b 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -188,6 +188,19 @@ export const MODEL_CATALOG: Partial> = displayName: 'Copilot Codex', defaultModel: 'gpt-5.4', models: [ + { + id: 'gpt-5.5', + name: 'GPT-5.5', + tier: 'pro', + description: + 'Newest Codex-released GPT-5 family model; falls back to GPT-5.4 on free plans', + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high', 'xhigh'], + maxLevel: 'xhigh', + dynamicAllowed: false, + }, + }, { id: 'gpt-5.4', name: 'GPT-5.4', diff --git a/src/cliproxy/quota-fetcher-claude-normalizer.ts b/src/cliproxy/quota-fetcher-claude-normalizer.ts index a6cdda19..b064d8a2 100644 --- a/src/cliproxy/quota-fetcher-claude-normalizer.ts +++ b/src/cliproxy/quota-fetcher-claude-normalizer.ts @@ -89,7 +89,27 @@ function clampUnit(value: number): number { return Math.max(0, Math.min(1, value)); } -function normalizeUtilization(raw: Record): { +/** + * Unit of the `utilization` field in a Claude quota payload. + * + * Anthropic exposes utilization in two different units depending on the + * payload form: + * - The `restrictions` payload (array or object map) returns a 0..1 ratio + * (e.g. 0.25 for 25% used). + * - The OAuth `oauth/usage` endpoint returns a percent value 0..100 + * (e.g. 25 for 25% used; values like 1 for 1% used). + * + * The previous heuristic of "value <= 1 means ratio, otherwise percent" + * silently misinterpreted boundary values like 1.0 (1% used) from the + * OAuth payload as 100% used. Each call site now passes the unit it + * knows it is parsing. + */ +type UtilizationUnit = 'ratio' | 'percent'; + +function normalizeUtilization( + raw: Record, + unit: UtilizationUnit +): { utilization: number | null; usedPercent: number; remainingPercent: number; @@ -99,11 +119,12 @@ function normalizeUtilization(raw: Record): { const remainingPercentRaw = asNumber(raw['remainingPercent'] ?? raw['remaining_percent']); if (utilizationRaw !== null) { - const ratio = utilizationRaw <= 1 ? utilizationRaw : utilizationRaw / 100; - const normalizedRatio = clampUnit(ratio); - const usedPercent = clampPercent(normalizedRatio * 100); + const usedPercent = + unit === 'ratio' + ? clampPercent(clampUnit(utilizationRaw) * 100) + : clampPercent(utilizationRaw); return { - utilization: normalizedRatio, + utilization: usedPercent / 100, usedPercent, remainingPercent: clampPercent(100 - usedPercent), }; @@ -149,7 +170,8 @@ function toObject(value: unknown): Record | null { function normalizeRestriction( raw: Record, - fallbackKey?: string + fallbackKey: string | undefined, + unit: UtilizationUnit ): ClaudeQuotaWindow | null { const rateLimitType = normalizeRateLimitType( raw['rateLimitType'] ?? raw['rate_limit_type'] ?? raw['claim'] ?? raw['claimAbbrev'], @@ -169,7 +191,7 @@ function normalizeRestriction( raw['overage_reset_at'] ) || null; - const { utilization, usedPercent, remainingPercent } = normalizeUtilization(raw); + const { utilization, usedPercent, remainingPercent } = normalizeUtilization(raw, unit); return { rateLimitType, @@ -211,14 +233,16 @@ export function buildClaudeQuotaWindows(payload: Record): Claud for (const item of rawRestrictions) { const raw = toObject(item); if (!raw) continue; - const window = normalizeRestriction(raw); + // policy-limits restrictions[] returns utilization as a 0..1 ratio. + const window = normalizeRestriction(raw, undefined, 'ratio'); if (window) windows.push(window); } } else if (toObject(rawRestrictions)) { for (const [key, value] of Object.entries(rawRestrictions as Record)) { const raw = toObject(value); if (!raw) continue; - const window = normalizeRestriction(raw, key); + // policy-limits restrictions{} returns utilization as a 0..1 ratio. + const window = normalizeRestriction(raw, key, 'ratio'); if (window) windows.push(window); } } else if (toObject(payload)) { @@ -226,13 +250,15 @@ export function buildClaudeQuotaWindows(payload: Record): Claud const raw = toObject(value); if (!raw) continue; if (!isClaudeOAuthUsageWindowCandidate(key, raw)) continue; - const window = normalizeRestriction(raw, key); + // OAuth /oauth/usage payloads return utilization as percent 0..100. + const window = normalizeRestriction(raw, key, 'percent'); if (window) windows.push(window); } - // Some responses may contain a single restriction object directly. + // Some responses may contain a single restriction object directly, + // matching the policy-limits ratio shape. if (windows.length === 0) { - const direct = normalizeRestriction(payload); + const direct = normalizeRestriction(payload, undefined, 'ratio'); if (direct) windows.push(direct); } } diff --git a/src/cliproxy/quota-fetcher-codex.ts b/src/cliproxy/quota-fetcher-codex.ts index 811be609..c0c4940b 100644 --- a/src/cliproxy/quota-fetcher-codex.ts +++ b/src/cliproxy/quota-fetcher-codex.ts @@ -39,8 +39,10 @@ interface CodexUsageResponse { planType?: string; rate_limit?: CodexRateLimitWindow; rateLimit?: CodexRateLimitWindow; - code_review_rate_limit?: CodexRateLimitWindow; - codeReviewRateLimit?: CodexRateLimitWindow; + code_review_rate_limit?: CodexRateLimitWindow | null; + codeReviewRateLimit?: CodexRateLimitWindow | null; + additional_rate_limits?: CodexAdditionalRateLimit[] | null; + additionalRateLimits?: CodexAdditionalRateLimit[] | null; } /** Rate limit window from API */ @@ -51,6 +53,19 @@ interface CodexRateLimitWindow { secondaryWindow?: CodexWindowData; } +/** + * Additional rate limit entry from API (introduced for features like GPT-5.3 Codex Spark). + * Each entry surfaces its own primary/secondary windows under a feature-specific limit name. + */ +interface CodexAdditionalRateLimit { + limit_name?: string; + limitName?: string; + metered_feature?: string; + meteredFeature?: string; + rate_limit?: CodexRateLimitWindow; + rateLimit?: CodexRateLimitWindow; +} + /** Individual window data */ interface CodexWindowData { used_percent?: number; @@ -92,7 +107,11 @@ function getCodexWindowKind(label: string): CodexWindowKind { function getUnknownCodexWindowLabels(windows: CodexQuotaWindow[]): string[] { const unknownLabels = windows - .filter((window) => getCodexWindowKind(window.label) === 'unknown') + .filter((window) => { + // Windows with explicit category metadata are always classified. + if (window.category) return false; + return getCodexWindowKind(window.label) === 'unknown'; + }) .map((window) => window.label) .filter((label): label is string => typeof label === 'string' && label.trim().length > 0); return Array.from(new Set(unknownLabels)); @@ -106,7 +125,8 @@ function shouldLogCodexWindowWarnings(verbose: boolean): boolean { /** * Build explicit 5h + weekly usage summary from raw Codex windows. - * Falls back to shortest/longest reset windows if API labels change. + * Prefers explicit `category`/`cadence` metadata when present. + * Falls back to label sniffing for legacy cached windows. */ export function buildCodexCoreUsageSummary(windows: CodexQuotaWindow[]): CodexCoreUsageSummary { if (!windows || windows.length === 0) { @@ -117,20 +137,36 @@ export function buildCodexCoreUsageSummary(windows: CodexQuotaWindow[]): CodexCo let weeklyWindow: CodexQuotaWindow | null = null; const nonCodeReviewWindows: CodexQuotaWindow[] = []; - for (const window of windows) { - const kind = getCodexWindowKind(window.label); - if (kind === 'usage-5h') { - if (!fiveHourWindow) fiveHourWindow = window; - nonCodeReviewWindows.push(window); - continue; + // Determine if any window carries category metadata. If so, prefer category-based + // selection so 'additional' windows (e.g. Spark) do not pollute the main usage summary. + const hasCategoryMetadata = windows.some((window) => Boolean(window.category)); + + if (hasCategoryMetadata) { + for (const window of windows) { + if (window.category === 'usage') { + if (window.cadence === '5h' && !fiveHourWindow) fiveHourWindow = window; + else if (window.cadence === 'weekly' && !weeklyWindow) weeklyWindow = window; + nonCodeReviewWindows.push(window); + } + // 'code-review' and 'additional' windows are intentionally excluded from the main + // usage summary — they represent feature-specific quotas, not core usage. } - if (kind === 'usage-weekly') { - if (!weeklyWindow) weeklyWindow = window; - nonCodeReviewWindows.push(window); - continue; - } - if (kind === 'unknown') { - nonCodeReviewWindows.push(window); + } else { + for (const window of windows) { + const kind = getCodexWindowKind(window.label); + if (kind === 'usage-5h') { + if (!fiveHourWindow) fiveHourWindow = window; + nonCodeReviewWindows.push(window); + continue; + } + if (kind === 'usage-weekly') { + if (!weeklyWindow) weeklyWindow = window; + nonCodeReviewWindows.push(window); + continue; + } + if (kind === 'unknown') { + nonCodeReviewWindows.push(window); + } } } @@ -270,9 +306,18 @@ function buildCodexQuotaWindows(payload: CodexUsageResponse): CodexQuotaWindow[] // Get rate limit object (handles both cases) const rateLimit = payload.rate_limit || payload.rateLimit; const codeReviewRateLimit = payload.code_review_rate_limit || payload.codeReviewRateLimit; + const additionalRateLimits = payload.additional_rate_limits || payload.additionalRateLimits; // Helper to extract window data - const addWindow = (label: string, windowData: CodexWindowData | undefined): void => { + const addWindow = ( + label: string, + windowData: CodexWindowData | undefined, + meta: { + category: NonNullable; + cadence: NonNullable; + featureLabel?: string; + } + ): void => { if (!windowData) return; // Clamp usedPercent to [0, 100] range @@ -287,33 +332,68 @@ function buildCodexQuotaWindows(payload: CodexUsageResponse): CodexQuotaWindow[] resetAt = new Date(Date.now() + resetAfterSeconds * 1000).toISOString(); } - windows.push({ + const window: CodexQuotaWindow = { label, usedPercent, remainingPercent: Math.max(0, 100 - usedPercent), resetAfterSeconds, resetAt, - }); + category: meta.category, + cadence: meta.cadence, + }; + if (meta.featureLabel) { + window.featureLabel = meta.featureLabel; + } + windows.push(window); }; // Add main rate limit windows if (rateLimit) { - addWindow('Primary', rateLimit.primary_window || rateLimit.primaryWindow); - addWindow('Secondary', rateLimit.secondary_window || rateLimit.secondaryWindow); + addWindow('Primary', rateLimit.primary_window || rateLimit.primaryWindow, { + category: 'usage', + cadence: '5h', + }); + addWindow('Secondary', rateLimit.secondary_window || rateLimit.secondaryWindow, { + category: 'usage', + cadence: 'weekly', + }); } // Add code review rate limit windows if (codeReviewRateLimit) { addWindow( 'Code Review (Primary)', - codeReviewRateLimit.primary_window || codeReviewRateLimit.primaryWindow + codeReviewRateLimit.primary_window || codeReviewRateLimit.primaryWindow, + { category: 'code-review', cadence: '5h', featureLabel: 'Code Review' } ); addWindow( 'Code Review (Secondary)', - codeReviewRateLimit.secondary_window || codeReviewRateLimit.secondaryWindow + codeReviewRateLimit.secondary_window || codeReviewRateLimit.secondaryWindow, + { category: 'code-review', cadence: 'weekly', featureLabel: 'Code Review' } ); } + // Add additional rate limit windows (e.g. GPT-5.3 Codex Spark) + if (Array.isArray(additionalRateLimits)) { + for (const entry of additionalRateLimits) { + if (!entry) continue; + const entryRateLimit = entry.rate_limit || entry.rateLimit; + if (!entryRateLimit) continue; + + const featureLabel = entry.limit_name || entry.limitName || 'Additional'; + addWindow( + `${featureLabel} (Primary)`, + entryRateLimit.primary_window || entryRateLimit.primaryWindow, + { category: 'additional', cadence: '5h', featureLabel } + ); + addWindow( + `${featureLabel} (Secondary)`, + entryRateLimit.secondary_window || entryRateLimit.secondaryWindow, + { category: 'additional', cadence: 'weekly', featureLabel } + ); + } + } + return windows; } diff --git a/src/cliproxy/quota-fetcher-ghcp.ts b/src/cliproxy/quota-fetcher-ghcp.ts index e2c4f8f2..96f4450b 100644 --- a/src/cliproxy/quota-fetcher-ghcp.ts +++ b/src/cliproxy/quota-fetcher-ghcp.ts @@ -48,29 +48,41 @@ interface TokenData { } function normalizeSnapshot(raw?: RawGhcpQuotaSnapshot): GhcpQuotaSnapshot { + const unlimited = Boolean(raw?.unlimited); + const hasPercentSignal = + typeof raw?.percent_remaining === 'number' && Number.isFinite(raw.percent_remaining); + const rawRemaining = typeof raw?.remaining === 'number' ? raw.remaining : raw?.quota_remaining; + const hasEntitlementRemainingSignal = + typeof raw?.entitlement === 'number' && + Number.isFinite(raw.entitlement) && + raw.entitlement > 0 && + typeof rawRemaining === 'number' && + Number.isFinite(rawRemaining); + const reported = + raw !== undefined && (unlimited || hasPercentSignal || hasEntitlementRemainingSignal); const entitlement = Number(raw?.entitlement ?? 0); - const remainingRaw = raw?.remaining ?? raw?.quota_remaining ?? 0; + const remainingRaw = rawRemaining ?? 0; const remaining = Number(remainingRaw); const safeEntitlement = Number.isFinite(entitlement) ? Math.max(0, entitlement) : 0; const safeRemaining = Number.isFinite(remaining) ? Math.max(0, remaining) : 0; const used = Math.max(0, safeEntitlement - safeRemaining); - const percentRemainingRaw = - typeof raw?.percent_remaining === 'number' ? raw.percent_remaining : null; - const percentRemaining = - percentRemainingRaw !== null - ? clampPercent(percentRemainingRaw) - : safeEntitlement > 0 + const percentRemaining = unlimited + ? 100 + : hasPercentSignal + ? clampPercent(raw.percent_remaining as number) + : reported && safeEntitlement > 0 ? clampPercent((safeRemaining / safeEntitlement) * 100) : 0; return { + reported, entitlement: safeEntitlement, remaining: safeRemaining, used, percentRemaining, percentUsed: clampPercent(100 - percentRemaining), - unlimited: Boolean(raw?.unlimited), + unlimited, overageCount: typeof raw?.overage_count === 'number' && Number.isFinite(raw.overage_count) ? Math.max(0, raw.overage_count) diff --git a/src/cliproxy/quota-manager.ts b/src/cliproxy/quota-manager.ts index ce3b59a8..6c5b46d6 100644 --- a/src/cliproxy/quota-manager.ts +++ b/src/cliproxy/quota-manager.ts @@ -15,7 +15,15 @@ import { CLIProxyProvider } from './types'; import { QuotaResult, fetchAccountQuota } from './quota-fetcher'; import { fetchClaudeQuota } from './quota-fetcher-claude'; -import type { ClaudeQuotaResult } from './quota-types'; +import { fetchCodexQuota } from './quota-fetcher-codex'; +import { fetchGeminiCliQuota } from './quota-fetcher-gemini-cli'; +import { fetchGhcpQuota } from './quota-fetcher-ghcp'; +import type { + ClaudeQuotaResult, + CodexQuotaResult, + GeminiCliQuotaResult, + GhcpQuotaResult, +} from './quota-types'; import { getDefaultAccount, getProviderAccounts, @@ -27,12 +35,25 @@ import { import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; import type { RuntimeMonitorConfig } from '../config/unified-config-types'; -type ManagedQuotaProvider = 'agy' | 'claude'; -type ManagedQuotaResult = QuotaResult | ClaudeQuotaResult; +export type ManagedQuotaProvider = 'agy' | 'claude' | 'codex' | 'gemini' | 'ghcp'; +type ManagedQuotaResult = + | QuotaResult + | ClaudeQuotaResult + | CodexQuotaResult + | GeminiCliQuotaResult + | GhcpQuotaResult; -const MANAGED_QUOTA_PROVIDERS: readonly ManagedQuotaProvider[] = ['agy', 'claude']; +export const MANAGED_QUOTA_PROVIDERS: readonly ManagedQuotaProvider[] = [ + 'agy', + 'claude', + 'codex', + 'gemini', + 'ghcp', +]; -function isManagedQuotaProvider(provider: CLIProxyProvider): provider is ManagedQuotaProvider { +export function isManagedQuotaProvider( + provider: CLIProxyProvider +): provider is ManagedQuotaProvider { return MANAGED_QUOTA_PROVIDERS.includes(provider as ManagedQuotaProvider); } @@ -117,19 +138,7 @@ async function fetchQuotaWithDedup( setCachedQuota(provider, accountId, result); return result; }) - .catch((): ManagedQuotaResult => { - if (provider === 'claude') { - return { - success: false, - windows: [], - coreUsage: { fiveHour: null, weekly: null }, - lastUpdated: Date.now(), - error: 'Failed to fetch Claude quota', - accountId, - }; - } - return { success: false, models: [], lastUpdated: Date.now() }; - }) + .catch((): ManagedQuotaResult => buildFailedQuotaResult(provider, accountId)) .finally(() => { pendingFetches.delete(key); }); @@ -143,10 +152,85 @@ async function fetchManagedQuota( accountId: string, verbose: boolean ): Promise { - if (provider === 'claude') { - return fetchClaudeQuota(accountId, verbose); + switch (provider) { + case 'agy': + return fetchAccountQuota(provider, accountId, verbose); + case 'claude': + return fetchClaudeQuota(accountId, verbose); + case 'codex': + return fetchCodexQuota(accountId, verbose); + case 'gemini': + return fetchGeminiCliQuota(accountId, verbose); + case 'ghcp': + return fetchGhcpQuota(accountId, verbose); + } +} + +function buildFailedGhcpSnapshot(): GhcpQuotaResult['snapshots']['chat'] { + return { + reported: false, + entitlement: 0, + remaining: 0, + used: 0, + percentRemaining: 0, + percentUsed: 0, + unlimited: false, + overageCount: 0, + overagePermitted: false, + quotaId: null, + }; +} + +function buildFailedQuotaResult( + provider: ManagedQuotaProvider, + accountId: string +): ManagedQuotaResult { + switch (provider) { + case 'agy': + return { success: false, models: [], lastUpdated: Date.now(), accountId }; + case 'claude': + return { + success: false, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + lastUpdated: Date.now(), + error: 'Failed to fetch Claude quota', + accountId, + }; + case 'codex': + return { + success: false, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + planType: null, + lastUpdated: Date.now(), + error: 'Failed to fetch Codex quota', + accountId, + }; + case 'gemini': + return { + success: false, + buckets: [], + projectId: null, + lastUpdated: Date.now(), + error: 'Failed to fetch Gemini quota', + accountId, + }; + case 'ghcp': + return { + success: false, + planType: null, + quotaResetDate: null, + snapshots: { + premiumInteractions: buildFailedGhcpSnapshot(), + chat: buildFailedGhcpSnapshot(), + completions: buildFailedGhcpSnapshot(), + }, + lastUpdated: Date.now(), + error: 'Failed to fetch GitHub Copilot quota', + accountId, + }; } - return fetchAccountQuota(provider, accountId, verbose); } // ============================================================================ @@ -264,14 +348,62 @@ function calculateClaudeQuotaPercent(quota: ClaudeQuotaResult): number | null { return null; } +function calculateCodexQuotaPercent(quota: CodexQuotaResult): number | null { + if (!quota.success) return null; + + const coreWindows = [quota.coreUsage?.fiveHour, quota.coreUsage?.weekly].filter( + (window): window is NonNullable => !!window + ); + if (coreWindows.length > 0) { + return Math.min(...coreWindows.map((window) => window.remainingPercent)); + } + + const usageWindows = quota.windows.filter((window) => window.category !== 'code-review'); + if (usageWindows.length > 0) { + return Math.min(...usageWindows.map((window) => window.remainingPercent)); + } + + return null; +} + +function calculateGeminiQuotaPercent(quota: GeminiCliQuotaResult): number | null { + if (!quota.success || quota.buckets.length === 0) return null; + return Math.min(...quota.buckets.map((bucket) => bucket.remainingPercent)); +} + +function calculateGhcpQuotaPercent(quota: GhcpQuotaResult): number | null { + if (!quota.success) return null; + const percentages = [ + quota.snapshots.premiumInteractions, + quota.snapshots.chat, + quota.snapshots.completions, + ] + .filter((snapshot) => snapshot.reported !== false) + .map((snapshot) => (snapshot.unlimited ? 100 : snapshot.percentRemaining)) + .filter((percentage) => Number.isFinite(percentage)); + + return percentages.length > 0 ? Math.min(...percentages) : null; +} + /** * Calculate normalized quota percentage for managed providers. */ -function calculateQuotaPercent(quota: ManagedQuotaResult): number | null { - if ('models' in quota) { - return calculateAgyQuotaPercent(quota); +function calculateQuotaPercent( + provider: ManagedQuotaProvider, + quota: ManagedQuotaResult +): number | null { + switch (provider) { + case 'agy': + return calculateAgyQuotaPercent(quota as QuotaResult); + case 'claude': + return calculateClaudeQuotaPercent(quota as ClaudeQuotaResult); + case 'codex': + return calculateCodexQuotaPercent(quota as CodexQuotaResult); + case 'gemini': + return calculateGeminiQuotaPercent(quota as GeminiCliQuotaResult); + case 'ghcp': + return calculateGhcpQuotaPercent(quota as GhcpQuotaResult); } - return calculateClaudeQuotaPercent(quota); } /** @@ -312,7 +444,7 @@ export async function findHealthyAccount( quota = await fetchQuotaWithDedup(provider, account.id); } - const avgQuota = calculateQuotaPercent(quota); + const avgQuota = calculateQuotaPercent(provider, quota); return { id: account.id, @@ -437,7 +569,7 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise= threshold) { + pauseAccountForQuotaCooldown( + provider, + defaultAccount.id, + quotaConfig.auto?.cooldown_minutes ?? 5 + ); + } setDefaultAccount(provider, alternative.id); touchAccount(provider, alternative.id); return { @@ -506,12 +640,14 @@ export async function getQuotaStatus(provider: CLIProxyProvider): Promise<{ const results = await Promise.all( accounts.map(async (account) => { + const managedProvider = isManagedQuotaProvider(provider) ? provider : null; let quota = getCachedQuota(provider, account.id); - if (!quota && isManagedQuotaProvider(provider)) { - quota = await fetchQuotaWithDedup(provider, account.id); + if (!quota && managedProvider) { + quota = await fetchQuotaWithDedup(managedProvider, account.id); } - const avgQuota = quota ? calculateQuotaPercent(quota) : null; + const avgQuota = + quota && managedProvider ? calculateQuotaPercent(managedProvider, quota) : null; return { account, @@ -530,14 +666,14 @@ export async function getQuotaStatus(provider: CLIProxyProvider): Promise<{ // RUNTIME QUOTA MONITOR (adaptive polling during active sessions) // ============================================================================ -/** Active monitor timer (null = not running) */ -let monitorTimer: ReturnType | null = null; +interface RuntimeMonitorState { + timer: ReturnType | null; + hasWarnedThisSession: boolean; + stopped: boolean; +} -/** Tracks if warning was shown this session (avoid spam) */ -let hasWarnedThisSession = false; - -/** Guards against in-flight poll callbacks running after stop */ -let monitorStopped = false; +/** Active monitor timers keyed by provider/account. */ +const activeRuntimeMonitors = new Map(); /** * Schedule next quota poll with adaptive interval. @@ -547,16 +683,18 @@ function scheduleNextPoll( provider: ManagedQuotaProvider, accountId: string, monitorConfig: RuntimeMonitorConfig, - intervalMs: number + intervalMs: number, + state: RuntimeMonitorState ): void { - monitorTimer = setTimeout(async () => { + const monitorKey = getCacheKey(provider, accountId); + state.timer = setTimeout(async () => { // Guard: skip if monitor was stopped while this callback was queued - if (monitorStopped) return; + if (state.stopped || activeRuntimeMonitors.get(monitorKey) !== state) return; try { const quota = await fetchQuotaWithDedup(provider, accountId); - if (monitorStopped) return; // Re-check after async fetch - const avgQuota = calculateQuotaPercent(quota); + if (state.stopped || activeRuntimeMonitors.get(monitorKey) !== state) return; + const avgQuota = calculateQuotaPercent(provider, quota); if (avgQuota === null) { // Quota data unavailable: keep polling, but do not treat unknown as healthy/exhausted. @@ -564,7 +702,8 @@ function scheduleNextPoll( provider, accountId, monitorConfig, - monitorConfig.normal_interval_seconds * 1000 + monitorConfig.normal_interval_seconds * 1000, + state ); return; } @@ -576,22 +715,25 @@ function scheduleNextPoll( // default only takes effect on next session start via preflightCheck(). const { handleQuotaExhaustion } = await import('./account-safety'); await handleQuotaExhaustion(provider, accountId, monitorConfig.cooldown_minutes); - monitorTimer = null; + state.stopped = true; + state.timer = null; + activeRuntimeMonitors.delete(monitorKey); return; // Stop polling } if (avgQuota <= monitorConfig.warn_threshold) { // WARNING: switch to critical interval, warn once - if (!hasWarnedThisSession) { + if (!state.hasWarnedThisSession) { const { writeQuotaWarning } = await import('./account-safety'); writeQuotaWarning(accountId, avgQuota); - hasWarnedThisSession = true; + state.hasWarnedThisSession = true; } scheduleNextPoll( provider, accountId, monitorConfig, - monitorConfig.critical_interval_seconds * 1000 + monitorConfig.critical_interval_seconds * 1000, + state ); return; } @@ -601,17 +743,20 @@ function scheduleNextPoll( provider, accountId, monitorConfig, - monitorConfig.normal_interval_seconds * 1000 + monitorConfig.normal_interval_seconds * 1000, + state ); } catch { // API failure: silently reschedule at same interval - scheduleNextPoll(provider, accountId, monitorConfig, intervalMs); + if (!state.stopped && activeRuntimeMonitors.get(monitorKey) === state) { + scheduleNextPoll(provider, accountId, monitorConfig, intervalMs, state); + } } }, intervalMs); // Prevent monitor from keeping Node.js process alive - if (monitorTimer && typeof monitorTimer === 'object' && 'unref' in monitorTimer) { - monitorTimer.unref(); + if (state.timer && typeof state.timer === 'object' && 'unref' in state.timer) { + state.timer.unref(); } } @@ -628,9 +773,6 @@ export function startQuotaMonitor(provider: CLIProxyProvider, accountId: string) // Only managed providers support runtime quota monitoring. if (!isManagedQuotaProvider(provider)) return; - // Prevent duplicate monitors - if (monitorTimer) return; - const config = loadOrCreateUnifiedConfig(); const quotaConfig = config.quota_management; @@ -647,15 +789,23 @@ export function startQuotaMonitor(provider: CLIProxyProvider, accountId: string) return; // Invalid config — skip monitoring silently (logged at config level) } - hasWarnedThisSession = false; - monitorStopped = false; + const monitorKey = getCacheKey(provider, accountId); + if (activeRuntimeMonitors.has(monitorKey)) return; + + const state: RuntimeMonitorState = { + timer: null, + hasWarnedThisSession: false, + stopped: false, + }; + activeRuntimeMonitors.set(monitorKey, state); // Start first poll at normal interval scheduleNextPoll( provider, accountId, quotaConfig.runtime_monitor, - quotaConfig.runtime_monitor.normal_interval_seconds * 1000 + quotaConfig.runtime_monitor.normal_interval_seconds * 1000, + state ); } @@ -663,10 +813,12 @@ export function startQuotaMonitor(provider: CLIProxyProvider, accountId: string) * Stop the runtime quota monitor. Safe to call multiple times. */ export function stopQuotaMonitor(): void { - monitorStopped = true; - if (monitorTimer) { - clearTimeout(monitorTimer); - monitorTimer = null; + for (const state of activeRuntimeMonitors.values()) { + state.stopped = true; + if (state.timer) { + clearTimeout(state.timer); + state.timer = null; + } } - hasWarnedThisSession = false; + activeRuntimeMonitors.clear(); } diff --git a/src/cliproxy/quota-types.ts b/src/cliproxy/quota-types.ts index e768ec9b..5fee4d88 100644 --- a/src/cliproxy/quota-types.ts +++ b/src/cliproxy/quota-types.ts @@ -29,10 +29,10 @@ export interface QuotaErrorMetadata { } /** - * Codex quota window (primary, secondary, code review) + * Codex quota window (primary, secondary, code review, additional) */ export interface CodexQuotaWindow { - /** Window label: "Primary", "Secondary", "Code Review (Primary)", "Code Review (Secondary)" */ + /** Window label: "Primary", "Secondary", "Code Review (Primary)", "Code Review (Secondary)", or " (Primary|Secondary)" */ label: string; /** Percentage used (0-100) */ usedPercent: number; @@ -42,6 +42,18 @@ export interface CodexQuotaWindow { resetAfterSeconds: number | null; /** ISO timestamp when quota resets, null if unknown */ resetAt: string | null; + /** + * Window category indicating the bucket this window belongs to. + * Optional for back-compat with cached data emitted before this field existed. + * - 'usage' -> standard rate_limit usage windows + * - 'code-review' -> code_review_rate_limit windows + * - 'additional' -> additional_rate_limits[] windows (e.g. GPT-5.3 Codex Spark) + */ + category?: 'usage' | 'code-review' | 'additional'; + /** Cadence of the window: '5h' = primary, 'weekly' = secondary. Optional for legacy data. */ + cadence?: '5h' | 'weekly'; + /** Raw upstream label (e.g. 'GPT-5.3-Codex-Spark', 'Code Review'); absent for plain usage windows. */ + featureLabel?: string; } /** Core Codex usage window (5h/weekly) extracted from raw windows */ @@ -218,6 +230,8 @@ export interface GeminiCliQuotaResult extends QuotaErrorMetadata { * GitHub Copilot quota snapshot. */ export interface GhcpQuotaSnapshot { + /** False when upstream omitted this quota category; callers should exclude it from health math */ + reported?: boolean; /** Total quota allocation for this category */ entitlement: number; /** Remaining quota count */ diff --git a/src/cliproxy/routing-strategy-http.ts b/src/cliproxy/routing-strategy-http.ts index 33d7b070..81eeb558 100644 --- a/src/cliproxy/routing-strategy-http.ts +++ b/src/cliproxy/routing-strategy-http.ts @@ -7,13 +7,18 @@ import { } from './proxy-target-resolver'; const ROUTING_TIMEOUT_MS = 5000; +const CLIPROXY_ROUTING_MANAGEMENT_PATH = '/v0/management/routing/strategy'; + +export function getCliproxyRoutingManagementUrl(target: ProxyTarget): string { + return buildProxyUrl(target, CLIPROXY_ROUTING_MANAGEMENT_PATH); +} export async function fetchCliproxyRoutingResponse( target: ProxyTarget, method: 'GET' | 'PUT', body?: Record ): Promise { - const url = buildProxyUrl(target, '/routing/strategy'); + const url = getCliproxyRoutingManagementUrl(target); const headers = buildManagementHeaders( target, body ? { 'Content-Type': 'application/json' } : {} diff --git a/src/cliproxy/routing-strategy.ts b/src/cliproxy/routing-strategy.ts index ff621a9b..5cc97735 100644 --- a/src/cliproxy/routing-strategy.ts +++ b/src/cliproxy/routing-strategy.ts @@ -9,6 +9,11 @@ import { import type { CliproxyRoutingStrategy } from './types'; export const DEFAULT_CLIPROXY_ROUTING_STRATEGY: CliproxyRoutingStrategy = 'round-robin'; +export const DEFAULT_CLIPROXY_SESSION_AFFINITY_ENABLED = false; +export const DEFAULT_CLIPROXY_SESSION_AFFINITY_TTL = '1h'; + +const GO_DURATION_SEGMENT = String.raw`(?:\d+(?:\.\d+)?(?:ns|us|µs|μs|ms|s|m|h))`; +const GO_DURATION_PATTERN = new RegExp(`^${GO_DURATION_SEGMENT}+$`); export interface CliproxyRoutingState { strategy: CliproxyRoutingStrategy; @@ -22,6 +27,25 @@ export interface CliproxyRoutingApplyResult extends CliproxyRoutingState { applied: 'live' | 'live-and-config' | 'config-only'; } +export interface CliproxySessionAffinitySettings { + enabled: boolean; + ttl?: string; +} + +export interface CliproxySessionAffinityState { + enabled?: boolean; + ttl?: string; + source: 'config' | 'unsupported'; + target: 'local' | 'remote'; + reachable: boolean; + manageable: boolean; + message?: string; +} + +export interface CliproxySessionAffinityApplyResult extends CliproxySessionAffinityState { + applied: 'config-only' | 'unsupported'; +} + export function normalizeCliproxyRoutingStrategy(value: unknown): CliproxyRoutingStrategy | null { if (typeof value !== 'string') { return null; @@ -41,6 +65,47 @@ export function normalizeCliproxyRoutingStrategy(value: unknown): CliproxyRoutin } } +export function normalizeCliproxySessionAffinityEnabled(value: unknown): boolean | null { + if (typeof value === 'boolean') { + return value; + } + if (typeof value !== 'string') { + return null; + } + + switch (value.trim().toLowerCase()) { + case 'true': + case '1': + case 'yes': + case 'on': + case 'enable': + case 'enabled': + return true; + case 'false': + case '0': + case 'no': + case 'off': + case 'disable': + case 'disabled': + return false; + default: + return null; + } +} + +export function normalizeCliproxySessionAffinityTtl(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + + const trimmed = value.trim(); + if (!trimmed || !GO_DURATION_PATTERN.test(trimmed) || !hasPositiveDuration(trimmed)) { + return null; + } + + return trimmed; +} + export function getConfiguredCliproxyRoutingStrategy(): CliproxyRoutingStrategy { return ( normalizeCliproxyRoutingStrategy(loadOrCreateUnifiedConfig().cliproxy?.routing?.strategy) ?? @@ -48,6 +113,18 @@ export function getConfiguredCliproxyRoutingStrategy(): CliproxyRoutingStrategy ); } +export function getConfiguredCliproxySessionAffinitySettings(): Required { + const routing = loadOrCreateUnifiedConfig().cliproxy?.routing; + return { + enabled: + normalizeCliproxySessionAffinityEnabled(routing?.session_affinity) ?? + DEFAULT_CLIPROXY_SESSION_AFFINITY_ENABLED, + ttl: + normalizeCliproxySessionAffinityTtl(routing?.session_affinity_ttl) ?? + DEFAULT_CLIPROXY_SESSION_AFFINITY_TTL, + }; +} + export async function fetchLiveCliproxyRoutingStrategy(): Promise { const response = await fetchCliproxyRoutingResponse(getCliproxyRoutingTarget(), 'GET'); if (!response.ok) { @@ -95,6 +172,38 @@ export async function readCliproxyRoutingState(): Promise } } +export async function readCliproxySessionAffinityState(): Promise { + const target = getCliproxyRoutingTarget(); + + if (target.isRemote) { + const reachable = await isLiveCliproxyRoutingReachable(); + return { + source: 'unsupported', + target: 'remote', + reachable, + manageable: false, + message: reachable + ? 'Remote session-affinity management is not supported from CCS yet because upstream management APIs only expose routing.strategy.' + : 'Remote session-affinity management is not supported from CCS yet, and the remote CLIProxy routing endpoint is not reachable.', + }; + } + + const settings = getConfiguredCliproxySessionAffinitySettings(); + const reachable = await isLiveCliproxyRoutingReachable(); + + return { + enabled: settings.enabled, + ttl: settings.ttl, + source: 'config', + target: 'local', + reachable, + manageable: true, + message: reachable + ? 'Showing the saved local session-affinity setting. Running local CLIProxy may hot-reload config changes, but CCS does not verify live selector state.' + : 'Local CLIProxy is not reachable. Showing the saved local startup default.', + }; +} + export async function applyCliproxyRoutingStrategy( strategy: CliproxyRoutingStrategy ): Promise { @@ -116,7 +225,7 @@ export async function applyCliproxyRoutingStrategy( mutateUnifiedConfig((config) => { if (config.cliproxy) { - config.cliproxy.routing = { strategy }; + config.cliproxy.routing = { ...config.cliproxy.routing, strategy }; } }); regenerateConfig(target.port, { configPath, authDir }); @@ -143,6 +252,58 @@ export async function applyCliproxyRoutingStrategy( } } +export async function applyCliproxySessionAffinitySettings( + settings: CliproxySessionAffinitySettings +): Promise { + const target = getCliproxyRoutingTarget(); + if (target.isRemote) { + const reachable = await isLiveCliproxyRoutingReachable(); + return { + source: 'unsupported', + target: 'remote', + reachable, + manageable: false, + applied: 'unsupported', + message: reachable + ? 'Remote session-affinity management is not supported from CCS yet because upstream management APIs only expose routing.strategy.' + : 'Remote session-affinity management is not supported from CCS yet, and the remote CLIProxy routing endpoint is not reachable.', + }; + } + + const configPath = getConfigPathForPort(target.port); + const authDir = getAuthDir(); + const current = getConfiguredCliproxySessionAffinitySettings(); + const ttl = + normalizeCliproxySessionAffinityTtl(settings.ttl) ?? + current.ttl ?? + DEFAULT_CLIPROXY_SESSION_AFFINITY_TTL; + + mutateUnifiedConfig((config) => { + if (config.cliproxy) { + config.cliproxy.routing = { + ...config.cliproxy.routing, + session_affinity: settings.enabled, + session_affinity_ttl: ttl, + }; + } + }); + regenerateConfig(target.port, { configPath, authDir }); + + const reachable = await isLiveCliproxyRoutingReachable(); + return { + enabled: settings.enabled, + ttl, + source: 'config', + target: 'local', + reachable, + manageable: true, + applied: 'config-only', + message: reachable + ? 'Saved the local startup default. Running local CLIProxy may hot-reload the session-affinity setting, but CCS does not verify live selector state yet.' + : 'Saved the local startup default. It will apply the next time local CLIProxy starts.', + }; +} + async function updateLiveCliproxyRoutingStrategy(strategy: CliproxyRoutingStrategy): Promise { const response = await fetchCliproxyRoutingResponse(getCliproxyRoutingTarget(), 'PUT', { value: strategy, @@ -156,3 +317,24 @@ async function updateLiveCliproxyRoutingStrategy(strategy: CliproxyRoutingStrate ); } } + +function hasPositiveDuration(value: string): boolean { + const segments = value.match(new RegExp(GO_DURATION_SEGMENT, 'g')); + if (!segments) { + return false; + } + + return segments.some((segment) => { + const numeric = parseFloat(segment); + return Number.isFinite(numeric) && numeric > 0; + }); +} + +async function isLiveCliproxyRoutingReachable(): Promise { + try { + await fetchLiveCliproxyRoutingStrategy(); + return true; + } catch { + return false; + } +} diff --git a/src/cliproxy/service-manager.ts b/src/cliproxy/service-manager.ts index b97add9f..7b1612e6 100644 --- a/src/cliproxy/service-manager.ts +++ b/src/cliproxy/service-manager.ts @@ -138,6 +138,18 @@ export interface ServiceStartResult { error?: string; } +/** + * Test-only seams for ensureCliproxyService. Production callers omit this and + * get the real implementations. Tests inject stubs to avoid bun's + * `mock.module()`, which is process-wide and leaks across test files. + */ +export interface EnsureCliproxyServiceDeps { + ensureBinaryFn?: typeof ensureCLIProxyBinary; + detectRunningProxyFn?: typeof detectRunningProxy; + configNeedsRegenerationFn?: typeof configNeedsRegeneration; + withStartupLockFn?: typeof withStartupLock; +} + /** * Ensure CLIProxy service is running * @@ -146,12 +158,18 @@ export interface ServiceStartResult { * * @param port CLIProxy port (default: 8317) * @param verbose Show debug output + * @param deps Test-only dependency overrides (see EnsureCliproxyServiceDeps) * @returns Result indicating success and whether it was already running */ export async function ensureCliproxyService( port: number = CLIPROXY_DEFAULT_PORT, - verbose: boolean = false + verbose: boolean = false, + deps: EnsureCliproxyServiceDeps = {} ): Promise { + const ensureBinaryFn = deps.ensureBinaryFn ?? ensureCLIProxyBinary; + const detectRunningProxyFn = deps.detectRunningProxyFn ?? detectRunningProxy; + const configNeedsRegenerationFn = deps.configNeedsRegenerationFn ?? configNeedsRegeneration; + const withStartupLockFn = deps.withStartupLockFn ?? withStartupLock; const log = (msg: string) => { if (verbose) { console.error(`[cliproxy-service] ${msg}`); @@ -160,17 +178,17 @@ export async function ensureCliproxyService( // Check if config needs update (even if running) let configRegenerated = false; - if (configNeedsRegeneration()) { + if (configNeedsRegenerationFn()) { log('Config outdated, regenerating...'); regenerateConfig(port); configRegenerated = true; } // Use startup lock to coordinate with other CCS processes (ccs agy, ccs config, etc.) - return await withStartupLock(async () => { + return await withStartupLockFn(async () => { // Use unified detection (HTTP check + session-lock + port-process) log(`Checking if CLIProxy is running on port ${port}...`); - const proxyStatus = await detectRunningProxy(port); + const proxyStatus = await detectRunningProxyFn(port); log(`Proxy detection: ${JSON.stringify(proxyStatus)}`); if (proxyStatus.running && proxyStatus.verified) { @@ -216,7 +234,7 @@ export async function ensureCliproxyService( // 1. Ensure binary exists let binaryPath: string; try { - binaryPath = await ensureCLIProxyBinary(verbose, { + binaryPath = await ensureBinaryFn(verbose, { allowInstall: false, skipAutoUpdate: true, }); diff --git a/src/cliproxy/sync/profile-mapper.ts b/src/cliproxy/sync/profile-mapper.ts index 40583fa4..3a4a120c 100644 --- a/src/cliproxy/sync/profile-mapper.ts +++ b/src/cliproxy/sync/profile-mapper.ts @@ -149,6 +149,28 @@ export function mapProfileToClaudeKey(profile: SyncableProfile): ClaudeKey | nul alias: '', }, ]; + + // Append extra models from ANTHROPIC_EXTRA_MODELS env var (comma-separated). + // Skip duplicates of the primary model and any repeated entries to keep + // the synced CLIProxy `models` array clean. + const extraModelsRaw = env.ANTHROPIC_EXTRA_MODELS; + if (extraModelsRaw && extraModelsRaw.trim()) { + const seen = new Set([modelName]); + const extraModels = extraModelsRaw + .split(',') + .map((m) => m.trim()) + .filter((m) => m.length > 0); + for (const extra of extraModels) { + if (seen.has(extra)) { + continue; + } + seen.add(extra); + claudeKey.models.push({ + name: extra, + alias: '', + }); + } + } } return claudeKey; diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index 2202e345..2abd2052 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -4,6 +4,7 @@ */ import type { CompositeTierConfig } from '../config/unified-config-types'; +import type { UpdateCheckResult } from './binary/types'; /** * Supported operating systems @@ -56,6 +57,18 @@ export interface BinaryManagerConfig { allowInstall: boolean; /** Backend variant (original vs plus) */ backend?: CLIProxyBackend; + /** + * Test-only seam: override the auto-update check. When omitted, the real + * `checkForUpdates` from ./binary/version-checker is used. Provided so tests + * can verify "skipAutoUpdate respected" without resorting to bun's + * `mock.module()`, which leaks across test files in the same process. + */ + checkForUpdatesFn?: ( + binPath: string, + configVersion: string, + verbose?: boolean, + backend?: CLIProxyBackend + ) => Promise; } /** @@ -181,6 +194,9 @@ export interface CLIProxyConfig { debug: boolean; routing?: { strategy?: CliproxyRoutingStrategy; + 'session-affinity'?: boolean; + 'session-affinity-ttl'?: string; + 'claude-code-session-affinity'?: boolean; }; 'gemini-api-key'?: Array<{ 'api-key': string; diff --git a/src/commands/api-command/create-command.ts b/src/commands/api-command/create-command.ts index 1909c2dc..b0d799f8 100644 --- a/src/commands/api-command/create-command.ts +++ b/src/commands/api-command/create-command.ts @@ -493,7 +493,15 @@ export async function handleApiCreateCommand(args: string[]): Promise { console.log(''); console.log(info('Creating API profile...')); - const result = createApiProfile(name, baseUrl || '', apiKey, finalModels, target); + const result = createApiProfile( + name, + baseUrl || '', + apiKey, + finalModels, + target, + undefined, + parsedArgs.extraModels + ); if (!result.success) { console.log(fail(`Failed to create API profile: ${result.error}`)); process.exit(1); @@ -534,6 +542,10 @@ export async function handleApiCreateCommand(args: string[]): Promise { ` Haiku: ${finalModels.haiku}`; } + if (parsedArgs.extraModels && parsedArgs.extraModels.length > 0) { + details += `\nExtraModels: ${parsedArgs.extraModels.join(', ')}`; + } + console.log(''); console.log(infoBox(details, 'API Profile Created')); if (hasClaudeMappings) { diff --git a/src/commands/api-command/help.ts b/src/commands/api-command/help.ts index 000a1840..2da106d2 100644 --- a/src/commands/api-command/help.ts +++ b/src/commands/api-command/help.ts @@ -55,6 +55,9 @@ export async function showApiCommandHelp(writeLine: HelpWriter = console.log): P writeLine(` ${color('--base-url ', 'command')} API base URL (create)`); writeLine(` ${color('--api-key ', 'command')} API key (create)`); writeLine(` ${color('--model ', 'command')} Default model (create)`); + writeLine( + ` ${color('--extra-models ', 'command')} Comma-separated extra models to expose alongside --model` + ); writeLine( ` ${color('--1m / --no-1m', 'command')} Write or clear [1m] on compatible Claude mappings` ); @@ -96,6 +99,11 @@ export async function showApiCommandHelp(writeLine: HelpWriter = console.log): P ); writeLine(` ${color('ccs api create --preset glm', 'command')}`); writeLine(''); + writeLine(` ${dim('# Expose multiple models from one provider endpoint')}`); + writeLine( + ` ${color('ccs api create dashscope --base-url https://... --api-key sk-xxx --model qwen3-coder-plus --extra-models glm-4.6,kimi-k2', 'command')}` + ); + writeLine(''); writeLine(subheader('Claude Long Context')); writeLine(` ${dim('Plain Claude model IDs stay on standard context by default.')}`); writeLine( diff --git a/src/commands/api-command/shared.ts b/src/commands/api-command/shared.ts index 37dfe35f..657cbb3d 100644 --- a/src/commands/api-command/shared.ts +++ b/src/commands/api-command/shared.ts @@ -17,6 +17,7 @@ export interface ApiCommandArgs { baseUrl?: string; apiKey?: string; model?: string; + extraModels?: string[]; preset?: string; cliproxyProvider?: string; target?: TargetType; @@ -33,6 +34,7 @@ export const API_VALUE_FLAGS = [ '--base-url', '--api-key', '--model', + '--extra-models', '--preset', '--cliproxy-provider', '--target', @@ -222,6 +224,21 @@ export function parseApiCommandArgs( true ); + remaining = applyRepeatedOption( + remaining, + ['--extra-models'], + (value) => { + result.extraModels = value + .split(',') + .map((m) => m.trim()) + .filter((m) => m.length > 0); + }, + () => { + result.errors.push('Missing value for --extra-models'); + }, + false + ); + remaining = applyRepeatedOption( remaining, ['--preset'], diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index ccecad99..f349be7e 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -59,8 +59,10 @@ export async function showHelp(): Promise { ['quota', 'Show quota status for all providers (Codex/Claude include 5h + weekly reset)'], ['quota --provider ', `Filter by provider (${QUOTA_PROVIDER_HELP_TEXT})`], ['routing', 'Show current routing strategy and manual guidance'], - ['routing explain', 'Explain round-robin vs fill-first'], + ['routing explain', 'Explain strategy vs session-affinity and how sessions are recognized'], ['routing set ', 'Explicitly set round-robin or fill-first'], + ['routing affinity', 'Show local session-affinity status and TTL'], + ['routing affinity [--ttl ]', 'Toggle local session-affinity settings'], ], ], [ diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index b7de5ebb..02ae8bc5 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -34,7 +34,14 @@ import { } from './proxy-lifecycle-subcommand'; import { showStatus, handleInstallVersion, handleInstallLatest } from './install-subcommand'; import { showHelp } from './help-subcommand'; -import { handleRoutingStatus, handleRoutingExplain, handleRoutingSet } from './routing-subcommand'; +import { + handleRoutingStatus, + handleRoutingExplain, + handleRoutingSet, + handleRoutingAffinityStatus, + handleRoutingAffinityHelp, + handleRoutingAffinitySet, +} from './routing-subcommand'; import { handleCatalogStatus, handleCatalogRefresh, @@ -189,6 +196,18 @@ export async function handleCliproxyCommand(args: string[]): Promise { await handleRoutingExplain(); return; } + if (subcommand === 'affinity') { + if (hasAnyFlag(remainingArgs.slice(2), ['--help', '-h'])) { + await handleRoutingAffinityHelp(); + return; + } + if (remainingArgs[2]) { + await handleRoutingAffinitySet(remainingArgs.slice(2)); + return; + } + await handleRoutingAffinityStatus(); + return; + } await handleRoutingStatus(); return; } diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index 5df3faaf..e41fab95 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -238,7 +238,10 @@ function getCodexWindowKind(label: string): CodexWindowKind { return 'unknown'; } -type CodexWindowSummary = Pick; +type CodexWindowSummary = Pick< + CodexQuotaResult['windows'][number], + 'label' | 'resetAfterSeconds' | 'category' | 'cadence' | 'featureLabel' +>; function inferCodeReviewCadence( window: CodexWindowSummary, @@ -272,12 +275,46 @@ function inferCodeReviewCadence( return diffToWeekly <= diffTo5h ? 'weekly' : '5h'; } +/** + * Strip a leading "GPT-X.Y-Codex-" prefix from a feature label and turn the + * remainder into a Codex-prefixed display name. Other labels pass through unchanged. + */ +function prettifyCodexFeatureLabel(featureLabel: string): string { + const trimmed = featureLabel.trim(); + if (!trimmed) return 'Additional'; + const stripped = trimmed.replace(/^GPT-[\d.]+-Codex-/i, ''); + if (stripped !== trimmed && stripped.length > 0) { + return `Codex ${stripped}`; + } + return trimmed; +} + function getCodexWindowDisplayLabel( window: CodexWindowSummary, allWindows: CodexWindowSummary[] = [] ): string { const context = allWindows.length > 0 ? allWindows : [window]; + // Prefer explicit category metadata when present (post-2026-04 windows). + if (window.category === 'usage') { + if (window.cadence === '5h') return '5h usage limit'; + if (window.cadence === 'weekly') return 'Weekly usage limit'; + } + + if (window.category === 'additional') { + const pretty = prettifyCodexFeatureLabel(window.featureLabel || window.label || 'Additional'); + if (window.cadence === '5h') return `${pretty} (5h)`; + if (window.cadence === 'weekly') return `${pretty} (weekly)`; + return pretty; + } + + if (window.category === 'code-review') { + if (window.cadence === '5h') return 'Code review (5h)'; + if (window.cadence === 'weekly') return 'Code review (weekly)'; + return 'Code review'; + } + + // Legacy fallback: classify via label sniffing for cached windows without metadata. switch (getCodexWindowKind(window.label)) { case 'usage-5h': return '5h usage limit'; @@ -304,20 +341,35 @@ function getCodexCoreUsageWindows(windows: CodexQuotaResult['windows']): { let weeklyWindow: CodexQuotaResult['windows'][number] | null = null; const nonCodeReviewWindows: CodexQuotaResult['windows'] = []; - for (const window of windows) { - const kind = getCodexWindowKind(window.label); - if (kind === 'usage-5h') { - if (!fiveHourWindow) fiveHourWindow = window; - nonCodeReviewWindows.push(window); - continue; + // Prefer explicit category metadata when present so 'additional' windows + // (e.g. GPT-5.3 Codex Spark) do not displace core usage windows in the summary. + const hasCategoryMetadata = windows.some((window) => Boolean(window.category)); + + if (hasCategoryMetadata) { + for (const window of windows) { + if (window.category === 'usage') { + if (window.cadence === '5h' && !fiveHourWindow) fiveHourWindow = window; + else if (window.cadence === 'weekly' && !weeklyWindow) weeklyWindow = window; + nonCodeReviewWindows.push(window); + } + // 'code-review' and 'additional' are excluded from the core usage summary. } - if (kind === 'usage-weekly') { - if (!weeklyWindow) weeklyWindow = window; - nonCodeReviewWindows.push(window); - continue; - } - if (kind === 'unknown') { - nonCodeReviewWindows.push(window); + } else { + for (const window of windows) { + const kind = getCodexWindowKind(window.label); + if (kind === 'usage-5h') { + if (!fiveHourWindow) fiveHourWindow = window; + nonCodeReviewWindows.push(window); + continue; + } + if (kind === 'usage-weekly') { + if (!weeklyWindow) weeklyWindow = window; + nonCodeReviewWindows.push(window); + continue; + } + if (kind === 'unknown') { + nonCodeReviewWindows.push(window); + } } } @@ -700,13 +752,17 @@ function displayGhcpQuotaSection(results: { account: string; quota: GhcpQuotaRes continue; } - const rows = [ - quota.snapshots.premiumInteractions.percentRemaining, - quota.snapshots.chat.percentRemaining, - quota.snapshots.completions.percentRemaining, - ]; - const minQuota = rows.length > 0 ? Math.min(...rows) : 0; - const statusIcon = minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail(''); + const reportedSnapshots = [ + quota.snapshots.premiumInteractions, + quota.snapshots.chat, + quota.snapshots.completions, + ].filter((snapshot) => snapshot.reported !== false); + const rows = reportedSnapshots.map((snapshot) => + snapshot.unlimited ? 100 : snapshot.percentRemaining + ); + const minQuota = rows.length > 0 ? Math.min(...rows) : null; + const statusIcon = + minQuota === null ? info('') : minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail(''); const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : ''; console.log(` ${statusIcon}${accountLabel}${defaultMark}${planBadge}`); @@ -714,12 +770,14 @@ function displayGhcpQuotaSection(results: { account: string; quota: GhcpQuotaRes console.log(` ${dim(`Resets ${formatResetTimeISO(quota.quotaResetDate)}`)}`); } - const items: Array<[string, GhcpQuotaResult['snapshots'][keyof GhcpQuotaResult['snapshots']]]> = - [ - ['Premium interactions', quota.snapshots.premiumInteractions], - ['Chat', quota.snapshots.chat], - ['Completions', quota.snapshots.completions], - ]; + const allItems: Array< + [string, GhcpQuotaResult['snapshots'][keyof GhcpQuotaResult['snapshots']]] + > = [ + ['Premium interactions', quota.snapshots.premiumInteractions], + ['Chat', quota.snapshots.chat], + ['Completions', quota.snapshots.completions], + ]; + const items = allItems.filter(([, snapshot]) => snapshot.reported !== false); for (const [label, snapshot] of items) { const bar = formatQuotaBar(snapshot.percentRemaining); diff --git a/src/commands/cliproxy/routing-subcommand.ts b/src/commands/cliproxy/routing-subcommand.ts index f78aed21..45fcfd14 100644 --- a/src/commands/cliproxy/routing-subcommand.ts +++ b/src/commands/cliproxy/routing-subcommand.ts @@ -1,8 +1,13 @@ import { initUI, header, subheader, color, dim, ok, fail, infoBox } from '../../utils/ui'; +import { extractOption } from '../arg-extractor'; import { applyCliproxyRoutingStrategy, + applyCliproxySessionAffinitySettings, normalizeCliproxyRoutingStrategy, + normalizeCliproxySessionAffinityEnabled, + normalizeCliproxySessionAffinityTtl, readCliproxyRoutingState, + readCliproxySessionAffinityState, } from '../../cliproxy/routing-strategy'; function printStrategyGuide(): void { @@ -23,13 +28,52 @@ function printStrategyGuide(): void { console.log(''); } +function printSessionAffinityGuide(): void { + console.log(subheader('Session Affinity:')); + console.log( + ` ${color('session-affinity off', 'command')} Each request follows the base routing strategy.` + ); + console.log(` ${dim(' Best when you want pure proxy-wide balancing behavior.')}`); + console.log(''); + console.log( + ` ${color('session-affinity on', 'command')} Keep one conversation pinned to the same account when possible.` + ); + console.log( + ` ${dim(' Best when you want stronger prompt-cache locality for a single conversation.')}` + ); + console.log(''); +} + +function printSessionRecognitionGuide(): void { + console.log(subheader('How CLIProxy Knows A Session Is New:')); + console.log( + ` ${dim(' CLIProxy prefers explicit session or thread identifiers when clients send them.')}` + ); + console.log( + ` ${dim(' Common examples: Claude session UUIDs, X-Session-ID, or provider-specific thread ids.')}` + ); + console.log( + ` ${dim(' If no explicit identifier is present, it can fall back to fields such as metadata.user_id or conversation_id.')}` + ); + console.log( + ` ${dim(' Last resort: it derives a stable key from the opening prompt history.')}` + ); + console.log( + ` ${dim(' Exact precedence can vary by upstream backend/runtime version, so CCS does not promise one universal order.')}` + ); + console.log(''); +} + export async function handleRoutingStatus(): Promise { await initUI(); console.log(''); console.log(header('CLIProxy Routing Strategy')); console.log(''); - const state = await readCliproxyRoutingState(); + const [state, sessionAffinity] = await Promise.all([ + readCliproxyRoutingState(), + readCliproxySessionAffinityState(), + ]); console.log(` Current: ${color(state.strategy, 'command')}`); console.log(` Target: ${color(state.target, 'info')}`); console.log( @@ -39,8 +83,28 @@ export async function handleRoutingStatus(): Promise { console.log(''); console.log(infoBox(state.message, state.reachable ? 'INFO' : 'WARNING')); } + console.log( + ` Session Affinity: ${ + sessionAffinity.manageable + ? color(sessionAffinity.enabled ? 'on' : 'off', 'command') + : color('unsupported', 'warning') + }` + ); + if (sessionAffinity.ttl) { + console.log(` Affinity TTL: ${color(sessionAffinity.ttl, 'info')}`); + } + if (sessionAffinity.message) { + console.log(''); + console.log( + infoBox( + sessionAffinity.message, + sessionAffinity.manageable && sessionAffinity.reachable ? 'INFO' : 'WARNING' + ) + ); + } console.log(''); printStrategyGuide(); + printSessionAffinityGuide(); } export async function handleRoutingExplain(): Promise { @@ -49,6 +113,8 @@ export async function handleRoutingExplain(): Promise { console.log(header('CLIProxy Routing Guide')); console.log(''); printStrategyGuide(); + printSessionAffinityGuide(); + printSessionRecognitionGuide(); } export async function handleRoutingSet(args: string[]): Promise { @@ -78,3 +144,105 @@ export async function handleRoutingSet(args: string[]): Promise { } console.log(''); } + +export async function handleRoutingAffinityStatus(): Promise { + await initUI(); + console.log(''); + console.log(header('CLIProxy Session Affinity')); + console.log(''); + + const state = await readCliproxySessionAffinityState(); + if (!state.manageable) { + console.log(` Status: ${color('unsupported', 'warning')}`); + } else { + console.log(` Status: ${color(state.enabled ? 'on' : 'off', 'command')}`); + } + console.log( + ` Source: ${state.manageable ? color('saved local setting', 'info') : color('unsupported', 'warning')}` + ); + console.log(` Target: ${color(state.target, 'info')}`); + if (state.ttl) { + console.log(` TTL: ${color(state.ttl, 'info')}`); + } + if (state.message) { + console.log(''); + console.log(infoBox(state.message, state.manageable && state.reachable ? 'INFO' : 'WARNING')); + } + console.log(''); + printSessionAffinityGuide(); + printSessionRecognitionGuide(); +} + +export async function handleRoutingAffinityHelp(): Promise { + await initUI(); + console.log(''); + console.log(header('CLIProxy Session Affinity')); + console.log(''); + console.log(subheader('Usage:')); + console.log(` ${color('ccs cliproxy routing affinity', 'command')}`); + console.log(` ${color('ccs cliproxy routing affinity on', 'command')}`); + console.log(` ${color('ccs cliproxy routing affinity off', 'command')}`); + console.log(` ${color('ccs cliproxy routing affinity on --ttl 1h', 'command')}`); + console.log(''); + printSessionAffinityGuide(); + console.log(` ${dim('Accepted TTL examples: 30m, 1h, 2h30m')}`); + console.log(''); + printSessionRecognitionGuide(); +} + +export async function handleRoutingAffinitySet(args: string[]): Promise { + const requested = normalizeCliproxySessionAffinityEnabled(args[0]); + const extractedTtl = extractOption(args.slice(1), ['--ttl']); + const remainingArgs = extractedTtl.remainingArgs.filter((token) => token.trim().length > 0); + const ttl: string | undefined = + extractedTtl.found && !extractedTtl.missingValue + ? (normalizeCliproxySessionAffinityTtl(extractedTtl.value) ?? undefined) + : undefined; + + if ( + requested === null || + extractedTtl.missingValue || + (extractedTtl.found && !ttl) || + remainingArgs.length > 0 + ) { + await initUI(); + console.log(''); + console.log( + fail('Invalid session affinity command. Use: routing affinity [--ttl 1h]') + ); + console.log(''); + printSessionAffinityGuide(); + console.log(` ${dim('Accepted TTL examples: 30m, 1h, 2h30m')}`); + console.log(''); + process.exitCode = 1; + return; + } + + await initUI(); + console.log(''); + console.log(header('Update CLIProxy Session Affinity')); + console.log(''); + + const result = await applyCliproxySessionAffinitySettings({ + enabled: requested, + ttl, + }); + + if (!result.manageable) { + console.log(fail(result.message || 'Session affinity is not supported for this target.')); + console.log(''); + process.exitCode = 1; + return; + } + + console.log(ok(`Session affinity ${requested ? 'enabled' : 'disabled'}`)); + if (result.ttl) { + console.log(` TTL: ${color(result.ttl, 'info')}`); + } + console.log(` Applied: ${color(result.applied, 'info')}`); + if (result.message) { + console.log(''); + console.log(infoBox(result.message, result.reachable ? 'SUCCESS' : 'INFO')); + } + console.log(''); +} diff --git a/src/commands/command-catalog.ts b/src/commands/command-catalog.ts index a1ad558f..2006422e 100644 --- a/src/commands/command-catalog.ts +++ b/src/commands/command-catalog.ts @@ -225,7 +225,10 @@ export const ROOT_PROFILE_EXAMPLES: readonly ShortcutEntry[] = [ export const ROOT_COMPATIBLE_ALIAS_EXAMPLES: readonly ShortcutEntry[] = [ { name: '--target claude|droid|codex', summary: 'Route a profile to the target runtime' }, { name: 'ccs-droid / ccsd', summary: 'Explicit Droid runtime aliases' }, - { name: 'ccs-codex / ccsx / ccsxp', summary: 'Explicit Codex runtime aliases' }, + { + name: 'ccs-codex / ccsx / ccsxp', + summary: 'Codex runtime aliases plus the cliproxy shortcut', + }, ] as const; export const ROOT_COMMAND_FLAGS = [ diff --git a/src/commands/completion-backend.ts b/src/commands/completion-backend.ts index c968c720..660503af 100644 --- a/src/commands/completion-backend.ts +++ b/src/commands/completion-backend.ts @@ -168,10 +168,27 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg '-h', ]); if (subcommand === 'routing') { + const routingSubcommand = tokensBeforeCurrent[2]; + const routingAffinityMode = tokensBeforeCurrent[3]; if (lastToken === 'set') { return completeSubcommands(['round-robin', 'fill-first']); } - return completeSubcommands(['set', 'explain']); + if (routingSubcommand === 'affinity') { + if (!routingAffinityMode || lastToken === 'affinity') { + return completeSubcommands(['on', 'off']); + } + if ( + (routingAffinityMode === 'on' || routingAffinityMode === 'off') && + !tokensBeforeCurrent.includes('--ttl') + ) { + return completeSubcommands([], ['--ttl']); + } + if (lastToken === '--ttl') { + return []; + } + return completeSubcommands([]); + } + return completeSubcommands(['set', 'explain', 'affinity']); } if (['remove', 'edit'].includes(subcommand)) { return completeSubcommands(getProfileNames('cliproxyVariants'), ['--yes', '-y']); diff --git a/src/commands/config-dashboard-host.ts b/src/commands/config-dashboard-host.ts index 7a29242c..c0e10fb7 100644 --- a/src/commands/config-dashboard-host.ts +++ b/src/commands/config-dashboard-host.ts @@ -45,7 +45,7 @@ export function normalizeDashboardHost(host: string | undefined): string | undef export function resolveDashboardUrls( host: string | undefined, port: number, - networkInterfaces: NetworkInterfacesMap = os.networkInterfaces() + networkInterfaces?: NetworkInterfacesMap ): DashboardUrls { const bindHost = normalizeDashboardHost(host); if (!bindHost) { @@ -58,7 +58,7 @@ export function resolveDashboardUrls( return { bindHost, browserUrl: `http://localhost:${port}`, - networkUrls: getExternalIpv4Urls(port, networkInterfaces), + networkUrls: getExternalIpv4Urls(port, networkInterfaces ?? getNetworkInterfacesSafe()), }; } @@ -70,8 +70,12 @@ export function resolveDashboardUrls( function getExternalIpv4Urls( port: number, - networkInterfaces: NetworkInterfacesMap + networkInterfaces: NetworkInterfacesMap | undefined ): string[] | undefined { + if (!networkInterfaces) { + return undefined; + } + const urls: string[] = []; const seen = new Set(); @@ -97,6 +101,15 @@ function getExternalIpv4Urls( return urls.length > 0 ? urls : undefined; } +function getNetworkInterfacesSafe(): NetworkInterfacesMap | undefined { + try { + // Some constrained environments (for example proot) throw here; keep the local dashboard URL. + return os.networkInterfaces(); + } catch { + return undefined; + } +} + function formatHostForUrl(host: string): string { if (host.includes(':') && !host.startsWith('[') && !host.endsWith(']')) { return `[${host}]`; diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 261b2d72..16c2efb2 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -59,6 +59,8 @@ const CONFIG_YAML = 'config.yaml'; const CONFIG_JSON = 'config.json'; const CONFIG_LOCK = 'config.yaml.lock'; const LOCK_STALE_MS = 5000; // Lock is stale after 5 seconds +const GO_DURATION_SEGMENT = String.raw`(?:\d+(?:\.\d+)?(?:ns|us|µs|μs|ms|s|m|h))`; +const GO_DURATION_PATTERN = new RegExp(`^${GO_DURATION_SEGMENT}+$`); function normalizeBrowserDevtoolsPort(value: number | undefined): number { if (!Number.isFinite(value)) { @@ -112,6 +114,31 @@ function canonicalizeBrowserConfig( }; } +function normalizeSessionAffinityTtl(value: unknown, fallback: string): string { + if (typeof value !== 'string') { + return fallback; + } + + const trimmed = value.trim(); + if (!trimmed || !GO_DURATION_PATTERN.test(trimmed) || !hasPositiveDuration(trimmed)) { + return fallback; + } + + return trimmed; +} + +function hasPositiveDuration(value: string): boolean { + const segments = value.match(new RegExp(GO_DURATION_SEGMENT, 'g')); + if (!segments) { + return false; + } + + return segments.some((segment) => { + const numeric = parseFloat(segment); + return Number.isFinite(numeric) && numeric > 0; + }); +} + /** * Get path to unified config.yaml */ @@ -440,6 +467,14 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { partial.cliproxy?.routing?.strategy === 'round-robin' ? partial.cliproxy.routing.strategy : defaults.cliproxy.routing?.strategy, + session_affinity: + typeof partial.cliproxy?.routing?.session_affinity === 'boolean' + ? partial.cliproxy.routing.session_affinity + : defaults.cliproxy.routing?.session_affinity, + session_affinity_ttl: normalizeSessionAffinityTtl( + partial.cliproxy?.routing?.session_affinity_ttl, + defaults.cliproxy.routing?.session_affinity_ttl ?? '1h' + ), }, }, proxy: { diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 4639744c..ab4eca6c 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -205,6 +205,10 @@ export interface TokenRefreshSettings { export interface CLIProxyRoutingConfig { /** Credential selection strategy when multiple accounts match */ strategy?: CliproxyRoutingStrategy; + /** Keep one conversation pinned to the same account when possible */ + session_affinity?: boolean; + /** Go-style duration for session-affinity binding retention */ + session_affinity_ttl?: string; } /** @@ -1034,6 +1038,8 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { auto_sync: true, routing: { strategy: 'round-robin', + session_affinity: false, + session_affinity_ttl: '1h', }, }, proxy: { diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index 76e4bcdd..26e2760c 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -23,6 +23,7 @@ import { stripAnthropicRoutingEnv, stripClaudeCodeEnv, } from '../utils/shell-executor'; +import { createOpenAICompatLaunchSettings } from '../utils/openai-compat-launch-settings'; import { resolveProfileContinuityInheritance } from '../auth/profile-continuity-inheritance'; import { appendThirdPartyImageAnalysisToolArgs, @@ -255,8 +256,12 @@ export class HeadlessExecutor { // Smart slash command detection and preservation const processedPrompt = this._processSlashCommand(enhancedPrompt); + const launchSettings = openAICompatProfile + ? createOpenAICompatLaunchSettings(settingsPath, settings) + : { settingsPath, cleanup: () => {} }; + // Prepare arguments - const args: string[] = ['-p', processedPrompt, '--settings', settingsPath]; + const args: string[] = ['-p', processedPrompt, '--settings', launchSettings.settingsPath]; // Always use stream-json for real-time progress visibility args.push('--output-format', 'stream-json', '--verbose'); @@ -368,6 +373,7 @@ export class HeadlessExecutor { imageAnalysisEnv, runtimeEnvVars, traceEnv, + launchCleanup: launchSettings.cleanup, }); } @@ -388,6 +394,7 @@ export class HeadlessExecutor { imageAnalysisEnv?: Record; runtimeEnvVars?: NodeJS.ProcessEnv; traceEnv?: Record; + launchCleanup?: () => void; } ): Promise { const { @@ -401,6 +408,7 @@ export class HeadlessExecutor { imageAnalysisEnv = {}, runtimeEnvVars = {}, traceEnv = {}, + launchCleanup = () => {}, } = ctx; return new Promise((resolve, reject) => { @@ -438,6 +446,14 @@ export class HeadlessExecutor { let progressInterval: NodeJS.Timeout | undefined; const messages: StreamMessage[] = []; let timedOut = false; + let cleanedUp = false; + const cleanupLaunchArtifacts = () => { + if (cleanedUp) { + return; + } + cleanedUp = true; + launchCleanup(); + }; // Setup signal handlers for cleanup const cleanupHandler = () => { @@ -496,6 +512,7 @@ export class HeadlessExecutor { // Handle completion proc.on('close', (exitCode: number | null) => { + cleanupLaunchArtifacts(); const duration = Date.now() - startTime; if (progressInterval) { @@ -588,6 +605,7 @@ export class HeadlessExecutor { // Handle errors proc.on('error', (error: Error) => { + cleanupLaunchArtifacts(); if (progressInterval) clearInterval(progressInterval); reject(new Error(`Failed to execute Claude CLI: ${error.message}`)); }); diff --git a/src/proxy/transformers/request-transformer.ts b/src/proxy/transformers/request-transformer.ts index 58e7969e..b5877bd6 100644 --- a/src/proxy/transformers/request-transformer.ts +++ b/src/proxy/transformers/request-transformer.ts @@ -362,7 +362,7 @@ function transformToolChoice( function mapThinkingToReasoning( thinking: AnthropicThinking | undefined, outputConfig: AnthropicOutputConfig | undefined -): Pick { +): Pick { if (!thinking || thinking.type === 'disabled') { return {}; } @@ -371,10 +371,6 @@ function mapThinkingToReasoning( const effort = toOpenAIEffort(resolveOutputConfigEffort(outputConfig) ?? 'high'); return { reasoning_effort: effort, - reasoning: { - enabled: true, - effort, - }, }; } @@ -389,10 +385,6 @@ function mapThinkingToReasoning( return { reasoning_effort: effort, - reasoning: { - enabled: true, - effort, - }, }; } diff --git a/src/targets/target-metadata.ts b/src/targets/target-metadata.ts index 1c0ae30b..035ba432 100644 --- a/src/targets/target-metadata.ts +++ b/src/targets/target-metadata.ts @@ -21,7 +21,7 @@ export const TARGET_METADATA: Record = { }, codex: { displayName: 'Codex CLI', - runtimeAliases: ['ccs-codex', 'ccsx'], + runtimeAliases: ['ccs-codex', 'ccsx', 'ccsxp'], legacyAliasEnvVar: 'CCS_CODEX_ALIASES', persistedTarget: false, }, diff --git a/src/utils/openai-compat-launch-settings.ts b/src/utils/openai-compat-launch-settings.ts new file mode 100644 index 00000000..bfb0887d --- /dev/null +++ b/src/utils/openai-compat-launch-settings.ts @@ -0,0 +1,52 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import type { Settings } from '../types/config'; +import { stripAnthropicRoutingEnv } from './shell-executor'; + +export interface OpenAICompatLaunchSettings { + settingsPath: string; + cleanup: () => void; +} + +export function createOpenAICompatLaunchSettings( + settingsPath: string, + settings: Settings +): OpenAICompatLaunchSettings { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-openai-compat-settings-')); + fs.chmodSync(tempDir, 0o700); + + const launchSettings = JSON.parse(JSON.stringify(settings)) as Settings; + const sanitizedEnv = Object.fromEntries( + Object.entries(stripAnthropicRoutingEnv({ ...(launchSettings.env ?? {}) })).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + ); + + if (Object.keys(sanitizedEnv).length > 0) { + launchSettings.env = sanitizedEnv; + } else { + delete launchSettings.env; + } + + const launchSettingsPath = path.join(tempDir, path.basename(settingsPath)); + fs.writeFileSync(launchSettingsPath, JSON.stringify(launchSettings, null, 2) + '\n', { + encoding: 'utf8', + mode: 0o600, + }); + + let cleanedUp = false; + const cleanup = (): void => { + if (cleanedUp) { + return; + } + cleanedUp = true; + fs.rmSync(tempDir, { recursive: true, force: true }); + }; + + return { + settingsPath: launchSettingsPath, + cleanup, + }; +} diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index a6a5fd63..35a7505d 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -208,7 +208,8 @@ export function getWindowsEscapedCommandShell(): SpawnOptions['shell'] { export function execClaude( claudeCli: string, args: string[], - envVars: NodeJS.ProcessEnv | null = null + envVars: NodeJS.ProcessEnv | null = null, + onExitCleanup?: () => void ): void { const isWindows = process.platform === 'win32'; const isPowerShellScript = isWindows && /\.ps1$/i.test(claudeCli); @@ -286,6 +287,17 @@ export function execClaude( }); } + let cleanedUp = false; + const runExitCleanup = (): void => { + if (cleanedUp) { + return; + } + cleanedUp = true; + onExitCleanup?.(); + }; + child.once('exit', runExitCleanup); + child.once('error', runExitCleanup); + wireChildProcessSignals(child, async (err: NodeJS.ErrnoException) => { if (err.code === 'EACCES') { console.error(`[X] Claude CLI is not executable: ${claudeCli}`); diff --git a/src/web-server/jsonl-parser.ts b/src/web-server/jsonl-parser.ts index b6d2f74b..c99dc5dd 100644 --- a/src/web-server/jsonl-parser.ts +++ b/src/web-server/jsonl-parser.ts @@ -82,6 +82,18 @@ function toNonNegativeNumber(value: unknown): number { return numeric; } +function parseRequiredNonNegativeNumber(value: unknown): number | null { + const numeric = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(numeric) || numeric < 0) { + return null; + } + return numeric; +} + +function isValidTimestamp(value: unknown): value is string { + return typeof value === 'string' && Number.isFinite(Date.parse(value)); +} + export function parseUsageEntry(line: string, projectPath: string): RawUsageEntry | null { // Strip UTF-8 BOM if present (can occur on first line of some files) const cleanLine = line.replace(/^\uFEFF/, '').trim(); @@ -97,16 +109,25 @@ export function parseUsageEntry(line: string, projectPath: string): RawUsageEntr const usage = entry.message.usage; const assistant = entry as JsonlAssistantEntry; + const inputTokens = parseRequiredNonNegativeNumber(usage.input_tokens); + const outputTokens = parseRequiredNonNegativeNumber(usage.output_tokens); + if (inputTokens === null || outputTokens === null || !isValidTimestamp(assistant.timestamp)) { + return null; + } + const normalizedProjectPath = + typeof assistant.cwd === 'string' && assistant.cwd.trim().length > 0 + ? assistant.cwd + : projectPath; return { - inputTokens: toNonNegativeNumber(usage.input_tokens), - outputTokens: toNonNegativeNumber(usage.output_tokens), + inputTokens, + outputTokens, cacheCreationTokens: toNonNegativeNumber(usage.cache_creation_input_tokens), cacheReadTokens: toNonNegativeNumber(usage.cache_read_input_tokens), model: assistant.message.model, sessionId: assistant.sessionId || '', - timestamp: assistant.timestamp || new Date().toISOString(), - projectPath, + timestamp: assistant.timestamp, + projectPath: normalizedProjectPath, version: assistant.version, target: typeof (entry as { target?: unknown }).target === 'string' diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index 93da148b..6d7a8bfa 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -7,6 +7,12 @@ * All rates are in USD per MILLION tokens. */ +import { + getKnownModelsDevModels, + resolveModelsDevPricing, + type ModelsDevPricingLookupOptions, +} from './models-dev/pricing-resolver'; + // ============================================================================ // TYPE DEFINITIONS // ============================================================================ @@ -25,6 +31,8 @@ export interface TokenUsage { cacheReadTokens: number; } +export type PricingLookupOptions = ModelsDevPricingLookupOptions; + // ============================================================================ // USER-EDITABLE PRICING TABLE // Update rates below (per million tokens in USD) @@ -750,16 +758,25 @@ const UNKNOWN_MODEL_PRICING: ModelPricing = { // ============================================================================ /** - * Normalize model name for matching - * Handles variations like provider prefixes and case differences + * Strip provider prefixes used by routing/catalog metadata. + * The CCS static table remains model-keyed, so static fallback normalizes + * provider-qualified model IDs before checking aliases. + */ +function stripProviderPrefix(model: string): string { + const trimmed = model.trim(); + const slashIndex = trimmed.indexOf('/'); + if (slashIndex <= 0) { + return trimmed; + } + return trimmed.slice(slashIndex + 1); +} + +/** + * Normalize model name for matching. + * Handles variations like provider prefixes and case differences. */ function normalizeModelName(model: string): string { - // Remove provider prefixes (e.g., "anthropic/claude-..." -> "claude-...") - const normalized = model - .trim() - .toLowerCase() - .replace(/^[^/]+\//, ''); - return normalized; + return stripProviderPrefix(model).toLowerCase(); } /** @@ -827,29 +844,80 @@ function getDirectOrAliasPricing(model: string): ModelPricing | undefined { return undefined; } -/** - * Get pricing for a model with fuzzy matching fallback - * @param model - Model name (exact or with provider prefix) - * @returns ModelPricing for the model or fallback pricing - */ -export function getModelPricing(model: string): ModelPricing { - const directOrAliasPricing = getDirectOrAliasPricing(model); - if (directOrAliasPricing !== undefined) { - return directOrAliasPricing; +function getCcsStaticPricing(model: string): ModelPricing | undefined { + const staticPricing = getDirectOrAliasPricing(model); + if (staticPricing !== undefined) { + return staticPricing; } - for (const candidate of getLookupCandidates(model)) { - // Try suffix matching (e.g., "claude-sonnet-4-5" matches "*-claude-sonnet-4-5") - for (const [key, pricing] of Object.entries(NORMALIZED_PRICING_REGISTRY)) { - if (candidate.endsWith(key) || key.endsWith(candidate)) { - return pricing; + const providerlessModel = stripProviderPrefix(model); + if (providerlessModel !== model.trim()) { + return getDirectOrAliasPricing(providerlessModel); + } + + return undefined; +} + +function getCcsPolicyOverridePricing(model: string): ModelPricing | undefined { + const providerlessModel = stripProviderPrefix(model); + const normalized = normalizeModelName(providerlessModel); + + for (const candidate of getLookupCandidates(providerlessModel)) { + const alias = MODEL_PRICING_ALIASES[candidate]; + if (alias !== undefined) { + const aliasPricing = NORMALIZED_PRICING_REGISTRY[alias]; + if (aliasPricing !== undefined) { + return aliasPricing; } } - // Try partial matching for model families + if (candidate !== normalized) { + const variantPricing = NORMALIZED_PRICING_REGISTRY[candidate]; + if (variantPricing !== undefined) { + return variantPricing; + } + } + } + + return undefined; +} + +function hasProviderContext(model: string, options: PricingLookupOptions): boolean { + return Boolean(options.provider || /^[^/]+\//.test(model.trim())); +} + +/** + * Get pricing for a model with narrow fuzzy matching fallback. + * Unknown future model families should fall back instead of inheriting the + * first known family tier that happens to share a prefix. + */ +export function getModelPricing(model: string, options: PricingLookupOptions = {}): ModelPricing { + if (hasProviderContext(model, options)) { + const ccsOverridePricing = getCcsPolicyOverridePricing(model); + if (ccsOverridePricing !== undefined) { + return ccsOverridePricing; + } + + const providerPricing = resolveModelsDevPricing(model, options); + if (providerPricing !== undefined) { + return providerPricing.pricing; + } + } + + const ccsStaticPricing = getCcsStaticPricing(model); + if (ccsStaticPricing !== undefined) { + return ccsStaticPricing; + } + + const modelsDevPricing = resolveModelsDevPricing(model, options); + if (modelsDevPricing !== undefined) { + return modelsDevPricing.pricing; + } + + for (const candidate of getLookupCandidates(model)) { + // Allow provider/routing wrappers to suffix a canonical model ID. for (const [key, pricing] of Object.entries(NORMALIZED_PRICING_REGISTRY)) { - // Match by model family prefix - if (candidate.startsWith(key.split('-').slice(0, 2).join('-'))) { + if (candidate.endsWith(key)) { return pricing; } } @@ -865,8 +933,12 @@ export function getModelPricing(model: string): ModelPricing { * @param model - Model name for pricing lookup * @returns Cost in USD */ -export function calculateCost(usage: TokenUsage, model: string): number { - const pricing = getModelPricing(model); +export function calculateCost( + usage: TokenUsage, + model: string, + options: PricingLookupOptions = {} +): number { + const pricing = getModelPricing(model, options); const inputCost = (usage.inputTokens / 1_000_000) * pricing.inputPerMillion; const outputCost = (usage.outputTokens / 1_000_000) * pricing.outputPerMillion; @@ -881,12 +953,15 @@ export function calculateCost(usage: TokenUsage, model: string): number { * Get list of all known models for UI display */ export function getKnownModels(): string[] { - return Object.keys(PRICING_REGISTRY); + return [...new Set([...Object.keys(PRICING_REGISTRY), ...getKnownModelsDevModels()])]; } /** * Check if a model has custom pricing (not using fallback) */ -export function hasCustomPricing(model: string): boolean { - return getDirectOrAliasPricing(model) !== undefined; +export function hasCustomPricing(model: string, options: PricingLookupOptions = {}): boolean { + return ( + getCcsStaticPricing(model) !== undefined || + resolveModelsDevPricing(model, options) !== undefined + ); } diff --git a/src/web-server/models-dev/pricing-resolver.ts b/src/web-server/models-dev/pricing-resolver.ts new file mode 100644 index 00000000..daad1c92 --- /dev/null +++ b/src/web-server/models-dev/pricing-resolver.ts @@ -0,0 +1,195 @@ +import { getCachedModelsDevRegistry } from './registry-cache'; +import type { ModelsDevCost, ModelsDevModel, ModelsDevProvider, ModelsDevRegistry } from './types'; + +export interface ModelsDevPricing { + inputPerMillion: number; + outputPerMillion: number; + cacheCreationPerMillion: number; + cacheReadPerMillion: number; +} + +export interface ModelsDevPricingResolution { + provider: string; + model: string; + pricing: ModelsDevPricing; +} + +export interface ModelsDevPricingLookupOptions { + provider?: string; +} + +const PROVIDER_ALIASES: Record = { + agy: 'google', + antigravity: 'google', + claude: 'anthropic', + codex: 'openai', + copilot: 'github-copilot', + gemini: 'google', + ghcp: 'github-copilot', + github: 'github-copilot', + kimi: 'moonshotai', + moonshot: 'moonshotai', + qwen: 'alibaba', +}; + +function normalizeId(value: string): string { + return value.trim().toLowerCase(); +} + +export function normalizeModelsDevProviderId( + provider: string | null | undefined +): string | undefined { + if (!provider) return undefined; + const normalized = normalizeId(provider); + return PROVIDER_ALIASES[normalized] ?? normalized; +} + +function splitProviderPrefix(model: string): { provider?: string; model: string } { + const trimmed = model.trim(); + const slashIndex = trimmed.indexOf('/'); + if (slashIndex <= 0) return { model: trimmed }; + return { + provider: trimmed.slice(0, slashIndex), + model: trimmed.slice(slashIndex + 1), + }; +} + +function stripClaudeDateSuffix(model: string): string { + if (!model.startsWith('claude-')) return model; + return model.replace(/-\d{8}(?=-thinking(?:$|:))/g, '').replace(/-\d{8}(?=$|:)/g, ''); +} + +function getModelCandidates(model: string): string[] { + const normalized = normalizeId(model); + const baseModel = normalized.split(':')[0]; + const candidates = [normalized]; + + if (baseModel !== normalized) candidates.push(baseModel); + + for (const candidate of [...candidates]) { + const stripped = stripClaudeDateSuffix(candidate); + if (stripped !== candidate && !candidates.includes(stripped)) { + candidates.push(stripped); + } + } + + return candidates; +} + +function findProvider( + registry: ModelsDevRegistry, + provider: string | undefined +): ModelsDevProvider | undefined { + const normalizedProvider = normalizeModelsDevProviderId(provider); + if (!normalizedProvider) return undefined; + return registry[normalizedProvider]; +} + +function findModel(provider: ModelsDevProvider, model: string): ModelsDevModel | undefined { + const models = provider.models; + if (!models) return undefined; + + const normalizedEntries = new Map(); + for (const [key, value] of Object.entries(models)) { + normalizedEntries.set(normalizeId(key), value); + if (typeof value.id === 'string') normalizedEntries.set(normalizeId(value.id), value); + } + + for (const candidate of getModelCandidates(model)) { + const match = normalizedEntries.get(candidate); + if (match) return match; + } + + return undefined; +} + +function toNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function toPricing(cost: ModelsDevCost | null | undefined): ModelsDevPricing | undefined { + const input = toNumber(cost?.input); + const output = toNumber(cost?.output); + if (input === undefined || output === undefined) return undefined; + + return { + inputPerMillion: input, + outputPerMillion: output, + cacheCreationPerMillion: toNumber(cost?.cache_write) ?? 0, + cacheReadPerMillion: toNumber(cost?.cache_read) ?? 0, + }; +} + +function samePricing(left: ModelsDevPricing, right: ModelsDevPricing): boolean { + return ( + left.inputPerMillion === right.inputPerMillion && + left.outputPerMillion === right.outputPerMillion && + left.cacheCreationPerMillion === right.cacheCreationPerMillion && + left.cacheReadPerMillion === right.cacheReadPerMillion + ); +} + +function resolveProviderModel( + registry: ModelsDevRegistry, + providerId: string | undefined, + model: string +): ModelsDevPricingResolution | undefined { + const provider = findProvider(registry, providerId); + if (!provider) return undefined; + + const entry = findModel(provider, model); + const pricing = toPricing(entry?.cost); + if (!entry || !pricing) return undefined; + + return { provider: provider.id, model: entry.id, pricing }; +} + +function resolveUnambiguousModel( + registry: ModelsDevRegistry, + model: string +): ModelsDevPricingResolution | undefined { + const matches: ModelsDevPricingResolution[] = []; + + for (const provider of Object.values(registry)) { + const match = resolveProviderModel(registry, provider.id, model); + if (match) matches.push(match); + } + + if (matches.length === 0) return undefined; + const first = matches[0]; + if (matches.every((match) => samePricing(first.pricing, match.pricing))) { + return first; + } + return undefined; +} + +export function resolveModelsDevPricing( + model: string, + options: ModelsDevPricingLookupOptions = {} +): ModelsDevPricingResolution | undefined { + const registry = getCachedModelsDevRegistry({ allowStale: true }); + if (!registry) return undefined; + + const prefixed = splitProviderPrefix(model); + const provider = options.provider ?? prefixed.provider; + const modelId = prefixed.model; + + if (provider) { + return resolveProviderModel(registry, provider, modelId); + } + + return resolveUnambiguousModel(registry, modelId); +} + +export function getKnownModelsDevModels(): string[] { + const registry = getCachedModelsDevRegistry({ allowStale: true }); + if (!registry) return []; + + const ids = new Set(); + for (const provider of Object.values(registry)) { + for (const model of Object.values(provider.models ?? {})) { + if (typeof model.id === 'string') ids.add(`${provider.id}/${model.id}`); + } + } + return Array.from(ids).sort((a, b) => a.localeCompare(b)); +} diff --git a/src/web-server/models-dev/registry-cache.ts b/src/web-server/models-dev/registry-cache.ts new file mode 100644 index 00000000..9b2a0802 --- /dev/null +++ b/src/web-server/models-dev/registry-cache.ts @@ -0,0 +1,149 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir } from '../../utils/config-manager'; +import type { ModelsDevCacheData, ModelsDevProvider, ModelsDevRegistry } from './types'; + +export const MODELS_DEV_API_URL = 'https://models.dev/api.json'; + +const CACHE_FILE_NAME = 'models-dev-registry-cache.json'; +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; +const LIVE_FETCH_TIMEOUT_MS = 3000; + +let pendingBackgroundRefresh: Promise | null = null; + +export interface RegistryCacheReadOptions { + allowStale?: boolean; + now?: number; +} + +export interface RegistryRefreshOptions { + force?: boolean; + fetchImpl?: typeof fetch; + now?: () => number; +} + +function getCacheFilePath(): string { + return path.join(getCcsDir(), CACHE_FILE_NAME); +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function normalizeRegistryPayload(payload: unknown): ModelsDevRegistry | null { + if (!isPlainObject(payload)) return null; + + const providers: ModelsDevRegistry = {}; + for (const [key, value] of Object.entries(payload)) { + if (!isPlainObject(value)) continue; + const id = typeof value.id === 'string' && value.id.trim() ? value.id.trim() : key; + const models = isPlainObject(value.models) + ? (value.models as NonNullable) + : undefined; + if (!models || Object.keys(models).length === 0) continue; + + providers[id] = { + ...(value as ModelsDevProvider), + id, + models, + }; + } + + return Object.keys(providers).length > 0 ? providers : null; +} + +function normalizeCachePayload(payload: unknown): ModelsDevCacheData | null { + if (!isPlainObject(payload)) return null; + if (payload.version !== 1 || typeof payload.fetchedAt !== 'number') return null; + const providers = normalizeRegistryPayload(payload.providers); + return providers ? { version: 1, fetchedAt: payload.fetchedAt, providers } : null; +} + +export function getCachedModelsDevRegistry( + options: RegistryCacheReadOptions = {} +): ModelsDevRegistry | null { + try { + const filePath = getCacheFilePath(); + if (!fs.existsSync(filePath)) return null; + + const cache = normalizeCachePayload(JSON.parse(fs.readFileSync(filePath, 'utf8'))); + if (!cache) return null; + + const now = options.now ?? Date.now(); + if (!options.allowStale && now - cache.fetchedAt > CACHE_TTL_MS) return null; + return cache.providers; + } catch { + return null; + } +} + +export function setCachedModelsDevRegistry( + providers: ModelsDevRegistry, + fetchedAt = Date.now() +): void { + try { + const filePath = getCacheFilePath(); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const cache: ModelsDevCacheData = { version: 1, fetchedAt, providers }; + fs.writeFileSync(filePath, JSON.stringify(cache)); + } catch { + // Best-effort cache writes must not break analytics. + } +} + +export function clearModelsDevRegistryCache(): boolean { + try { + const filePath = getCacheFilePath(); + if (!fs.existsSync(filePath)) return false; + fs.unlinkSync(filePath); + return true; + } catch { + return false; + } +} + +export async function refreshModelsDevRegistry( + options: RegistryRefreshOptions = {} +): Promise { + const now = options.now ?? (() => Date.now()); + if (!options.force) { + const fresh = getCachedModelsDevRegistry({ allowStale: false, now: now() }); + if (fresh) return fresh; + } + + const fetchImpl = options.fetchImpl ?? fetch; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), LIVE_FETCH_TIMEOUT_MS); + + try { + const response = await fetchImpl(MODELS_DEV_API_URL, { + signal: controller.signal, + headers: { Accept: 'application/json' }, + }); + if (!response.ok) return getCachedModelsDevRegistry({ allowStale: true }); + + const providers = normalizeRegistryPayload(await response.json()); + if (!providers) return getCachedModelsDevRegistry({ allowStale: true }); + + setCachedModelsDevRegistry(providers, now()); + return providers; + } catch { + return getCachedModelsDevRegistry({ allowStale: true }); + } finally { + clearTimeout(timeoutId); + } +} + +export function startModelsDevRegistryRefresh( + options: RegistryRefreshOptions = {} +): Promise { + if (!pendingBackgroundRefresh) { + pendingBackgroundRefresh = refreshModelsDevRegistry(options) + .catch(() => null) + .finally(() => { + pendingBackgroundRefresh = null; + }); + } + + return pendingBackgroundRefresh; +} diff --git a/src/web-server/models-dev/types.ts b/src/web-server/models-dev/types.ts new file mode 100644 index 00000000..8536c7ee --- /dev/null +++ b/src/web-server/models-dev/types.ts @@ -0,0 +1,42 @@ +export interface ModelsDevCost { + input?: number; + output?: number; + cache_read?: number; + cache_write?: number; + [key: string]: unknown; +} + +export interface ModelsDevModel { + id: string; + name?: string; + cost?: ModelsDevCost | null; + limit?: Record; + modalities?: { + input?: string[]; + output?: string[]; + }; + reasoning?: boolean | null; + tool_call?: boolean | null; + structured_output?: boolean | null; + temperature?: boolean | null; + [key: string]: unknown; +} + +export interface ModelsDevProvider { + id: string; + name?: string; + env?: string[]; + npm?: string; + api?: string | null; + doc?: string; + models?: Record; + [key: string]: unknown; +} + +export type ModelsDevRegistry = Record; + +export interface ModelsDevCacheData { + version: 1; + fetchedAt: number; + providers: ModelsDevRegistry; +} diff --git a/src/web-server/routes/cliproxy-routing-routes.ts b/src/web-server/routes/cliproxy-routing-routes.ts index d1a21b54..0fa1c987 100644 --- a/src/web-server/routes/cliproxy-routing-routes.ts +++ b/src/web-server/routes/cliproxy-routing-routes.ts @@ -1,8 +1,12 @@ import { Router, Request, Response } from 'express'; import { applyCliproxyRoutingStrategy, + applyCliproxySessionAffinitySettings, normalizeCliproxyRoutingStrategy, + normalizeCliproxySessionAffinityEnabled, + normalizeCliproxySessionAffinityTtl, readCliproxyRoutingState, + readCliproxySessionAffinityState, } from '../../cliproxy/routing-strategy'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; @@ -42,4 +46,42 @@ router.put('/routing/strategy', async (req: Request, res: Response): Promise => { + try { + res.json(await readCliproxySessionAffinityState()); + } catch (error) { + res.status(502).json({ error: (error as Error).message }); + } +}); + +router.put('/routing/session-affinity', async (req: Request, res: Response): Promise => { + const enabled = normalizeCliproxySessionAffinityEnabled(req.body?.enabled ?? req.body?.value); + const ttl = req.body?.ttl; + const normalizedTtl: string | undefined = + ttl === undefined ? undefined : (normalizeCliproxySessionAffinityTtl(ttl) ?? undefined); + + if (enabled === null || (ttl !== undefined && !normalizedTtl)) { + res.status(400).json({ + error: 'Invalid session affinity payload. Use enabled=true|false and ttl like 30m or 1h.', + }); + return; + } + + try { + const result = await applyCliproxySessionAffinitySettings({ + enabled, + ttl: normalizedTtl, + }); + if (!result.manageable || result.applied === 'unsupported') { + res.status(400).json({ + error: result.message || 'Session affinity is not supported for this target.', + }); + return; + } + res.json(result); + } catch (error) { + res.status(502).json({ error: (error as Error).message }); + } +}); + export default router; diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index 84752b1d..9b812760 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -144,6 +144,7 @@ router.post('/', (req: Request, res: Response): void => { 'baseUrl', 'apiKey', 'model', + 'extraModels', 'opusModel', 'sonnetModel', 'haikuModel', @@ -156,7 +157,8 @@ router.post('/', (req: Request, res: Response): void => { return; } - const { name, baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body; + const { name, baseUrl, apiKey, model, extraModels, opusModel, sonnetModel, haikuModel, target } = + req.body; const providerHint = req.body?.droidProvider ?? req.body?.provider; const parsedProvider = normalizeDroidProvider(providerHint); const normalizedBaseUrl = typeof baseUrl === 'string' ? baseUrl.trim() : ''; @@ -198,6 +200,14 @@ router.post('/', (req: Request, res: Response): void => { } // Create profile using unified-config-aware service + const parsedExtraModels: string[] | undefined = + typeof extraModels === 'string' && extraModels.trim() + ? extraModels + .split(',') + .map((m: string) => m.trim()) + .filter((m: string) => m.length > 0) + : undefined; + const result = createApiProfile( name, normalizedBaseUrl, @@ -209,7 +219,8 @@ router.post('/', (req: Request, res: Response): void => { haiku: haikuModel || model || '', }, parsedTarget || 'claude', - parsedProvider || undefined + parsedProvider || undefined, + parsedExtraModels ); if (!result.success) { @@ -409,6 +420,7 @@ router.put('/:name', (req: Request, res: Response): void => { 'baseUrl', 'apiKey', 'model', + 'extraModels', 'opusModel', 'sonnetModel', 'haikuModel', @@ -422,7 +434,8 @@ router.put('/:name', (req: Request, res: Response): void => { } const { name } = req.params; - const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body; + const { baseUrl, apiKey, model, extraModels, opusModel, sonnetModel, haikuModel, target } = + req.body; const providerHint = req.body?.droidProvider ?? req.body?.provider; const parsedProvider = normalizeDroidProvider(providerHint); const normalizedBaseUrl = typeof baseUrl === 'string' ? baseUrl.trim() : baseUrl; @@ -457,6 +470,7 @@ router.put('/:name', (req: Request, res: Response): void => { baseUrl !== undefined || apiKey !== undefined || model !== undefined || + extraModels !== undefined || opusModel !== undefined || sonnetModel !== undefined || haikuModel !== undefined || @@ -473,6 +487,7 @@ router.put('/:name', (req: Request, res: Response): void => { baseUrl: normalizedBaseUrl, apiKey: normalizedApiKey, model, + extraModels, opusModel, sonnetModel, haikuModel, diff --git a/src/web-server/routes/route-helpers.ts b/src/web-server/routes/route-helpers.ts index b45f762a..3836711f 100644 --- a/src/web-server/routes/route-helpers.ts +++ b/src/web-server/routes/route-helpers.ts @@ -218,6 +218,7 @@ export function updateSettingsFile( baseUrl?: string; apiKey?: string; model?: string; + extraModels?: string; opusModel?: string; sonnetModel?: string; haikuModel?: string; @@ -336,6 +337,17 @@ export function updateSettingsFile( } } + // Handle extra models (comma-separated string) + if (updates.extraModels !== undefined) { + settings.env = settings.env || {}; + const normalized = typeof updates.extraModels === 'string' ? updates.extraModels.trim() : ''; + if (normalized.length > 0) { + settings.env.ANTHROPIC_EXTRA_MODELS = normalized; + } else { + delete settings.env.ANTHROPIC_EXTRA_MODELS; + } + } + if ( updates.provider !== undefined || updates.baseUrl !== undefined || diff --git a/src/web-server/usage/aggregator.ts b/src/web-server/usage/aggregator.ts index 8e9fa3c0..129f2d73 100644 --- a/src/web-server/usage/aggregator.ts +++ b/src/web-server/usage/aggregator.ts @@ -7,7 +7,13 @@ import * as fs from 'fs'; import * as path from 'path'; -import { loadAllUsageData } from './data-aggregator'; +import { + aggregateDailyUsage, + aggregateHourlyUsage, + aggregateMonthlyUsage, + aggregateSessionUsage, + loadAllUsageData, +} from './data-aggregator'; import type { DailyUsage, HourlyUsage, MonthlyUsage, SessionUsage } from './types'; import { readDiskCache, @@ -19,12 +25,21 @@ import { } from './disk-cache'; import { ok, info, fail } from '../../utils/ui'; import { getCcsDir } from '../../utils/config-manager'; +import { getClaudeConfigDir, getDefaultClaudeConfigDir } from '../../utils/claude-config-path'; import { loadCachedCliproxyData, startCliproxySync, stopCliproxySync, syncCliproxyUsage, } from './cliproxy-usage-syncer'; +import { scanCodexNativeUsageEntries } from './codex-native-usage-collector'; +import { scanDroidNativeUsageEntries } from './droid-native-usage-collector'; +import { startModelsDevRegistryRefresh } from '../models-dev/registry-cache'; +import { + coalesceLegacyProviderlessBreakdowns, + getModelsUsed, + getProviderModelKey, +} from './model-identity'; // ============================================================================ // Multi-Instance Support - Aggregate usage from CCS profiles @@ -35,6 +50,21 @@ function getCcsInstancesDir() { return path.join(getCcsDir(), 'instances'); } +function isPathWithinDir(childPath: string, parentPath: string): boolean { + const relative = path.relative(parentPath, childPath); + return relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative); +} + +function getDefaultProjectsDirForAnalytics(): string { + const activeClaudeConfigDir = getClaudeConfigDir(); + const instancesDir = getCcsInstancesDir(); + const claudeConfigDir = isPathWithinDir(activeClaudeConfigDir, instancesDir) + ? getDefaultClaudeConfigDir() + : activeClaudeConfigDir; + + return path.join(claudeConfigDir, 'projects'); +} + /** * Get list of CCS instance paths that have usage data * Only returns instances with existing projects/ directory @@ -83,6 +113,37 @@ async function loadInstanceData(instancePath: string): Promise<{ } } +function getHourlyRequestCount(hour: HourlyUsage): number { + return hour.requestCount ?? hour.modelBreakdowns.length; +} + +function finalizeDailyUsage(day: DailyUsage): DailyUsage { + const modelBreakdowns = coalesceLegacyProviderlessBreakdowns(day.modelBreakdowns); + return { + ...day, + modelsUsed: getModelsUsed(modelBreakdowns), + modelBreakdowns, + }; +} + +function finalizeMonthlyUsage(month: MonthlyUsage): MonthlyUsage { + const modelBreakdowns = coalesceLegacyProviderlessBreakdowns(month.modelBreakdowns); + return { + ...month, + modelsUsed: getModelsUsed(modelBreakdowns), + modelBreakdowns, + }; +} + +function finalizeHourlyUsage(hour: HourlyUsage): HourlyUsage { + const modelBreakdowns = coalesceLegacyProviderlessBreakdowns(hour.modelBreakdowns); + return { + ...hour, + modelsUsed: getModelsUsed(modelBreakdowns), + modelBreakdowns, + }; +} + /** * Merge daily usage data from multiple sources * Combines entries with same date by aggregating tokens @@ -100,13 +161,11 @@ export function mergeDailyData(sources: DailyUsage[][]): DailyUsage[] { existing.cacheCreationTokens += day.cacheCreationTokens; existing.cacheReadTokens += day.cacheReadTokens; existing.totalCost += day.totalCost; - // Merge unique models - const modelSet = new Set([...existing.modelsUsed, ...day.modelsUsed]); - existing.modelsUsed = Array.from(modelSet); // Merge model breakdowns by aggregating same modelName for (const breakdown of day.modelBreakdowns) { + const breakdownKey = getProviderModelKey(breakdown); const existingBreakdown = existing.modelBreakdowns.find( - (b) => b.modelName === breakdown.modelName + (b) => getProviderModelKey(b) === breakdownKey ); if (existingBreakdown) { existingBreakdown.inputTokens += breakdown.inputTokens; @@ -120,16 +179,19 @@ export function mergeDailyData(sources: DailyUsage[][]): DailyUsage[] { } } else { // Clone to avoid mutating original + const modelBreakdowns = day.modelBreakdowns.map((b) => ({ ...b })); dateMap.set(day.date, { ...day, - modelsUsed: [...day.modelsUsed], - modelBreakdowns: day.modelBreakdowns.map((b) => ({ ...b })), + modelsUsed: getModelsUsed(modelBreakdowns), + modelBreakdowns, }); } } } - return Array.from(dateMap.values()).sort((a, b) => a.date.localeCompare(b.date)); + return Array.from(dateMap.values()) + .map(finalizeDailyUsage) + .sort((a, b) => a.date.localeCompare(b.date)); } /** @@ -147,15 +209,35 @@ export function mergeMonthlyData(sources: MonthlyUsage[][]): MonthlyUsage[] { existing.cacheCreationTokens += month.cacheCreationTokens; existing.cacheReadTokens += month.cacheReadTokens; existing.totalCost += month.totalCost; - const modelSet = new Set([...existing.modelsUsed, ...month.modelsUsed]); - existing.modelsUsed = Array.from(modelSet); + for (const breakdown of month.modelBreakdowns) { + const breakdownKey = getProviderModelKey(breakdown); + const existingBreakdown = existing.modelBreakdowns.find( + (item) => getProviderModelKey(item) === breakdownKey + ); + if (existingBreakdown) { + existingBreakdown.inputTokens += breakdown.inputTokens; + existingBreakdown.outputTokens += breakdown.outputTokens; + existingBreakdown.cacheCreationTokens += breakdown.cacheCreationTokens; + existingBreakdown.cacheReadTokens += breakdown.cacheReadTokens; + existingBreakdown.cost += breakdown.cost; + } else { + existing.modelBreakdowns.push({ ...breakdown }); + } + } } else { - monthMap.set(month.month, { ...month, modelsUsed: [...month.modelsUsed] }); + const modelBreakdowns = month.modelBreakdowns.map((breakdown) => ({ ...breakdown })); + monthMap.set(month.month, { + ...month, + modelsUsed: getModelsUsed(modelBreakdowns), + modelBreakdowns, + }); } } } - return Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month)); + return Array.from(monthMap.values()) + .map(finalizeMonthlyUsage) + .sort((a, b) => a.month.localeCompare(b.month)); } /** @@ -174,12 +256,12 @@ export function mergeHourlyData(sources: HourlyUsage[][]): HourlyUsage[] { existing.cacheCreationTokens += hour.cacheCreationTokens; existing.cacheReadTokens += hour.cacheReadTokens; existing.totalCost += hour.totalCost; - const modelSet = new Set([...existing.modelsUsed, ...hour.modelsUsed]); - existing.modelsUsed = Array.from(modelSet); + existing.requestCount = getHourlyRequestCount(existing) + getHourlyRequestCount(hour); // Merge model breakdowns for (const breakdown of hour.modelBreakdowns) { + const breakdownKey = getProviderModelKey(breakdown); const existingBreakdown = existing.modelBreakdowns.find( - (b) => b.modelName === breakdown.modelName + (b) => getProviderModelKey(b) === breakdownKey ); if (existingBreakdown) { existingBreakdown.inputTokens += breakdown.inputTokens; @@ -192,16 +274,20 @@ export function mergeHourlyData(sources: HourlyUsage[][]): HourlyUsage[] { } } } else { + const modelBreakdowns = hour.modelBreakdowns.map((b) => ({ ...b })); hourMap.set(hour.hour, { ...hour, - modelsUsed: [...hour.modelsUsed], - modelBreakdowns: hour.modelBreakdowns.map((b) => ({ ...b })), + modelsUsed: getModelsUsed(modelBreakdowns), + modelBreakdowns, + requestCount: getHourlyRequestCount(hour), }); } } } - return Array.from(hourMap.values()).sort((a, b) => a.hour.localeCompare(b.hour)); + return Array.from(hourMap.values()) + .map(finalizeHourlyUsage) + .sort((a, b) => a.hour.localeCompare(b.hour)); } /** @@ -251,6 +337,10 @@ export function getLastFetchTimestamp(): number | null { return lastFetchTimestamp; } +export function getUsageCacheSize(): number { + return cache.size; +} + // In-memory cache const cache = new Map>(); @@ -296,12 +386,16 @@ async function refreshFromSource(): Promise<{ monthly: MonthlyUsage[]; session: SessionUsage[]; }> { + // Keep model metadata warming off the analytics request path. Current + // refreshes use cached/static pricing; the background result helps future runs. + void startModelsDevRegistryRefresh(); + // Try to sync CLIProxy snapshot before reading it. // Non-fatal: syncer handles unavailability and stale fallback. await syncCliproxyUsage(); - // Load default data (from ~/.claude/projects/ or CLAUDE_CONFIG_DIR) - const defaultData = await loadAllUsageData(); + // Load canonical default data and avoid counting the active instance twice + const defaultData = await loadAllUsageData({ projectsDir: getDefaultProjectsDirForAnalytics() }); // Load data from all CCS instances sequentially const instancePaths = getInstancePaths(); @@ -339,6 +433,32 @@ async function refreshFromSource(): Promise<{ console.log(info(`Aggregated usage data from ${instanceDataResults.length} CCS instance(s)`)); } + try { + const codexEntries = await scanCodexNativeUsageEntries(); + if (codexEntries.length > 0) { + allDailySources.push(aggregateDailyUsage(codexEntries, 'codex-native')); + allHourlySources.push(aggregateHourlyUsage(codexEntries, 'codex-native')); + allMonthlySources.push(aggregateMonthlyUsage(codexEntries, 'codex-native')); + allSessionSources.push(aggregateSessionUsage(codexEntries, 'codex-native')); + console.log(info(`Included native Codex usage data (${codexEntries.length} event(s))`)); + } + } catch (err) { + console.error(fail(`Failed to load native Codex usage data: ${err}`)); + } + + try { + const droidEntries = await scanDroidNativeUsageEntries(); + if (droidEntries.length > 0) { + allDailySources.push(aggregateDailyUsage(droidEntries, 'droid-native')); + allHourlySources.push(aggregateHourlyUsage(droidEntries, 'droid-native')); + allMonthlySources.push(aggregateMonthlyUsage(droidEntries, 'droid-native')); + allSessionSources.push(aggregateSessionUsage(droidEntries, 'droid-native')); + console.log(info(`Included native Droid usage data (${droidEntries.length} event(s))`)); + } + } catch (err) { + console.error(fail(`Failed to load native Droid usage data: ${err}`)); + } + // Load CLIProxy usage data (from local snapshot cache) try { const cliproxyData = await loadCachedCliproxyData(); diff --git a/src/web-server/usage/cliproxy-usage-syncer.ts b/src/web-server/usage/cliproxy-usage-syncer.ts index f3249089..db0ad837 100644 --- a/src/web-server/usage/cliproxy-usage-syncer.ts +++ b/src/web-server/usage/cliproxy-usage-syncer.ts @@ -12,29 +12,43 @@ import * as fs from 'fs'; import * as path from 'path'; import { fetchCliproxyUsageRaw } from '../../cliproxy/stats-fetcher'; import { - transformCliproxyToDailyUsage, - transformCliproxyToHourlyUsage, - transformCliproxyToMonthlyUsage, + buildCliproxyUsageHistoryAggregates, + extractCliproxyUsageHistoryDetails, + mergeCliproxyUsageHistoryDetails, + pruneCliproxyUsageHistoryDetails, + type CliproxyUsageHistoryDetail, } from './cliproxy-usage-transformer'; import type { DailyUsage, HourlyUsage, MonthlyUsage } from './types'; import { getCcsDir } from '../../utils/config-manager'; import { ok, info, warn } from '../../utils/ui'; -// --------------------------------------------------------------------------- -// Snapshot format -// --------------------------------------------------------------------------- - interface CliproxyUsageSnapshot { version: number; timestamp: number; + details: CliproxyUsageHistoryDetail[]; daily: DailyUsage[]; hourly: HourlyUsage[]; monthly: MonthlyUsage[]; } +type LegacyCliproxyUsageSnapshot = { + version?: number; + timestamp?: number; + daily?: DailyUsage[]; + hourly?: HourlyUsage[]; + monthly?: MonthlyUsage[]; +}; + type FetchCliproxyUsageRaw = typeof fetchCliproxyUsageRaw; -const SNAPSHOT_VERSION = 1; +const SNAPSHOT_VERSION = 3; +const SNAPSHOT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; +const HISTORY_RETENTION_DAYS = Math.max( + 30, + parseInt(process.env.CCS_CLIPROXY_HISTORY_RETENTION_DAYS ?? '365', 10) || 365 +); +const HISTORY_RETENTION_MS = HISTORY_RETENTION_DAYS * 24 * 60 * 60 * 1000; +const MAX_WRITE_ATTEMPTS = 3; /** Sync interval in ms, configurable via CCS_CLIPROXY_SYNC_INTERVAL env var (default: 5 min) */ const SYNC_INTERVAL_MS = Math.max( @@ -42,12 +56,8 @@ const SYNC_INTERVAL_MS = Math.max( parseInt(process.env.CCS_CLIPROXY_SYNC_INTERVAL ?? '300000', 10) || 300_000 ); -// Module-level interval ID let syncIntervalId: ReturnType | null = null; - -// --------------------------------------------------------------------------- -// Cache directory helpers -// --------------------------------------------------------------------------- +let snapshotTimestampOrdinal = 0; function getCliproxyCacheDir(): string { return path.join(getCcsDir(), 'cache', 'cliproxy-usage'); @@ -64,14 +74,203 @@ function ensureCliproxyCacheDir(): void { } } -// --------------------------------------------------------------------------- -// Load cached data -// --------------------------------------------------------------------------- +function getSnapshotTimestamp(): number { + snapshotTimestampOrdinal = (snapshotTimestampOrdinal + 1) % 1000; + return Date.now() + snapshotTimestampOrdinal / 1000; +} + +function buildHourlyTimestamp(hour: string): string { + return `${hour.replace(' ', 'T')}:00.000Z`; +} + +function buildDailyTimestamp(date: string): string { + return `${date}T00:00:00.000Z`; +} + +function distributeRequestCounts(total: number, buckets: number): number[] { + if (buckets <= 0) return []; + const normalizedTotal = Math.max(buckets, total); + const base = Math.floor(normalizedTotal / buckets); + const remainder = normalizedTotal % buckets; + return Array.from({ length: buckets }, (_value, index) => base + (index < remainder ? 1 : 0)); +} + +function buildLegacyHistoryDetails( + snapshot: LegacyCliproxyUsageSnapshot +): CliproxyUsageHistoryDetail[] { + const details: CliproxyUsageHistoryDetail[] = []; + const coveredDailyKeys = new Set(); + + for (const hour of snapshot.hourly ?? []) { + const requestCounts = distributeRequestCounts( + hour.requestCount ?? hour.modelBreakdowns.length, + hour.modelBreakdowns.length + ); + + hour.modelBreakdowns.forEach((breakdown, index) => { + details.push({ + model: breakdown.modelName, + timestamp: buildHourlyTimestamp(hour.hour), + source: hour.source, + authIndex: 'legacy-hourly', + inputTokens: breakdown.inputTokens, + outputTokens: breakdown.outputTokens, + cacheReadTokens: breakdown.cacheReadTokens, + requestCount: requestCounts[index] ?? 1, + cost: breakdown.cost, + failed: false, + }); + coveredDailyKeys.add(`${hour.hour.slice(0, 10)}|${breakdown.modelName}`); + }); + } + + for (const day of snapshot.daily ?? []) { + for (const breakdown of day.modelBreakdowns) { + const key = `${day.date}|${breakdown.modelName}`; + if (coveredDailyKeys.has(key)) { + continue; + } + + details.push({ + model: breakdown.modelName, + timestamp: buildDailyTimestamp(day.date), + source: day.source, + authIndex: 'legacy-daily', + inputTokens: breakdown.inputTokens, + outputTokens: breakdown.outputTokens, + cacheReadTokens: breakdown.cacheReadTokens, + requestCount: 1, + cost: breakdown.cost, + failed: false, + }); + } + } + + return details; +} + +function migrateLegacySnapshot( + snapshot: LegacyCliproxyUsageSnapshot, + emitWarnings: boolean +): CliproxyUsageSnapshot | null { + const details = buildLegacyHistoryDetails(snapshot); + if (details.length === 0) { + if (emitWarnings) { + console.log( + info('CLIProxy legacy snapshot had no migratable history, will refresh on next sync') + ); + } + return null; + } + + if (emitWarnings) { + console.log(info('Loaded legacy CLIProxy snapshot into historical format')); + } + + const { daily, hourly, monthly } = buildCliproxyUsageHistoryAggregates(details); + return { + version: SNAPSHOT_VERSION, + timestamp: Number.isFinite(snapshot.timestamp) ? Number(snapshot.timestamp) : Date.now(), + details, + daily, + hourly, + monthly, + }; +} + +function readSnapshot(emitWarnings = true): CliproxyUsageSnapshot | null { + try { + const snapshotPath = getLatestSnapshotPath(); + if (!fs.existsSync(snapshotPath)) { + return null; + } + + const raw = fs.readFileSync(snapshotPath, 'utf-8'); + const snapshot = JSON.parse(raw) as CliproxyUsageSnapshot | LegacyCliproxyUsageSnapshot; + + if (snapshot.version === SNAPSHOT_VERSION) { + if (!Number.isFinite(snapshot.timestamp)) { + if (emitWarnings) { + console.log(info('CLIProxy snapshot timestamp invalid, will refresh on next sync')); + } + return null; + } + + if (!Array.isArray((snapshot as CliproxyUsageSnapshot).details)) { + if (emitWarnings) { + console.log(info('CLIProxy snapshot details missing, will refresh on next sync')); + } + return null; + } + + return snapshot as CliproxyUsageSnapshot; + } + + if (snapshot.version === 1 || snapshot.version === 2) { + return migrateLegacySnapshot(snapshot as LegacyCliproxyUsageSnapshot, emitWarnings); + } + + if (emitWarnings) { + console.log(info('CLIProxy snapshot version mismatch, will refresh on next sync')); + } + return null; + } catch (err) { + if (emitWarnings) { + console.log(warn('Failed to read CLIProxy snapshot:') + ` ${(err as Error).message}`); + } + return null; + } +} + +function buildSnapshot( + baseDetails: CliproxyUsageHistoryDetail[], + incomingDetails: CliproxyUsageHistoryDetail[] +): CliproxyUsageSnapshot { + const mergedDetails = pruneCliproxyUsageHistoryDetails( + mergeCliproxyUsageHistoryDetails(baseDetails, incomingDetails), + Date.now() - HISTORY_RETENTION_MS + ); + const { daily, hourly, monthly } = buildCliproxyUsageHistoryAggregates(mergedDetails); + + return { + version: SNAPSHOT_VERSION, + timestamp: getSnapshotTimestamp(), + details: mergedDetails, + daily, + hourly, + monthly, + }; +} + +async function writeSnapshotWithMerge( + incomingDetails: CliproxyUsageHistoryDetail[] +): Promise { + ensureCliproxyCacheDir(); + const snapshotPath = getLatestSnapshotPath(); + + for (let attempt = 0; attempt < MAX_WRITE_ATTEMPTS; attempt++) { + const baseSnapshot = readSnapshot(false); + const baseTimestamp = baseSnapshot?.timestamp ?? -Infinity; + const snapshot = buildSnapshot(baseSnapshot?.details ?? [], incomingDetails); + const tempFile = `${snapshotPath}.${process.pid}.${snapshot.timestamp}.tmp`; + + fs.writeFileSync(tempFile, JSON.stringify(snapshot), 'utf-8'); + + const latestSnapshot = readSnapshot(false); + const latestTimestamp = latestSnapshot?.timestamp ?? -Infinity; + if (latestTimestamp > baseTimestamp) { + fs.rmSync(tempFile, { force: true }); + continue; + } + + fs.renameSync(tempFile, snapshotPath); + console.log(ok('CLIProxy usage snapshot updated')); + return; + } + + throw new Error('Failed to write CLIProxy snapshot after repeated overlap retries'); +} -/** - * Read the latest CLIProxy usage snapshot from disk. - * Returns empty arrays on failure (file not found, parse error, version mismatch). - */ export async function loadCachedCliproxyData(): Promise<{ daily: DailyUsage[]; hourly: HourlyUsage[]; @@ -79,35 +278,19 @@ export async function loadCachedCliproxyData(): Promise<{ }> { const empty = { daily: [], hourly: [], monthly: [] }; - try { - const snapshotPath = getLatestSnapshotPath(); - if (!fs.existsSync(snapshotPath)) { - return empty; - } - - const raw = fs.readFileSync(snapshotPath, 'utf-8'); - const snapshot: CliproxyUsageSnapshot = JSON.parse(raw); - - if (snapshot.version !== SNAPSHOT_VERSION) { - console.log(info('CLIProxy snapshot version mismatch, will refresh on next sync')); - return empty; - } - - return { daily: snapshot.daily, hourly: snapshot.hourly, monthly: snapshot.monthly }; - } catch (err) { - console.log(warn('Failed to read CLIProxy snapshot:') + ` ${(err as Error).message}`); + const snapshot = readSnapshot(); + if (!snapshot) { return empty; } + + const age = Date.now() - snapshot.timestamp; + if (age > SNAPSHOT_MAX_AGE_MS) { + console.log(info('Using stale CLIProxy snapshot while proxy sync is unavailable')); + } + + return { daily: snapshot.daily, hourly: snapshot.hourly, monthly: snapshot.monthly }; } -// --------------------------------------------------------------------------- -// Sync -// --------------------------------------------------------------------------- - -/** - * Fetch latest CLIProxy usage data and persist a snapshot to disk. - * Non-fatal: logs warning and returns early if CLIProxy is unavailable. - */ export async function syncCliproxyUsage( fetchRaw: FetchCliproxyUsageRaw = fetchCliproxyUsageRaw ): Promise { @@ -119,41 +302,12 @@ export async function syncCliproxyUsage( } try { - ensureCliproxyCacheDir(); - - const daily = transformCliproxyToDailyUsage(raw); - const hourly = transformCliproxyToHourlyUsage(raw); - const monthly = transformCliproxyToMonthlyUsage(raw); - - const snapshot: CliproxyUsageSnapshot = { - version: SNAPSHOT_VERSION, - timestamp: Date.now(), - daily, - hourly, - monthly, - }; - - // Atomic write: temp file + rename - const snapshotPath = getLatestSnapshotPath(); - const tempFile = snapshotPath + '.tmp'; - fs.writeFileSync(tempFile, JSON.stringify(snapshot), 'utf-8'); - fs.renameSync(tempFile, snapshotPath); - - console.log(ok('CLIProxy usage snapshot updated')); + await writeSnapshotWithMerge(extractCliproxyUsageHistoryDetails(raw)); } catch (err) { - // Non-fatal - stale snapshot will continue to be served console.log(warn('Failed to write CLIProxy snapshot:') + ` ${(err as Error).message}`); } } -// --------------------------------------------------------------------------- -// Interval management -// --------------------------------------------------------------------------- - -/** - * Start periodic CLIProxy usage sync (every 5 minutes). - * Performs an immediate sync on startup. - */ export function startCliproxySync(syncNow: () => Promise = () => syncCliproxyUsage()): void { if (syncIntervalId !== null) { return; @@ -162,7 +316,6 @@ export function startCliproxySync(syncNow: () => Promise = () => syncClipr const intervalMin = Math.round(SYNC_INTERVAL_MS / 60_000); console.log(info(`Starting CLIProxy usage sync (interval: ${intervalMin} min)`)); - // Fire-and-forget initial sync void syncNow(); syncIntervalId = setInterval(() => { @@ -170,9 +323,6 @@ export function startCliproxySync(syncNow: () => Promise = () => syncClipr }, SYNC_INTERVAL_MS); } -/** - * Stop periodic CLIProxy usage sync. - */ export function stopCliproxySync(): void { if (syncIntervalId !== null) { clearInterval(syncIntervalId); diff --git a/src/web-server/usage/cliproxy-usage-transformer.ts b/src/web-server/usage/cliproxy-usage-transformer.ts index aff50311..92ddbfbb 100644 --- a/src/web-server/usage/cliproxy-usage-transformer.ts +++ b/src/web-server/usage/cliproxy-usage-transformer.ts @@ -8,15 +8,25 @@ import type { CliproxyUsageApiResponse, CliproxyRequestDetail } from '../../cliproxy/stats-fetcher'; import { calculateCost } from '../model-pricing'; import type { ModelBreakdown, DailyUsage, HourlyUsage, MonthlyUsage } from './types'; +import { getModelsUsed, normalizeUsageProvider } from './model-identity'; // ============================================================================ // INTERNAL HELPERS // ============================================================================ -/** Flat entry pairing a model name with its request detail */ -interface FlatDetail { +/** Persisted request detail used to rebuild historical CLIProxy analytics buckets */ +export interface CliproxyUsageHistoryDetail { model: string; - detail: CliproxyRequestDetail; + provider?: string; + timestamp: string; + source: string; + authIndex: string; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + requestCount: number; + cost: number; + failed: boolean; } /** Accumulator for token counts per model per time bucket */ @@ -24,79 +34,220 @@ interface ModelAccumulator { inputTokens: number; outputTokens: number; cacheReadTokens: number; + cost: number; } /** Build ModelBreakdown from accumulated token counts */ -function buildModelBreakdown(modelName: string, acc: ModelAccumulator): ModelBreakdown { - const { inputTokens, outputTokens, cacheReadTokens } = acc; - const cost = calculateCost( - { inputTokens, outputTokens, cacheCreationTokens: 0, cacheReadTokens }, - modelName - ); - return { modelName, inputTokens, outputTokens, cacheCreationTokens: 0, cacheReadTokens, cost }; +function buildModelBreakdown( + modelName: string, + provider: string | undefined, + acc: ModelAccumulator +): ModelBreakdown { + const { inputTokens, outputTokens, cacheReadTokens, cost } = acc; + return { + modelName, + ...(provider && { provider }), + inputTokens, + outputTokens, + cacheCreationTokens: 0, + cacheReadTokens, + cost, + }; +} + +function createHistoryDetail( + provider: string, + model: string, + detail: CliproxyRequestDetail +): CliproxyUsageHistoryDetail { + const pricingProvider = normalizeUsageProvider(provider) ?? provider.trim().toLowerCase(); + return { + model, + provider: pricingProvider, + timestamp: detail.timestamp, + source: detail.source, + authIndex: String(detail.auth_index), + inputTokens: detail.tokens?.input_tokens ?? 0, + outputTokens: detail.tokens?.output_tokens ?? 0, + cacheReadTokens: detail.tokens?.cached_tokens ?? 0, + requestCount: 1, + cost: calculateCost( + { + inputTokens: detail.tokens?.input_tokens ?? 0, + outputTokens: detail.tokens?.output_tokens ?? 0, + cacheCreationTokens: 0, + cacheReadTokens: detail.tokens?.cached_tokens ?? 0, + }, + model, + { provider: pricingProvider } + ), + failed: detail.failed, + }; } // ============================================================================ // FLATTEN // ============================================================================ +function hasTrackedUsage(detail: CliproxyRequestDetail): boolean { + const tokens = detail.tokens; + return ( + (tokens?.input_tokens ?? 0) > 0 || + (tokens?.output_tokens ?? 0) > 0 || + (tokens?.cached_tokens ?? 0) > 0 + ); +} + /** * Flatten the nested response.usage.apis[provider].models[model].details[] - * structure into a flat array. Failed requests are skipped. + * structure into normalized history details. Failed requests are retained only + * when they still report tracked token usage that analytics can account for. */ -export function flattenCliproxyDetails(response: CliproxyUsageApiResponse): FlatDetail[] { +export function extractCliproxyUsageHistoryDetails( + response: CliproxyUsageApiResponse +): CliproxyUsageHistoryDetail[] { const apis = response?.usage?.apis; if (!apis) return []; - const results: FlatDetail[] = []; - for (const providerData of Object.values(apis)) { + const results: CliproxyUsageHistoryDetail[] = []; + for (const [provider, providerData] of Object.entries(apis)) { const models = providerData?.models; if (!models) continue; for (const [model, modelData] of Object.entries(models)) { const details = modelData?.details; if (!details) continue; for (const detail of details) { - if (detail.failed) continue; - results.push({ model, detail }); + if (detail.failed && !hasTrackedUsage(detail)) continue; + results.push(createHistoryDetail(provider, model, detail)); } } } return results; } +function createHistorySignature(detail: CliproxyUsageHistoryDetail): string { + return [ + detail.model, + detail.provider ?? '', + detail.timestamp, + detail.source, + detail.authIndex, + detail.inputTokens, + detail.outputTokens, + detail.cacheReadTokens, + detail.requestCount, + detail.failed ? '1' : '0', + ].join('|'); +} + +export function mergeCliproxyUsageHistoryDetails( + existing: CliproxyUsageHistoryDetail[], + incoming: CliproxyUsageHistoryDetail[] +): CliproxyUsageHistoryDetail[] { + const existingCounts = new Map(); + for (const detail of existing) { + const signature = createHistorySignature(detail); + const entry = existingCounts.get(signature); + if (entry) { + entry.count += 1; + } else { + existingCounts.set(signature, { detail, count: 1 }); + } + } + + const incomingCounts = new Map(); + for (const detail of incoming) { + const signature = createHistorySignature(detail); + const entry = incomingCounts.get(signature); + if (entry) { + entry.count += 1; + } else { + incomingCounts.set(signature, { detail, count: 1 }); + } + } + + for (const [signature, incomingEntry] of incomingCounts) { + const existingEntry = existingCounts.get(signature); + if (!existingEntry || incomingEntry.count > existingEntry.count) { + existingCounts.set(signature, { + detail: incomingEntry.detail, + count: incomingEntry.count, + }); + } + } + + const merged: CliproxyUsageHistoryDetail[] = []; + for (const { detail, count } of existingCounts.values()) { + for (let index = 0; index < count; index++) { + merged.push({ ...detail }); + } + } + + return merged.sort((a, b) => a.timestamp.localeCompare(b.timestamp)); +} + +export function pruneCliproxyUsageHistoryDetails( + details: CliproxyUsageHistoryDetail[], + oldestTimestamp: number +): CliproxyUsageHistoryDetail[] { + return details.filter((detail) => { + const timestamp = Date.parse(detail.timestamp); + return Number.isFinite(timestamp) && timestamp >= oldestTimestamp; + }); +} + // ============================================================================ // GENERIC AGGREGATOR // ============================================================================ /** Group flat details by a time key extractor, return sorted DailyUsage-like records */ function aggregateByKey( - flat: FlatDetail[], + flat: CliproxyUsageHistoryDetail[], keyFn: (timestamp: string) => string, - buildRecord: (key: string, breakdowns: ModelBreakdown[]) => T, + buildRecord: (key: string, breakdowns: ModelBreakdown[], requestCount: number) => T, sortFn: (a: T, b: T) => number ): T[] { - // bucket: timeKey -> modelName -> accumulator - const buckets = new Map>(); + // bucket: timeKey -> provider/model key -> accumulator + const buckets = new Map< + string, + Map + >(); + const requestCounts = new Map(); - for (const { model, detail } of flat) { + for (const detail of flat) { const key = keyFn(detail.timestamp); if (!buckets.has(key)) buckets.set(key, new Map()); - const modelMap = buckets.get(key) as Map; - if (!modelMap.has(model)) { - modelMap.set(model, { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }); + requestCounts.set(key, (requestCounts.get(key) ?? 0) + detail.requestCount); + const modelMap = buckets.get(key) as Map< + string, + { modelName: string; provider?: string; acc: ModelAccumulator } + >; + const modelKey = `${detail.provider ?? ''}\u0000${detail.model}`; + if (!modelMap.has(modelKey)) { + modelMap.set(modelKey, { + modelName: detail.model, + provider: detail.provider, + acc: { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cost: 0, + }, + }); } - const acc = modelMap.get(model) as ModelAccumulator; - acc.inputTokens += detail.tokens?.input_tokens ?? 0; - acc.outputTokens += detail.tokens?.output_tokens ?? 0; - acc.cacheReadTokens += detail.tokens?.cached_tokens ?? 0; + const acc = (modelMap.get(modelKey) as { acc: ModelAccumulator }).acc; + acc.inputTokens += detail.inputTokens; + acc.outputTokens += detail.outputTokens; + acc.cacheReadTokens += detail.cacheReadTokens; + acc.cost += detail.cost; } const records: T[] = []; Array.from(buckets.entries()).forEach(([key, modelMap]) => { - const breakdowns = Array.from(modelMap.entries()).map(([name, acc]) => - buildModelBreakdown(name, acc) + const breakdowns = Array.from(modelMap.values()).map((entry) => + buildModelBreakdown(entry.modelName, entry.provider, entry.acc) ); - records.push(buildRecord(key, breakdowns)); + records.push(buildRecord(key, breakdowns, requestCounts.get(key) ?? 0)); }); return records.sort(sortFn); @@ -113,7 +264,7 @@ function sumField(breakdowns: ModelBreakdown[], field: keyof ModelBreakdown): nu /** Transform CLIProxy usage response into DailyUsage array (sorted descending by date) */ export function transformCliproxyToDailyUsage(response: CliproxyUsageApiResponse): DailyUsage[] { - const flat = flattenCliproxyDetails(response); + const flat = extractCliproxyUsageHistoryDetails(response); return aggregateByKey( flat, (ts) => ts.slice(0, 10), @@ -128,7 +279,7 @@ export function transformCliproxyToDailyUsage(response: CliproxyUsageApiResponse cacheReadTokens: sumField(breakdowns, 'cacheReadTokens'), cost: totalCost, totalCost, - modelsUsed: breakdowns.map((b) => b.modelName), + modelsUsed: getModelsUsed(breakdowns), modelBreakdowns: breakdowns, }; }, @@ -138,7 +289,7 @@ export function transformCliproxyToDailyUsage(response: CliproxyUsageApiResponse /** Transform CLIProxy usage response into HourlyUsage array (sorted descending by hour) */ export function transformCliproxyToHourlyUsage(response: CliproxyUsageApiResponse): HourlyUsage[] { - const flat = flattenCliproxyDetails(response); + const flat = extractCliproxyUsageHistoryDetails(response); return aggregateByKey( flat, (ts) => { @@ -146,7 +297,7 @@ export function transformCliproxyToHourlyUsage(response: CliproxyUsageApiRespons const hour = ts.slice(11, 13) || '00'; return `${date} ${hour}:00`; }, - (hour, breakdowns) => { + (hour, breakdowns, requestCount) => { const totalCost = sumField(breakdowns, 'cost'); return { hour, @@ -157,8 +308,9 @@ export function transformCliproxyToHourlyUsage(response: CliproxyUsageApiRespons cacheReadTokens: sumField(breakdowns, 'cacheReadTokens'), cost: totalCost, totalCost, - modelsUsed: breakdowns.map((b) => b.modelName), + modelsUsed: getModelsUsed(breakdowns), modelBreakdowns: breakdowns, + requestCount, }; }, (a, b) => b.hour.localeCompare(a.hour) @@ -169,7 +321,7 @@ export function transformCliproxyToHourlyUsage(response: CliproxyUsageApiRespons export function transformCliproxyToMonthlyUsage( response: CliproxyUsageApiResponse ): MonthlyUsage[] { - const flat = flattenCliproxyDetails(response); + const flat = extractCliproxyUsageHistoryDetails(response); return aggregateByKey( flat, (ts) => ts.slice(0, 7), @@ -181,9 +333,79 @@ export function transformCliproxyToMonthlyUsage( cacheCreationTokens: 0, cacheReadTokens: sumField(breakdowns, 'cacheReadTokens'), totalCost: sumField(breakdowns, 'cost'), - modelsUsed: breakdowns.map((b) => b.modelName), + modelsUsed: getModelsUsed(breakdowns), modelBreakdowns: breakdowns, }), (a, b) => b.month.localeCompare(a.month) ); } + +export function buildCliproxyUsageHistoryAggregates(details: CliproxyUsageHistoryDetail[]): { + daily: DailyUsage[]; + hourly: HourlyUsage[]; + monthly: MonthlyUsage[]; +} { + return { + daily: aggregateByKey( + details, + (timestamp) => timestamp.slice(0, 10), + (date, breakdowns) => { + const totalCost = sumField(breakdowns, 'cost'); + return { + date, + source: 'cliproxy', + inputTokens: sumField(breakdowns, 'inputTokens'), + outputTokens: sumField(breakdowns, 'outputTokens'), + cacheCreationTokens: 0, + cacheReadTokens: sumField(breakdowns, 'cacheReadTokens'), + cost: totalCost, + totalCost, + modelsUsed: getModelsUsed(breakdowns), + modelBreakdowns: breakdowns, + }; + }, + (a, b) => b.date.localeCompare(a.date) + ), + hourly: aggregateByKey( + details, + (timestamp) => { + const date = timestamp.slice(0, 10); + const hour = timestamp.slice(11, 13) || '00'; + return `${date} ${hour}:00`; + }, + (hour, breakdowns, requestCount) => { + const totalCost = sumField(breakdowns, 'cost'); + return { + hour, + source: 'cliproxy', + inputTokens: sumField(breakdowns, 'inputTokens'), + outputTokens: sumField(breakdowns, 'outputTokens'), + cacheCreationTokens: 0, + cacheReadTokens: sumField(breakdowns, 'cacheReadTokens'), + cost: totalCost, + totalCost, + modelsUsed: getModelsUsed(breakdowns), + modelBreakdowns: breakdowns, + requestCount, + }; + }, + (a, b) => b.hour.localeCompare(a.hour) + ), + monthly: aggregateByKey( + details, + (timestamp) => timestamp.slice(0, 7), + (month, breakdowns) => ({ + month, + source: 'cliproxy', + inputTokens: sumField(breakdowns, 'inputTokens'), + outputTokens: sumField(breakdowns, 'outputTokens'), + cacheCreationTokens: 0, + cacheReadTokens: sumField(breakdowns, 'cacheReadTokens'), + totalCost: sumField(breakdowns, 'cost'), + modelsUsed: getModelsUsed(breakdowns), + modelBreakdowns: breakdowns, + }), + (a, b) => b.month.localeCompare(a.month) + ), + }; +} diff --git a/src/web-server/usage/codex-native-usage-collector.ts b/src/web-server/usage/codex-native-usage-collector.ts new file mode 100644 index 00000000..9d4c8f4b --- /dev/null +++ b/src/web-server/usage/codex-native-usage-collector.ts @@ -0,0 +1,198 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as readline from 'readline'; +import type { RawUsageEntry } from '../jsonl-parser'; +import { resolveCodexConfigPaths } from '../services/codex-dashboard-service'; + +interface CodexNativeUsageCollectorOptions { + env?: NodeJS.ProcessEnv; + homeDir?: string; + includeCliproxySessions?: boolean; +} + +interface CodexTokenSnapshot { + inputTokens: number; + cacheReadTokens: number; + outputTokens: number; +} + +function isCliProxyBackedProvider(value: string | undefined): boolean { + return value === 'cliproxy' || value === 'ccs_runtime'; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value : null; +} + +function asNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0; +} + +function hasUsage(snapshot: CodexTokenSnapshot): boolean { + return snapshot.inputTokens > 0 || snapshot.cacheReadTokens > 0 || snapshot.outputTokens > 0; +} + +function normalizeTokenSnapshot(value: unknown): CodexTokenSnapshot | null { + if (!isObject(value)) return null; + return { + inputTokens: asNumber(value.input_tokens), + cacheReadTokens: asNumber(value.cached_input_tokens), + outputTokens: asNumber(value.output_tokens) + asNumber(value.reasoning_output_tokens), + }; +} + +function subtractSnapshots( + current: CodexTokenSnapshot, + previous: CodexTokenSnapshot +): CodexTokenSnapshot { + return { + inputTokens: Math.max(0, current.inputTokens - previous.inputTokens), + cacheReadTokens: Math.max(0, current.cacheReadTokens - previous.cacheReadTokens), + outputTokens: Math.max(0, current.outputTokens - previous.outputTokens), + }; +} + +function snapshotsEqual(left: CodexTokenSnapshot, right: CodexTokenSnapshot): boolean { + return ( + left.inputTokens === right.inputTokens && + left.cacheReadTokens === right.cacheReadTokens && + left.outputTokens === right.outputTokens + ); +} + +async function collectRolloutFiles(dir: string): Promise { + if (!fs.existsSync(dir)) return []; + + const files: string[] = []; + for (const entry of await fs.promises.readdir(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await collectRolloutFiles(entryPath))); + continue; + } + if (entry.isFile() && entry.name.startsWith('rollout-') && entry.name.endsWith('.jsonl')) { + files.push(entryPath); + } + } + + return files.sort(); +} + +async function parseRolloutFile( + filePath: string, + includeCliproxySessions: boolean +): Promise { + const entries: RawUsageEntry[] = []; + let sessionId = ''; + let projectPath = ''; + let version: string | undefined; + let modelProvider: string | undefined; + let model = 'unknown-codex-model'; + let previousTotal: CodexTokenSnapshot | null = null; + + const stream = fs.createReadStream(filePath, { encoding: 'utf8' }); + const reader = readline.createInterface({ input: stream, crlfDelay: Infinity }); + + try { + for await (const line of reader) { + if (!line.trim()) continue; + + let parsed: Record; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + + if (parsed.type === 'session_meta' && isObject(parsed.payload)) { + sessionId = asString(parsed.payload.id) ?? sessionId; + projectPath = asString(parsed.payload.cwd) ?? projectPath; + version = asString(parsed.payload.cli_version) ?? version; + modelProvider = asString(parsed.payload.model_provider) ?? modelProvider; + continue; + } + + if (parsed.type === 'turn_context' && isObject(parsed.payload)) { + model = asString(parsed.payload.model) ?? model; + projectPath = asString(parsed.payload.cwd) ?? projectPath; + continue; + } + + if ( + parsed.type !== 'event_msg' || + !isObject(parsed.payload) || + parsed.payload.type !== 'token_count' || + !sessionId || + (isCliProxyBackedProvider(modelProvider) && !includeCliproxySessions) + ) { + continue; + } + + const totalSnapshot = normalizeTokenSnapshot( + parsed.payload.info && isObject(parsed.payload.info) + ? (parsed.payload.info as Record).total_token_usage + : null + ); + const lastSnapshot = normalizeTokenSnapshot( + parsed.payload.info && isObject(parsed.payload.info) + ? (parsed.payload.info as Record).last_token_usage + : null + ); + + if (!totalSnapshot) continue; + if (previousTotal && snapshotsEqual(totalSnapshot, previousTotal)) continue; + + const delta = + previousTotal === null + ? lastSnapshot && hasUsage(lastSnapshot) + ? lastSnapshot + : totalSnapshot + : subtractSnapshots(totalSnapshot, previousTotal); + previousTotal = totalSnapshot; + + if (!hasUsage(delta)) continue; + + const timestamp = asString(parsed.timestamp); + if (!timestamp) continue; + + entries.push({ + inputTokens: delta.inputTokens, + outputTokens: delta.outputTokens, + cacheCreationTokens: 0, + cacheReadTokens: delta.cacheReadTokens, + model, + sessionId, + timestamp, + projectPath: projectPath || '/', + version, + target: 'codex', + }); + } + } finally { + reader.close(); + stream.destroy(); + } + + return entries; +} + +export async function scanCodexNativeUsageEntries( + options: CodexNativeUsageCollectorOptions = {} +): Promise { + const { baseDir } = resolveCodexConfigPaths({ + env: options.env, + homeDir: options.homeDir, + }); + const rolloutFiles = await collectRolloutFiles(path.join(baseDir, 'sessions')); + const entries: RawUsageEntry[] = []; + + for (const filePath of rolloutFiles) { + entries.push(...(await parseRolloutFile(filePath, options.includeCliproxySessions === true))); + } + + return entries; +} diff --git a/src/web-server/usage/data-aggregator.ts b/src/web-server/usage/data-aggregator.ts index b0f4fb5f..db2f5109 100644 --- a/src/web-server/usage/data-aggregator.ts +++ b/src/web-server/usage/data-aggregator.ts @@ -14,6 +14,7 @@ import { type MonthlyUsage, type SessionUsage, } from './types'; +import { getModelsUsed, normalizeUsageProvider } from './model-identity'; // ============================================================================ // HELPER FUNCTIONS @@ -39,6 +40,7 @@ function extractHour(timestamp: string): string { /** Create model breakdown from accumulated data */ function createModelBreakdown( modelName: string, + provider: string | undefined, inputTokens: number, outputTokens: number, cacheCreationTokens: number, @@ -46,11 +48,13 @@ function createModelBreakdown( ): ModelBreakdown { const cost = calculateCost( { inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens }, - modelName + modelName, + { provider } ); return { modelName, + ...(provider && { provider }), inputTokens, outputTokens, cacheCreationTokens, @@ -61,12 +65,33 @@ function createModelBreakdown( /** Accumulator for per-model token counts */ interface ModelAccumulator { + modelName: string; + provider?: string; inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; } +function getEntryProvider(entry: RawUsageEntry): string | undefined { + return normalizeUsageProvider(entry.target); +} + +function getEntryModelKey(entry: RawUsageEntry): string { + return `${getEntryProvider(entry) ?? ''}\u0000${entry.model}`; +} + +function createModelAccumulator(entry: RawUsageEntry): ModelAccumulator { + return { + modelName: entry.model, + provider: getEntryProvider(entry), + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + }; +} + // ============================================================================ // DAILY AGGREGATION // ============================================================================ @@ -101,19 +126,14 @@ export function aggregateDailyUsage( let totalCacheRead = 0; for (const entry of dateEntries) { - const model = entry.model; - const acc = modelMap.get(model) || { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - }; + const modelKey = getEntryModelKey(entry); + const acc = modelMap.get(modelKey) || createModelAccumulator(entry); acc.inputTokens += entry.inputTokens; acc.outputTokens += entry.outputTokens; acc.cacheCreationTokens += entry.cacheCreationTokens; acc.cacheReadTokens += entry.cacheReadTokens; - modelMap.set(model, acc); + modelMap.set(modelKey, acc); totalInput += entry.inputTokens; totalOutput += entry.outputTokens; @@ -125,9 +145,10 @@ export function aggregateDailyUsage( const modelBreakdowns: ModelBreakdown[] = []; let totalCost = 0; - for (const [modelName, acc] of modelMap) { + for (const acc of modelMap.values()) { const breakdown = createModelBreakdown( - modelName, + acc.modelName, + acc.provider, acc.inputTokens, acc.outputTokens, acc.cacheCreationTokens, @@ -149,7 +170,7 @@ export function aggregateDailyUsage( cacheReadTokens: totalCacheRead, cost: totalCost, totalCost, - modelsUsed: Array.from(modelMap.keys()), + modelsUsed: getModelsUsed(modelBreakdowns), modelBreakdowns, }); } @@ -194,19 +215,14 @@ export function aggregateHourlyUsage( let totalCacheRead = 0; for (const entry of hourEntries) { - const model = entry.model; - const acc = modelMap.get(model) || { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - }; + const modelKey = getEntryModelKey(entry); + const acc = modelMap.get(modelKey) || createModelAccumulator(entry); acc.inputTokens += entry.inputTokens; acc.outputTokens += entry.outputTokens; acc.cacheCreationTokens += entry.cacheCreationTokens; acc.cacheReadTokens += entry.cacheReadTokens; - modelMap.set(model, acc); + modelMap.set(modelKey, acc); totalInput += entry.inputTokens; totalOutput += entry.outputTokens; @@ -218,9 +234,10 @@ export function aggregateHourlyUsage( const modelBreakdowns: ModelBreakdown[] = []; let totalCost = 0; - for (const [modelName, acc] of modelMap) { + for (const acc of modelMap.values()) { const breakdown = createModelBreakdown( - modelName, + acc.modelName, + acc.provider, acc.inputTokens, acc.outputTokens, acc.cacheCreationTokens, @@ -242,8 +259,9 @@ export function aggregateHourlyUsage( cacheReadTokens: totalCacheRead, cost: totalCost, totalCost, - modelsUsed: Array.from(modelMap.keys()), + modelsUsed: getModelsUsed(modelBreakdowns), modelBreakdowns, + requestCount: hourEntries.length, }); } @@ -287,19 +305,14 @@ export function aggregateMonthlyUsage( let totalCacheRead = 0; for (const entry of monthEntries) { - const model = entry.model; - const acc = modelMap.get(model) || { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - }; + const modelKey = getEntryModelKey(entry); + const acc = modelMap.get(modelKey) || createModelAccumulator(entry); acc.inputTokens += entry.inputTokens; acc.outputTokens += entry.outputTokens; acc.cacheCreationTokens += entry.cacheCreationTokens; acc.cacheReadTokens += entry.cacheReadTokens; - modelMap.set(model, acc); + modelMap.set(modelKey, acc); totalInput += entry.inputTokens; totalOutput += entry.outputTokens; @@ -311,9 +324,10 @@ export function aggregateMonthlyUsage( const modelBreakdowns: ModelBreakdown[] = []; let totalCost = 0; - for (const [modelName, acc] of modelMap) { + for (const acc of modelMap.values()) { const breakdown = createModelBreakdown( - modelName, + acc.modelName, + acc.provider, acc.inputTokens, acc.outputTokens, acc.cacheCreationTokens, @@ -334,7 +348,7 @@ export function aggregateMonthlyUsage( cacheCreationTokens: totalCacheCreation, cacheReadTokens: totalCacheRead, totalCost, - modelsUsed: Array.from(modelMap.keys()), + modelsUsed: getModelsUsed(modelBreakdowns), modelBreakdowns, }); } @@ -387,19 +401,14 @@ export function aggregateSessionUsage( let target: string | undefined; for (const entry of orderedEntries) { - const model = entry.model; - const acc = modelMap.get(model) || { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - }; + const modelKey = getEntryModelKey(entry); + const acc = modelMap.get(modelKey) || createModelAccumulator(entry); acc.inputTokens += entry.inputTokens; acc.outputTokens += entry.outputTokens; acc.cacheCreationTokens += entry.cacheCreationTokens; acc.cacheReadTokens += entry.cacheReadTokens; - modelMap.set(model, acc); + modelMap.set(modelKey, acc); totalInput += entry.inputTokens; totalOutput += entry.outputTokens; @@ -430,9 +439,10 @@ export function aggregateSessionUsage( const modelBreakdowns: ModelBreakdown[] = []; let totalCost = 0; - for (const [modelName, acc] of modelMap) { + for (const acc of modelMap.values()) { const breakdown = createModelBreakdown( - modelName, + acc.modelName, + acc.provider, acc.inputTokens, acc.outputTokens, acc.cacheCreationTokens, @@ -456,7 +466,7 @@ export function aggregateSessionUsage( totalCost, lastActivity, versions: Array.from(versions), - modelsUsed: Array.from(modelMap.keys()), + modelsUsed: getModelsUsed(modelBreakdowns), modelBreakdowns, source, target, diff --git a/src/web-server/usage/droid-native-usage-collector.ts b/src/web-server/usage/droid-native-usage-collector.ts new file mode 100644 index 00000000..2814742d --- /dev/null +++ b/src/web-server/usage/droid-native-usage-collector.ts @@ -0,0 +1,215 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { RawUsageEntry } from '../jsonl-parser'; +import { resolveDroidConfigPaths } from '../services/droid-dashboard-service'; +import { warn } from '../../utils/ui'; +import { querySqliteJson } from './sqlite-cli'; + +export type DroidSqliteQuery = typeof querySqliteJson; + +interface DroidNativeUsageCollectorOptions { + env?: NodeJS.ProcessEnv; + homeDir?: string; + includeCcsManagedSessions?: boolean; + querySqliteJson?: DroidSqliteQuery; +} + +interface DroidSessionMetadata { + model: string; + projectPath: string; + rawSelector: string; + version?: string; +} + +const DROID_COST_BATCH_SIZE = 1000; + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function asNumber(value: unknown): number | null { + const numeric = typeof value === 'number' ? value : Number(value); + return Number.isFinite(numeric) && numeric >= 0 ? numeric : null; +} + +function isCcsManagedSelector(selector: string): boolean { + return selector.startsWith('custom:CCS-') || selector.startsWith('custom:ccs-'); +} + +function buildSelectorAlias(displayName: string, index: number): string { + return `${displayName.trim().replace(/\s+/g, '-')}-${index}`; +} + +function normalizeCustomModels(settings: Record): Array> { + const raw = settings.customModels ?? settings.custom_models; + if (Array.isArray(raw)) { + return raw.filter((entry): entry is Record => isObject(entry)); + } + if (isObject(raw)) { + return Object.values(raw).filter((entry): entry is Record => isObject(entry)); + } + return []; +} + +function buildCustomModelMap(settingsPath: string): Map { + const selectors = new Map(); + if (!fs.existsSync(settingsPath)) return selectors; + + try { + const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + if (!isObject(parsed)) return selectors; + + for (const [index, entry] of normalizeCustomModels(parsed).entries()) { + const displayName = asString(entry.displayName ?? entry.model_display_name); + const model = asString(entry.model); + if (!displayName || !model) continue; + selectors.set(`custom:${buildSelectorAlias(displayName, index)}`, model); + } + } catch { + return selectors; + } + + return selectors; +} + +function collectSessionFiles(dir: string, suffix: string, results: string[] = []): string[] { + if (!fs.existsSync(dir)) return results; + + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + collectSessionFiles(entryPath, suffix, results); + continue; + } + if (entry.isFile() && entry.name.endsWith(suffix)) { + results.push(entryPath); + } + } + + return results; +} + +function readSessionStartMetadata( + filePath: string +): { projectPath: string; version?: string } | null { + try { + const firstLine = fs.readFileSync(filePath, 'utf8').split('\n')[0]; + const parsed = JSON.parse(firstLine); + if (parsed?.type !== 'session_start') return null; + return { + projectPath: asString(parsed.cwd) ?? '/', + version: + typeof parsed.version === 'number' + ? String(parsed.version) + : (asString(parsed.version) ?? undefined), + }; + } catch { + return null; + } +} + +function loadSessionMetadata(factoryDir: string): Map { + const metadata = new Map(); + const selectorMap = buildCustomModelMap(path.join(factoryDir, 'settings.json')); + const sessionsDir = path.join(factoryDir, 'sessions'); + + for (const settingsPath of collectSessionFiles(sessionsDir, '.settings.json')) { + const sessionId = path.basename(settingsPath, '.settings.json'); + const jsonlPath = settingsPath.replace(/\.settings\.json$/, '.jsonl'); + const start = readSessionStartMetadata(jsonlPath); + if (!start) continue; + + try { + const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + const rawSelector = asString(parsed?.model) ?? 'unknown-droid-model'; + metadata.set(sessionId, { + model: selectorMap.get(rawSelector) ?? rawSelector, + projectPath: start.projectPath, + rawSelector, + version: start.version, + }); + } catch { + continue; + } + } + + return metadata; +} + +export async function scanDroidNativeUsageEntries( + options: DroidNativeUsageCollectorOptions = {} +): Promise { + const query = options.querySqliteJson ?? querySqliteJson; + const { settingsPath } = resolveDroidConfigPaths({ + env: options.env, + homeDir: options.homeDir, + }); + const factoryDir = path.dirname(settingsPath); + const costsDbPath = path.join(factoryDir, 'costs.db'); + if (!fs.existsSync(costsDbPath)) return []; + + const rows: Array> = []; + let offset = 0; + while (true) { + const batch = await query( + costsDbPath, + `SELECT session_id, timestamp, input_tokens, output_tokens FROM costs ` + + `ORDER BY timestamp ASC LIMIT ${DROID_COST_BATCH_SIZE} OFFSET ${offset};` + ); + if (!batch.length) break; + rows.push(...batch); + if (batch.length < DROID_COST_BATCH_SIZE) break; + offset += batch.length; + } + if (!rows.length) return []; + + const metadata = loadSessionMetadata(factoryDir); + const entries: RawUsageEntry[] = []; + let skippedRowsWithoutMetadata = 0; + + for (const row of rows) { + if (!isObject(row)) continue; + const sessionId = asString(row.session_id); + const timestamp = asString(row.timestamp); + const inputTokens = asNumber(row.input_tokens); + const outputTokens = asNumber(row.output_tokens); + if (!sessionId || !timestamp || inputTokens === null || outputTokens === null) continue; + + const session = metadata.get(sessionId); + if (!session) { + skippedRowsWithoutMetadata++; + continue; + } + + if (!options.includeCcsManagedSessions && isCcsManagedSelector(session.rawSelector)) { + continue; + } + + entries.push({ + inputTokens, + outputTokens, + cacheCreationTokens: 0, + cacheReadTokens: 0, + model: session.model, + sessionId, + timestamp, + projectPath: session.projectPath, + version: session.version, + target: 'droid', + }); + } + + if (skippedRowsWithoutMetadata > 0) { + console.log( + warn( + `Skipped ${skippedRowsWithoutMetadata} Droid native cost row(s) without local session metadata` + ) + ); + } + + return entries; +} diff --git a/src/web-server/usage/handlers.ts b/src/web-server/usage/handlers.ts index e9e34668..5e6e14dc 100644 --- a/src/web-server/usage/handlers.ts +++ b/src/web-server/usage/handlers.ts @@ -13,9 +13,15 @@ import { getCachedMonthlyData, getCachedSessionData, getCachedHourlyData, + getUsageCacheSize, getLastFetchTimestamp, refreshUsageCache, } from './aggregator'; +import { + coalesceLegacyProviderlessBreakdowns, + getModelsUsed, + getProviderModelKey, +} from './model-identity'; // ============================================================================ // Types @@ -87,6 +93,13 @@ export function validateOffset(offset?: string): number { return num; } +export function validateDateRangeOrder(since?: string, until?: string): void { + if (!since || !until) return; + if (since > until) { + throw new Error('The "since" date must be earlier than or equal to "until"'); + } +} + export function filterByDateRange< T extends { date?: string; month?: string; lastActivity?: string }, >(data: T[] | undefined, since?: string, until?: string): T[] { @@ -120,6 +133,66 @@ export function errorResponse(res: Response, error: unknown, defaultMessage: str }); } +function roundToCurrency(value: number): number { + return Math.round((value + Number.EPSILON) * 100) / 100; +} + +function calculateUsageTotalTokens( + input: number, + output: number, + cacheCreation: number, + cacheRead: number +): number { + return input + output + cacheCreation + cacheRead; +} + +function parseDateKey(dateString: string): Date { + return new Date( + Date.UTC( + Number(dateString.slice(0, 4)), + Number(dateString.slice(4, 6)) - 1, + Number(dateString.slice(6, 8)) + ) + ); +} + +function getInclusiveDayCount(start: Date, end: Date): number { + const dayMs = 24 * 60 * 60 * 1000; + return Math.floor((end.getTime() - start.getTime()) / dayMs) + 1; +} + +function countCalendarDays(data: DailyUsage[], since?: string, until?: string): number { + const sortedDates = [...data] + .map((item) => item.date.replace(/-/g, '')) + .sort((a, b) => a.localeCompare(b)); + const earliestDate = sortedDates[0]; + const latestDate = sortedDates[sortedDates.length - 1]; + + if (since && until) { + return getInclusiveDayCount(parseDateKey(since), parseDateKey(until)); + } + + if (since) { + const end = until + ? parseDateKey(until) + : latestDate + ? parseDateKey(latestDate) + : parseDateKey(since); + return getInclusiveDayCount(parseDateKey(since), end); + } + + if (until) { + const start = earliestDate ? parseDateKey(earliestDate) : parseDateKey(until); + return getInclusiveDayCount(start, parseDateKey(until)); + } + + if (data.length === 0) { + return 0; + } + + return getInclusiveDayCount(parseDateKey(earliestDate), parseDateKey(latestDate)); +} + // ============================================================================ // Cost Calculation Helpers // ============================================================================ @@ -136,7 +209,7 @@ export function calculateTokenBreakdownCosts(dailyData: DailyUsage[]): TokenBrea for (const day of dailyData) { for (const breakdown of day.modelBreakdowns) { - const pricing = getModelPricing(breakdown.modelName); + const pricing = getModelPricing(breakdown.modelName, { provider: breakdown.provider }); inputTokens += breakdown.inputTokens; outputTokens += breakdown.outputTokens; cacheCreationTokens += breakdown.cacheCreationTokens; @@ -150,10 +223,10 @@ export function calculateTokenBreakdownCosts(dailyData: DailyUsage[]): TokenBrea } return { - input: { tokens: inputTokens, cost: Math.round(inputCost * 100) / 100 }, - output: { tokens: outputTokens, cost: Math.round(outputCost * 100) / 100 }, - cacheCreation: { tokens: cacheCreationTokens, cost: Math.round(cacheCreationCost * 100) / 100 }, - cacheRead: { tokens: cacheReadTokens, cost: Math.round(cacheReadCost * 100) / 100 }, + input: { tokens: inputTokens, cost: roundToCurrency(inputCost) }, + output: { tokens: outputTokens, cost: roundToCurrency(outputCost) }, + cacheCreation: { tokens: cacheCreationTokens, cost: roundToCurrency(cacheCreationCost) }, + cacheRead: { tokens: cacheReadTokens, cost: roundToCurrency(cacheReadCost) }, }; } @@ -334,6 +407,7 @@ export async function handleSummary( try { const since = validateDate(req.query.since); const until = validateDate(req.query.until); + validateDateRangeOrder(since, until); const dailyData = await getCachedDailyData(); const filtered = filterByDateRange(dailyData, since, until); @@ -351,8 +425,15 @@ export async function handleSummary( totalCost += day.totalCost; } - const totalTokens = totalInputTokens + totalOutputTokens; + const totalTokens = calculateUsageTotalTokens( + totalInputTokens, + totalOutputTokens, + totalCacheCreationTokens, + totalCacheReadTokens + ); const tokenBreakdown = calculateTokenBreakdownCosts(filtered); + const totalDays = countCalendarDays(filtered, since, until); + const activeDays = filtered.length; res.json({ success: true, @@ -363,12 +444,14 @@ export async function handleSummary( totalCacheTokens: totalCacheCreationTokens + totalCacheReadTokens, totalCacheCreationTokens, totalCacheReadTokens, - totalCost: Math.round(totalCost * 100) / 100, + totalCost: roundToCurrency(totalCost), tokenBreakdown, - totalDays: filtered.length, - averageTokensPerDay: filtered.length > 0 ? Math.round(totalTokens / filtered.length) : 0, - averageCostPerDay: - filtered.length > 0 ? Math.round((totalCost / filtered.length) * 100) / 100 : 0, + totalDays, + activeDays, + averageTokensPerDay: totalDays > 0 ? Math.round(totalTokens / totalDays) : 0, + averageTokensPerActiveDay: activeDays > 0 ? Math.round(totalTokens / activeDays) : 0, + averageCostPerDay: totalDays > 0 ? roundToCurrency(totalCost / totalDays) : 0, + averageCostPerActiveDay: activeDays > 0 ? roundToCurrency(totalCost / activeDays) : 0, }, }); } catch (error) { @@ -383,16 +466,22 @@ export async function handleDaily( try { const since = validateDate(req.query.since); const until = validateDate(req.query.until); + validateDateRangeOrder(since, until); const dailyData = await getCachedDailyData(); const filtered = filterByDateRange(dailyData, since, until); const trends = filtered.map((day) => ({ date: day.date, - tokens: day.inputTokens + day.outputTokens, + tokens: calculateUsageTotalTokens( + day.inputTokens, + day.outputTokens, + day.cacheCreationTokens, + day.cacheReadTokens + ), inputTokens: day.inputTokens, outputTokens: day.outputTokens, cacheTokens: day.cacheCreationTokens + day.cacheReadTokens, - cost: Math.round(day.totalCost * 100) / 100, + cost: roundToCurrency(day.totalCost), modelsUsed: day.modelsUsed.length, })); @@ -409,6 +498,7 @@ export async function handleHourly( try { const since = validateDate(req.query.since); const until = validateDate(req.query.until); + validateDateRangeOrder(since, until); const hourlyData = await getCachedHourlyData(); const filtered = (hourlyData || []).filter((h) => { @@ -420,13 +510,18 @@ export async function handleHourly( const trends = filtered.map((hour) => ({ hour: hour.hour, - tokens: hour.inputTokens + hour.outputTokens, + tokens: calculateUsageTotalTokens( + hour.inputTokens, + hour.outputTokens, + hour.cacheCreationTokens, + hour.cacheReadTokens + ), inputTokens: hour.inputTokens, outputTokens: hour.outputTokens, cacheTokens: hour.cacheCreationTokens + hour.cacheReadTokens, - cost: Math.round(hour.totalCost * 100) / 100, + cost: roundToCurrency(hour.totalCost), modelsUsed: hour.modelsUsed.length, - requests: hour.modelBreakdowns.length, + requests: hour.requestCount ?? hour.modelBreakdowns.length, })); const filledTrends = fillHourlyGaps(trends, since, until); @@ -443,6 +538,7 @@ export async function handleModels( try { const since = validateDate(req.query.since); const until = validateDate(req.query.until); + validateDateRangeOrder(since, until); const dailyData = await getCachedDailyData(); const filtered = filterByDateRange(dailyData, since, until); @@ -450,6 +546,7 @@ export async function handleModels( string, { model: string; + provider?: string; inputTokens: number; outputTokens: number; cacheCreationTokens: number; @@ -460,8 +557,10 @@ export async function handleModels( for (const day of filtered) { for (const breakdown of day.modelBreakdowns) { - const existing = modelMap.get(breakdown.modelName) || { + const modelKey = getProviderModelKey(breakdown); + const existing = modelMap.get(modelKey) || { model: breakdown.modelName, + provider: breakdown.provider, inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, @@ -473,44 +572,59 @@ export async function handleModels( existing.cacheCreationTokens += breakdown.cacheCreationTokens; existing.cacheReadTokens += breakdown.cacheReadTokens; existing.cost += breakdown.cost; - modelMap.set(breakdown.modelName, existing); + modelMap.set(modelKey, existing); } } const models = Array.from(modelMap.values()); - const totalTokens = models.reduce((sum, m) => sum + m.inputTokens + m.outputTokens, 0); + const totalTokens = models.reduce( + (sum, model) => + sum + + calculateUsageTotalTokens( + model.inputTokens, + model.outputTokens, + model.cacheCreationTokens, + model.cacheReadTokens + ), + 0 + ); const result = models .map((m) => { - const pricing = getModelPricing(m.model); + const pricing = getModelPricing(m.model, { provider: m.provider }); const inputCost = (m.inputTokens / 1_000_000) * pricing.inputPerMillion; const outputCost = (m.outputTokens / 1_000_000) * pricing.outputPerMillion; const cacheCreationCost = (m.cacheCreationTokens / 1_000_000) * pricing.cacheCreationPerMillion; const cacheReadCost = (m.cacheReadTokens / 1_000_000) * pricing.cacheReadPerMillion; const ioRatio = m.outputTokens > 0 ? m.inputTokens / m.outputTokens : 0; + const totalModelTokens = calculateUsageTotalTokens( + m.inputTokens, + m.outputTokens, + m.cacheCreationTokens, + m.cacheReadTokens + ); return { model: m.model, - tokens: m.inputTokens + m.outputTokens, + provider: m.provider, + tokens: totalModelTokens, inputTokens: m.inputTokens, outputTokens: m.outputTokens, cacheCreationTokens: m.cacheCreationTokens, cacheReadTokens: m.cacheReadTokens, cacheTokens: m.cacheCreationTokens + m.cacheReadTokens, - cost: Math.round(m.cost * 100) / 100, + cost: roundToCurrency(m.cost), percentage: - totalTokens > 0 - ? Math.round(((m.inputTokens + m.outputTokens) / totalTokens) * 1000) / 10 - : 0, + totalTokens > 0 ? Math.round((totalModelTokens / totalTokens) * 1000) / 10 : 0, costBreakdown: { - input: { tokens: m.inputTokens, cost: Math.round(inputCost * 100) / 100 }, - output: { tokens: m.outputTokens, cost: Math.round(outputCost * 100) / 100 }, + input: { tokens: m.inputTokens, cost: roundToCurrency(inputCost) }, + output: { tokens: m.outputTokens, cost: roundToCurrency(outputCost) }, cacheCreation: { tokens: m.cacheCreationTokens, - cost: Math.round(cacheCreationCost * 100) / 100, + cost: roundToCurrency(cacheCreationCost), }, - cacheRead: { tokens: m.cacheReadTokens, cost: Math.round(cacheReadCost * 100) / 100 }, + cacheRead: { tokens: m.cacheReadTokens, cost: roundToCurrency(cacheReadCost) }, }, ioRatio: Math.round(ioRatio * 10) / 10, }; @@ -530,6 +644,7 @@ export async function handleSessions( try { const since = validateDate(req.query.since); const until = validateDate(req.query.until); + validateDateRangeOrder(since, until); const limit = validateLimit(req.query.limit); const offset = validateOffset(req.query.offset); @@ -543,10 +658,15 @@ export async function handleSessions( const sessions = paginated.map((s) => ({ sessionId: s.sessionId, projectPath: s.projectPath, - tokens: s.inputTokens + s.outputTokens, + tokens: calculateUsageTotalTokens( + s.inputTokens, + s.outputTokens, + s.cacheCreationTokens, + s.cacheReadTokens + ), inputTokens: s.inputTokens, outputTokens: s.outputTokens, - cost: Math.round(s.totalCost * 100) / 100, + cost: roundToCurrency(s.totalCost), lastActivity: s.lastActivity, modelsUsed: s.modelsUsed, target: s.target || 'claude', @@ -574,25 +694,116 @@ export async function handleMonthly( try { const since = validateDate(req.query.since); const until = validateDate(req.query.until); - const monthlyData = await getCachedMonthlyData(); + validateDateRangeOrder(since, until); + let filtered: Array<{ + month: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + totalCost: number; + modelsUsed: string[]; + modelBreakdowns: DailyUsage['modelBreakdowns']; + }>; - const filtered = - since || until - ? monthlyData.filter((m) => { - const monthDate = m.month.replace('-', '') + '01'; - if (since && monthDate < since) return false; - if (until && monthDate > until) return false; - return true; - }) - : monthlyData; + if (since || until) { + const dailyData = filterByDateRange(await getCachedDailyData(), since, until); + const monthMap = new Map< + string, + { + month: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + totalCost: number; + modelBreakdowns: Map< + string, + { + modelName: string; + provider?: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: number; + } + >; + } + >(); + + for (const day of dailyData) { + const month = day.date.slice(0, 7); + const existing = monthMap.get(month) ?? { + month, + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalCost: 0, + modelBreakdowns: new Map(), + }; + + existing.inputTokens += day.inputTokens; + existing.outputTokens += day.outputTokens; + existing.cacheCreationTokens += day.cacheCreationTokens; + existing.cacheReadTokens += day.cacheReadTokens; + existing.totalCost += day.totalCost; + for (const breakdown of day.modelBreakdowns) { + const breakdownKey = getProviderModelKey(breakdown); + const existingBreakdown = existing.modelBreakdowns.get(breakdownKey) ?? { + modelName: breakdown.modelName, + provider: breakdown.provider, + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 0, + }; + existingBreakdown.inputTokens += breakdown.inputTokens; + existingBreakdown.outputTokens += breakdown.outputTokens; + existingBreakdown.cacheCreationTokens += breakdown.cacheCreationTokens; + existingBreakdown.cacheReadTokens += breakdown.cacheReadTokens; + existingBreakdown.cost += breakdown.cost; + existing.modelBreakdowns.set(breakdownKey, existingBreakdown); + } + + monthMap.set(month, existing); + } + + filtered = Array.from(monthMap.values()) + .map((month) => { + const modelBreakdowns = coalesceLegacyProviderlessBreakdowns( + Array.from(month.modelBreakdowns.values()) + ); + return { + month: month.month, + inputTokens: month.inputTokens, + outputTokens: month.outputTokens, + cacheCreationTokens: month.cacheCreationTokens, + cacheReadTokens: month.cacheReadTokens, + totalCost: month.totalCost, + modelBreakdowns, + modelsUsed: getModelsUsed(modelBreakdowns), + }; + }) + .sort((a, b) => a.month.localeCompare(b.month)); + } else { + filtered = await getCachedMonthlyData(); + } const result = filtered.map((m) => ({ month: m.month, - tokens: m.inputTokens + m.outputTokens, + tokens: calculateUsageTotalTokens( + m.inputTokens, + m.outputTokens, + m.cacheCreationTokens, + m.cacheReadTokens + ), inputTokens: m.inputTokens, outputTokens: m.outputTokens, cacheTokens: m.cacheCreationTokens + m.cacheReadTokens, - cost: Math.round(m.totalCost * 100) / 100, + cost: roundToCurrency(m.totalCost), modelsUsed: m.modelsUsed.length, })); @@ -612,10 +823,9 @@ export async function handleRefresh(_req: Request, res: Response): Promise } export function handleStatus(_req: Request, res: Response): void { - const cache = new Map(); // Note: this is a placeholder, actual cache is in aggregator res.json({ success: true, - data: { lastFetch: getLastFetchTimestamp(), cacheSize: cache.size }, + data: { lastFetch: getLastFetchTimestamp(), cacheSize: getUsageCacheSize() }, }); } @@ -626,6 +836,7 @@ export async function handleInsights( try { const since = validateDate(req.query.since); const until = validateDate(req.query.until); + validateDateRangeOrder(since, until); const dailyData = await getCachedDailyData(); const filtered = filterByDateRange(dailyData, since, until); const anomalies = detectAnomalies(filtered); diff --git a/src/web-server/usage/model-identity.ts b/src/web-server/usage/model-identity.ts new file mode 100644 index 00000000..7336ac26 --- /dev/null +++ b/src/web-server/usage/model-identity.ts @@ -0,0 +1,94 @@ +import { normalizeModelsDevProviderId } from '../models-dev/pricing-resolver'; + +export interface ProviderModelIdentity { + modelName: string; + provider?: string; +} + +export interface MergeableProviderModelBreakdown extends ProviderModelIdentity { + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: number; +} + +export function normalizeUsageProvider(provider: string | undefined): string | undefined { + return normalizeModelsDevProviderId(provider); +} + +function getProviderKey(provider: string | undefined): string { + return normalizeUsageProvider(provider) ?? ''; +} + +function getModelUsageLabel(item: ProviderModelIdentity, ambiguousModelNames: Set): string { + const provider = getProviderKey(item.provider); + if (provider && ambiguousModelNames.has(item.modelName)) { + return `${provider}/${item.modelName}`; + } + return item.modelName; +} + +export function getProviderModelKey(item: ProviderModelIdentity): string { + return `${getProviderKey(item.provider)}\u0000${item.modelName}`; +} + +export function getModelsUsed(items: ProviderModelIdentity[]): string[] { + const providersByModel = new Map>(); + for (const item of items) { + const providers = providersByModel.get(item.modelName) ?? new Set(); + providers.add(getProviderKey(item.provider)); + providersByModel.set(item.modelName, providers); + } + + const ambiguousModelNames = new Set( + Array.from(providersByModel.entries()) + .filter(([, providers]) => providers.size > 1) + .map(([modelName]) => modelName) + ); + + return [...new Set(items.map((item) => getModelUsageLabel(item, ambiguousModelNames)))]; +} + +function addBreakdownTokens(target: T, source: T): void { + target.inputTokens += source.inputTokens; + target.outputTokens += source.outputTokens; + target.cacheCreationTokens += source.cacheCreationTokens; + target.cacheReadTokens += source.cacheReadTokens; + target.cost += source.cost; +} + +export function coalesceLegacyProviderlessBreakdowns( + items: T[] +): T[] { + const byModel = new Map(); + for (const item of items) { + const existing = byModel.get(item.modelName) ?? []; + existing.push(item); + byModel.set(item.modelName, existing); + } + + const coalesced: T[] = []; + for (const group of byModel.values()) { + const providerBreakdowns = group.filter((item) => getProviderKey(item.provider)); + const legacyBreakdowns = group.filter((item) => !getProviderKey(item.provider)); + const providerKeys = new Set(providerBreakdowns.map((item) => getProviderKey(item.provider))); + + if (legacyBreakdowns.length > 0 && providerKeys.size === 1 && providerBreakdowns.length > 0) { + const provider = Array.from(providerKeys)[0]; + const [firstProviderBreakdown, ...remainingProviderBreakdowns] = providerBreakdowns; + const merged = { ...firstProviderBreakdown, provider } as T; + + for (const breakdown of [...remainingProviderBreakdowns, ...legacyBreakdowns]) { + addBreakdownTokens(merged, breakdown); + } + + coalesced.push(merged); + continue; + } + + coalesced.push(...group.map((item) => ({ ...item }))); + } + + return coalesced; +} diff --git a/src/web-server/usage/sqlite-cli.ts b/src/web-server/usage/sqlite-cli.ts new file mode 100644 index 00000000..cde5afcb --- /dev/null +++ b/src/web-server/usage/sqlite-cli.ts @@ -0,0 +1,40 @@ +import { execFile } from 'child_process'; +import * as fs from 'fs'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); +const SQLITE_JSON_MAX_BUFFER = 10 * 1024 * 1024; + +export type SqliteJsonRow = Record; + +function isCommandMissing(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const nodeError = error as Error & { code?: string }; + return nodeError.code === 'ENOENT' || /not found/i.test(nodeError.message); +} + +export async function querySqliteJson(dbPath: string, sql: string): Promise { + if (!fs.existsSync(dbPath)) { + return []; + } + + try { + const { stdout } = await execFileAsync('sqlite3', ['-json', dbPath, sql], { + maxBuffer: SQLITE_JSON_MAX_BUFFER, + }); + const trimmed = stdout.trim(); + if (!trimmed) { + return []; + } + + const parsed = JSON.parse(trimmed); + return Array.isArray(parsed) ? (parsed as SqliteJsonRow[]) : []; + } catch (error) { + if (isCommandMissing(error)) { + throw new Error('sqlite3 command not available'); + } + + const message = error instanceof Error ? error.message : String(error); + throw new Error(`sqlite3 query failed for ${dbPath}: ${message}`); + } +} diff --git a/src/web-server/usage/types.ts b/src/web-server/usage/types.ts index 1866e783..e5588263 100644 --- a/src/web-server/usage/types.ts +++ b/src/web-server/usage/types.ts @@ -12,6 +12,7 @@ /** Per-model token and cost breakdown */ export interface ModelBreakdown { modelName: string; + provider?: string; inputTokens: number; outputTokens: number; cacheCreationTokens: number; @@ -49,6 +50,11 @@ export interface HourlyUsage { totalCost: number; modelsUsed: string[]; modelBreakdowns: ModelBreakdown[]; + /** + * Raw request count for this hour bucket. + * Optional for backward compatibility with previously persisted snapshots. + */ + requestCount?: number; } /** Monthly usage aggregation (YYYY-MM) */ diff --git a/tests/e2e/image-analyzer-hook.e2e.test.ts b/tests/e2e/image-analyzer-hook.e2e.test.ts index 32607646..d13a3a83 100644 --- a/tests/e2e/image-analyzer-hook.e2e.test.ts +++ b/tests/e2e/image-analyzer-hook.e2e.test.ts @@ -137,6 +137,30 @@ function resetMockState(): void { /** * Invoke the hook with JSON input */ +function buildHookEnv(env: Record = {}): NodeJS.ProcessEnv { + const baseEnv: NodeJS.ProcessEnv = { ...process.env }; + for (const key of Object.keys(baseEnv)) { + if ( + key.startsWith('CCS_IMAGE_ANALYSIS_') || + key === 'CCS_CURRENT_PROVIDER' || + key === 'CCS_PROFILE_TYPE' || + key === 'CCS_CLIPROXY_API_KEY' || + key === 'CCS_CLIPROXY_PORT' + ) { + delete baseEnv[key]; + } + } + + return { + ...baseEnv, + CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY, + CCS_CLIPROXY_PORT: String(MOCK_PORT), + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: DEFAULT_PROVIDER_MODELS, + CCS_CURRENT_PROVIDER: DEFAULT_PROVIDER, + ...env, + }; +} + function invokeHook( input: object, env: Record = {} @@ -144,15 +168,7 @@ function invokeHook( const result = spawnSync('node', [HOOK_PATH], { input: JSON.stringify(input), encoding: 'utf8', - env: { - ...process.env, - CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY, - CCS_CLIPROXY_PORT: String(MOCK_PORT), - // Default provider config for tests (can be overridden) - CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: DEFAULT_PROVIDER_MODELS, - CCS_CURRENT_PROVIDER: DEFAULT_PROVIDER, - ...env, - }, + env: buildHookEnv(env), timeout: 10000, // 10 second timeout per test }); @@ -169,14 +185,7 @@ function invokeHookAsync( ): Promise<{ code: number; stdout: string; stderr: string }> { return new Promise((resolve, reject) => { const child = spawn('node', [HOOK_PATH], { - env: { - ...process.env, - CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY, - CCS_CLIPROXY_PORT: String(MOCK_PORT), - CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: DEFAULT_PROVIDER_MODELS, - CCS_CURRENT_PROVIDER: DEFAULT_PROVIDER, - ...env, - }, + env: buildHookEnv(env), stdio: ['pipe', 'pipe', 'pipe'], }); diff --git a/tests/integration/proxy/daemon-lifecycle.test.ts b/tests/integration/proxy/daemon-lifecycle.test.ts index 0d694a3c..6fc5e15f 100644 --- a/tests/integration/proxy/daemon-lifecycle.test.ts +++ b/tests/integration/proxy/daemon-lifecycle.test.ts @@ -12,6 +12,8 @@ import { import { resolveOpenAICompatProxyPreferredPort } from '../../../src/proxy/proxy-port-resolver'; import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-router'; import { + OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_END, + OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_START, getLegacyOpenAICompatProxyPidPath, getLegacyOpenAICompatProxySessionPath, getOpenAICompatProxyPidPath, @@ -38,6 +40,37 @@ afterEach(async () => { fs.rmSync(tempDir, { recursive: true, force: true }); }); +async function findProfileNameWithFreeAdaptivePort(prefix: string): Promise { + for (let index = 0; index < 200; index += 1) { + const profileName = `${prefix}-${index}`; + const preferredPort = resolveOpenAICompatProxyPreferredPort(profileName); + const availablePort = await getPort({ port: preferredPort, host: '127.0.0.1' }); + if (availablePort === preferredPort) { + return profileName; + } + } + + throw new Error(`No free adaptive proxy port found for ${prefix}`); +} + +async function getPortOutsideOpenAICompatAdaptiveRange(): Promise { + for (let attempt = 0; attempt < 10; attempt += 1) { + const rangeStart = 45_000 + attempt * 101; + const port = await getPort({ + port: getPort.makeRange(rangeStart, rangeStart + 100), + host: '127.0.0.1', + }); + if ( + port < OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_START || + port > OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_END + ) { + return port; + } + } + + throw new Error('No stale proxy fixture port found outside the adaptive range'); +} + describe('openai proxy daemon lifecycle', () => { it('starts, reports status, serves health/models, and stops', async () => { const port = await getPort(); @@ -395,13 +428,13 @@ describe('openai proxy daemon lifecycle', () => { }); it('keeps the existing proxy running if replacement startup fails', async () => { - const firstPort = await getPort(); const busyServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('busy'), }); const occupiedPort = busyServer.port; + const firstPort = await getPort({ exclude: [occupiedPort] }); try { const settingsPath = path.join(tempDir, 'rollback.settings.json'); @@ -444,8 +477,9 @@ describe('openai proxy daemon lifecycle', () => { }); it('returns to the adaptive canonical port after a stale fallback session', async () => { - const stalePort = await getPort(); - const settingsPath = path.join(tempDir, 'outside-range.settings.json'); + const profileName = await findProfileNameWithFreeAdaptivePort('outside-range'); + const stalePort = await getPortOutsideOpenAICompatAdaptiveRange(); + const settingsPath = path.join(tempDir, `${profileName}.settings.json`); fs.writeFileSync( settingsPath, JSON.stringify({ @@ -459,7 +493,7 @@ describe('openai proxy daemon lifecycle', () => { 'utf8' ); - const profile = resolveOpenAICompatProfileConfig('outside-range', settingsPath, { + const profile = resolveOpenAICompatProfileConfig(profileName, settingsPath, { ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', ANTHROPIC_AUTH_TOKEN: 'ollama-outside-range', ANTHROPIC_MODEL: 'qwen3-coder', @@ -469,9 +503,9 @@ describe('openai proxy daemon lifecycle', () => { throw new Error('Expected an outside-range OpenAI-compatible profile'); } - fs.mkdirSync(path.dirname(getOpenAICompatProxySessionPath('outside-range')), { recursive: true }); + fs.mkdirSync(path.dirname(getOpenAICompatProxySessionPath(profileName)), { recursive: true }); fs.writeFileSync( - getOpenAICompatProxySessionPath('outside-range'), + getOpenAICompatProxySessionPath(profileName), JSON.stringify( { profileName: profile.profileName, @@ -490,7 +524,7 @@ describe('openai proxy daemon lifecycle', () => { const started = await startOpenAICompatProxy(profile); expect(started.success).toBe(true); - expect(started.port).toBe(resolveOpenAICompatProxyPreferredPort('outside-range')); + expect(started.port).toBe(resolveOpenAICompatProxyPreferredPort(profileName)); expect(started.port).not.toBe(stalePort); }); diff --git a/tests/integration/proxy/request-routing.test.ts b/tests/integration/proxy/request-routing.test.ts index 080405b2..7e44ae13 100644 --- a/tests/integration/proxy/request-routing.test.ts +++ b/tests/integration/proxy/request-routing.test.ts @@ -299,7 +299,58 @@ describe('openai proxy request routing', () => { expect(bodies[0]?.body).toMatchObject({ model: 'deepseek-reasoner', reasoning_effort: 'high', - reasoning: { enabled: true, effort: 'high' }, }); + expect((bodies[0]?.body as { reasoning?: unknown } | undefined)?.reasoning).toBeUndefined(); + }); + + it('forwards adaptive thinking to openai-profile upstreams via reasoning_effort only', async () => { + const hits: string[] = []; + const bodies: Array<{ label: string; body: unknown }> = []; + const upstreamPort = await startMockUpstream('openai', hits, bodies); + + const settingsPath = writeSettings('openai', { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'openai_token', + ANTHROPIC_MODEL: 'gpt-4.1', + }); + + fs.writeFileSync( + path.join(tempDir, '.ccs', 'config.json'), + JSON.stringify({ profiles: { openai: settingsPath } }, null, 2), + 'utf8' + ); + + const profile: OpenAICompatProfileConfig = { + profileName: 'openai', + settingsPath, + baseUrl: `http://127.0.0.1:${upstreamPort}`, + apiKey: 'openai_token', + provider: 'openai', + model: 'gpt-4.1', + }; + proxyServer = startOpenAICompatProxyServer({ + profile, + port: 0, + authToken: 'test-proxy-token', + }); + proxyPort = await waitForServerListening(proxyServer); + + const response = await requestProxy({ + model: 'gpt-4.1', + thinking: { type: 'adaptive' }, + output_config: { effort: 'max' }, + messages: [{ role: 'user', content: 'think adaptively' }], + }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + content: [{ type: 'text', text: 'Reply from openai' }], + }); + expect(hits).toEqual(['openai']); + expect(bodies[0]?.body).toMatchObject({ + model: 'gpt-4.1', + reasoning_effort: 'high', + }); + expect((bodies[0]?.body as { reasoning?: unknown } | undefined)?.reasoning).toBeUndefined(); }); }); diff --git a/tests/unit/bin/ccsxp-runtime.test.ts b/tests/unit/bin/ccsxp-runtime.test.ts index ee4d0f40..93c1e19d 100644 --- a/tests/unit/bin/ccsxp-runtime.test.ts +++ b/tests/unit/bin/ccsxp-runtime.test.ts @@ -1,4 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as os from 'os'; +import * as path from 'path'; const wrapperPath = require.resolve('../../../src/bin/ccsxp-runtime.ts'); const ccsPath = require.resolve('../../../src/ccs.ts'); @@ -6,6 +8,8 @@ const ccsPath = require.resolve('../../../src/ccs.ts'); describe('ccsxp runtime wrapper', () => { const originalArgv = process.argv; const originalEntryTarget = process.env.CCS_INTERNAL_ENTRY_TARGET; + const originalCodexHome = process.env.CODEX_HOME; + const originalCcsxpCodexHome = process.env.CCSXP_CODEX_HOME; beforeEach(() => { delete require.cache[wrapperPath]; @@ -20,28 +24,64 @@ describe('ccsxp runtime wrapper', () => { } else { process.env.CCS_INTERNAL_ENTRY_TARGET = originalEntryTarget; } + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = originalCodexHome; + } + if (originalCcsxpCodexHome === undefined) { + delete process.env.CCSXP_CODEX_HOME; + } else { + process.env.CCSXP_CODEX_HOME = originalCcsxpCodexHome; + } delete require.cache[wrapperPath]; delete require.cache[ccsPath]; }); - it('prepends the built-in codex profile and target before loading CCS', () => { + it('prepends the cliproxy provider override before loading CCS', () => { process.argv = ['node', wrapperPath, 'fix failing tests']; require.cache[ccsPath] = { exports: {} } as NodeJS.Module; require(wrapperPath); expect(process.env.CCS_INTERNAL_ENTRY_TARGET).toBe('codex'); - expect(process.argv.slice(2)).toEqual(['codex', '--target', 'codex', 'fix failing tests']); + expect(process.env.CODEX_HOME).toBe(path.join(os.homedir(), '.codex')); + expect(process.argv.slice(2)).toEqual([ + '--config', + 'model_provider="cliproxy"', + 'fix failing tests', + ]); }); - it('keeps flag-only invocations routed through the built-in codex profile shortcut', () => { + it('pins ccsxp history to native Codex default instead of inherited CODEX_HOME', () => { + process.env.CODEX_HOME = '/tmp/inherited-managed-codex-home'; process.argv = ['node', wrapperPath, '--version']; require.cache[ccsPath] = { exports: {} } as NodeJS.Module; require(wrapperPath); - expect(process.argv.slice(2)).toEqual(['codex', '--target', 'codex', '--version']); + expect(process.env.CODEX_HOME).toBe(path.join(os.homedir(), '.codex')); + }); + + it('allows an explicit ccsxp Codex home override', () => { + process.env.CODEX_HOME = '/tmp/inherited-managed-codex-home'; + process.env.CCSXP_CODEX_HOME = '/tmp/explicit-ccsxp-codex-home'; + process.argv = ['node', wrapperPath, '--version']; + require.cache[ccsPath] = { exports: {} } as NodeJS.Module; + + require(wrapperPath); + + expect(process.env.CODEX_HOME).toBe('/tmp/explicit-ccsxp-codex-home'); + }); + + it('keeps flag-only invocations routed through the native cliproxy shortcut', () => { + process.argv = ['node', wrapperPath, '--version']; + require.cache[ccsPath] = { exports: {} } as NodeJS.Module; + + require(wrapperPath); + + expect(process.argv.slice(2)).toEqual(['--config', 'model_provider="cliproxy"', '--version']); }); it('strips user-supplied target overrides before forcing the codex shortcut target', () => { @@ -50,6 +90,6 @@ describe('ccsxp runtime wrapper', () => { require(wrapperPath); - expect(process.argv.slice(2)).toEqual(['codex', '--target', 'codex', '--version']); + expect(process.argv.slice(2)).toEqual(['--config', 'model_provider="cliproxy"', '--version']); }); }); diff --git a/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts b/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts index e688e009..a0ce2238 100644 --- a/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts +++ b/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts @@ -86,6 +86,36 @@ function writeClaudeAuth(accountId: string, accessToken: string): void { ); } +function writeCodexAuth(tokenFile: string, accountId: string, accessToken: string): void { + writeAuthToken(tokenFile, { + access_token: accessToken, + account_id: `chatgpt-${accountId}`, + expired: '2099-01-01T00:00:00.000Z', + type: 'codex', + email: accountId, + }); +} + +function writeGhcpAuth(tokenFile: string, accessToken: string): void { + writeAuthToken(tokenFile, { + access_token: accessToken, + type: 'ghcp', + }); +} + +function writeGeminiAuth(tokenFile: string, accountId: string, accessToken: string): void { + writeAuthToken(tokenFile, { + type: 'gemini', + email: accountId, + project_id: 'cloudaicompanion-test-123', + token: { + access_token: accessToken, + refresh_token: `${accessToken}-refresh`, + expiry: Date.now() + 60 * 60 * 1000, + }, + }); +} + function writeAuthToken(tokenFile: string, payload: Record): void { const authDir = path.join(tmpDir, '.ccs', 'cliproxy', 'auth'); fs.mkdirSync(authDir, { recursive: true }); @@ -264,7 +294,7 @@ describe('Quota Exhaustion Handlers', () => { expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(false); }); - it('should switch Claude accounts when fallback quota endpoint returns 404', async () => { + it('should switch Claude accounts without durable pause when fallback quota is unknown', async () => { writeRegistry({ claude: { default: 'exhausted@example.com', @@ -312,11 +342,27 @@ describe('Quota Exhaustion Handlers', () => { }) as typeof fetch; const result = await handleQuotaExhaustion('claude', 'exhausted@example.com', 10); - const { getDefaultAccount } = await import('../../../src/cliproxy/account-manager'); + const { getAccount, getDefaultAccount } = await import( + '../../../src/cliproxy/account-manager' + ); expect(result.switchedTo).toBe('fallback@example.com'); expect(getDefaultAccount('claude')?.id).toBe('fallback@example.com'); - expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(true); + expect(getAccount('claude', 'exhausted@example.com')?.paused).not.toBe(true); + expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe( + false + ); + expect( + fs.existsSync( + path.join( + tmpDir, + '.ccs', + 'cliproxy', + 'auth', + `claude-${sanitizeEmail('exhausted@example.com')}.json` + ) + ) + ).toBe(true); expect( fs.existsSync( path.join( @@ -327,6 +373,512 @@ describe('Quota Exhaustion Handlers', () => { `claude-${sanitizeEmail('exhausted@example.com')}.json` ) ) + ).toBe(false); + }); + + it('should self-pause exhausted Codex accounts when a healthy fallback exists', async () => { + writeRegistry({ + codex: { + default: 'exhausted@example.com', + accounts: { + 'exhausted@example.com': { + email: 'exhausted@example.com', + tokenFile: 'codex-exhausted.json', + }, + 'fallback@example.com': { + email: 'fallback@example.com', + tokenFile: 'codex-fallback.json', + }, + }, + }, + }); + + writeConfig({ + mode: 'auto', + auto: { + tier_priority: ['ultra', 'pro', 'free'], + exhaustion_threshold: 5, + cooldown_minutes: 10, + preflight_check: true, + }, + runtime_monitor: { + enabled: true, + normal_interval_seconds: 300, + critical_interval_seconds: 60, + warn_threshold: 20, + exhaustion_threshold: 5, + cooldown_minutes: 10, + }, + }); + + writeCodexAuth('codex-exhausted.json', 'exhausted@example.com', 'exhausted-token'); + writeCodexAuth('codex-fallback.json', 'fallback@example.com', 'fallback-token'); + + global.fetch = mock((_url: string, options?: RequestInit) => { + const accountHeader = new Headers(options?.headers).get('ChatGPT-Account-Id') ?? ''; + const usedPercent = accountHeader === 'chatgpt-fallback@example.com' ? 10 : 100; + return Promise.resolve( + Response.json({ + plan_type: 'pro', + rate_limit: { + primary_window: { used_percent: usedPercent, reset_after_seconds: 3600 }, + secondary_window: { used_percent: usedPercent, reset_after_seconds: 604800 }, + }, + }) + ); + }) as typeof fetch; + + const result = await handleQuotaExhaustion('codex', 'exhausted@example.com', 10); + const { getAccount, getDefaultAccount } = await import( + '../../../src/cliproxy/account-manager' + ); + + expect(result.switchedTo).toBe('fallback@example.com'); + expect(getDefaultAccount('codex')?.id).toBe('fallback@example.com'); + expect(getAccount('codex', 'exhausted@example.com')?.paused).toBe(true); + expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(true); + expect( + fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'auth-paused', 'codex-exhausted.json')) + ).toBe(true); + expect( + fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'auth', 'codex-exhausted.json')) + ).toBe(false); + }); + + it('should not durably self-pause the only exhausted Codex account during preflight', async () => { + writeRegistry({ + codex: { + default: 'only-codex@example.com', + accounts: { + 'only-codex@example.com': { + email: 'only-codex@example.com', + tokenFile: 'codex-only.json', + }, + }, + }, + }); + + writeConfig({ + mode: 'auto', + auto: { + tier_priority: ['ultra', 'pro', 'free'], + exhaustion_threshold: 5, + cooldown_minutes: 10, + preflight_check: true, + }, + runtime_monitor: { + enabled: true, + normal_interval_seconds: 300, + critical_interval_seconds: 60, + warn_threshold: 20, + exhaustion_threshold: 5, + cooldown_minutes: 10, + }, + }); + + writeCodexAuth('codex-only.json', 'only-codex@example.com', 'only-token'); + + global.fetch = mock(() => + Promise.resolve( + Response.json({ + plan_type: 'pro', + rate_limit: { + primary_window: { used_percent: 100, reset_after_seconds: 3600 }, + secondary_window: { used_percent: 100, reset_after_seconds: 604800 }, + }, + }) + ) + ) as typeof fetch; + + const { preflightCheck } = await import('../../../src/cliproxy/quota-manager'); + const result = await preflightCheck('codex'); + const { getAccount } = await import('../../../src/cliproxy/account-manager'); + + expect(result.accountId).toBe('only-codex@example.com'); + expect(result.switchedFrom).toBeUndefined(); + expect(result.reason).toContain('no alternatives available'); + expect(getAccount('codex', 'only-codex@example.com')?.paused).not.toBe(true); + expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'auth', 'codex-only.json'))).toBe( + true + ); + expect( + fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'auth-paused', 'codex-only.json')) + ).toBe(false); + expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(false); + }); + + it('should self-pause an exhausted Codex default during preflight when fallback exists', async () => { + writeRegistry({ + codex: { + default: 'preflight-exhausted@example.com', + accounts: { + 'preflight-exhausted@example.com': { + email: 'preflight-exhausted@example.com', + tokenFile: 'codex-preflight-exhausted.json', + }, + 'preflight-fallback@example.com': { + email: 'preflight-fallback@example.com', + tokenFile: 'codex-preflight-fallback.json', + }, + }, + }, + }); + + writeConfig({ + mode: 'auto', + auto: { + tier_priority: ['ultra', 'pro', 'free'], + exhaustion_threshold: 5, + cooldown_minutes: 10, + preflight_check: true, + }, + runtime_monitor: { + enabled: true, + normal_interval_seconds: 300, + critical_interval_seconds: 60, + warn_threshold: 20, + exhaustion_threshold: 5, + cooldown_minutes: 10, + }, + }); + + writeCodexAuth( + 'codex-preflight-exhausted.json', + 'preflight-exhausted@example.com', + 'preflight-exhausted-token' + ); + writeCodexAuth( + 'codex-preflight-fallback.json', + 'preflight-fallback@example.com', + 'preflight-fallback-token' + ); + + global.fetch = mock((_url: string, options?: RequestInit) => { + const accountHeader = new Headers(options?.headers).get('ChatGPT-Account-Id') ?? ''; + const usedPercent = accountHeader === 'chatgpt-preflight-fallback@example.com' ? 10 : 100; + return Promise.resolve( + Response.json({ + plan_type: 'pro', + rate_limit: { + primary_window: { used_percent: usedPercent, reset_after_seconds: 3600 }, + secondary_window: { used_percent: usedPercent, reset_after_seconds: 604800 }, + }, + }) + ); + }) as typeof fetch; + + const { preflightCheck } = await import('../../../src/cliproxy/quota-manager'); + const result = await preflightCheck('codex'); + const { getAccount, getDefaultAccount } = await import( + '../../../src/cliproxy/account-manager' + ); + + expect(result.switchedFrom).toBe('preflight-exhausted@example.com'); + expect(result.accountId).toBe('preflight-fallback@example.com'); + expect(getDefaultAccount('codex')?.id).toBe('preflight-fallback@example.com'); + expect(getAccount('codex', 'preflight-exhausted@example.com')?.paused).toBe(true); + expect( + fs.existsSync( + path.join(tmpDir, '.ccs', 'cliproxy', 'auth-paused', 'codex-preflight-exhausted.json') + ) + ).toBe(true); + }); + + it('should not durably self-pause during preflight when fallback quota is unknown', async () => { + writeRegistry({ + codex: { + default: 'preflight-unknown-exhausted@example.com', + accounts: { + 'preflight-unknown-exhausted@example.com': { + email: 'preflight-unknown-exhausted@example.com', + tokenFile: 'codex-preflight-unknown-exhausted.json', + }, + 'preflight-unknown-fallback@example.com': { + email: 'preflight-unknown-fallback@example.com', + tokenFile: 'codex-preflight-unknown-fallback.json', + }, + }, + }, + }); + + writeConfig({ + mode: 'auto', + auto: { + tier_priority: ['ultra', 'pro', 'free'], + exhaustion_threshold: 5, + cooldown_minutes: 10, + preflight_check: true, + }, + runtime_monitor: { + enabled: true, + normal_interval_seconds: 300, + critical_interval_seconds: 60, + warn_threshold: 20, + exhaustion_threshold: 5, + cooldown_minutes: 10, + }, + }); + + writeCodexAuth( + 'codex-preflight-unknown-exhausted.json', + 'preflight-unknown-exhausted@example.com', + 'preflight-unknown-exhausted-token' + ); + writeCodexAuth( + 'codex-preflight-unknown-fallback.json', + 'preflight-unknown-fallback@example.com', + 'preflight-unknown-fallback-token' + ); + + global.fetch = mock((_url: string, options?: RequestInit) => { + const accountHeader = new Headers(options?.headers).get('ChatGPT-Account-Id') ?? ''; + if (accountHeader === 'chatgpt-preflight-unknown-fallback@example.com') { + return Promise.resolve(new Response('', { status: 500 })); + } + + return Promise.resolve( + Response.json({ + plan_type: 'pro', + rate_limit: { + primary_window: { used_percent: 100, reset_after_seconds: 3600 }, + secondary_window: { used_percent: 100, reset_after_seconds: 604800 }, + }, + }) + ); + }) as typeof fetch; + + const { preflightCheck } = await import('../../../src/cliproxy/quota-manager'); + const result = await preflightCheck('codex'); + const { getAccount, getDefaultAccount } = await import( + '../../../src/cliproxy/account-manager' + ); + + expect(result.switchedFrom).toBe('preflight-unknown-exhausted@example.com'); + expect(result.accountId).toBe('preflight-unknown-fallback@example.com'); + expect(getDefaultAccount('codex')?.id).toBe('preflight-unknown-fallback@example.com'); + expect(getAccount('codex', 'preflight-unknown-exhausted@example.com')?.paused).not.toBe( + true + ); + expect( + fs.existsSync( + path.join(tmpDir, '.ccs', 'cliproxy', 'auth', 'codex-preflight-unknown-exhausted.json') + ) + ).toBe(true); + expect( + fs.existsSync( + path.join( + tmpDir, + '.ccs', + 'cliproxy', + 'auth-paused', + 'codex-preflight-unknown-exhausted.json' + ) + ) + ).toBe(false); + expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe( + false + ); + }); + + it('should self-pause exhausted Gemini accounts when a healthy fallback exists', async () => { + writeRegistry({ + gemini: { + default: 'gemini-exhausted@example.com', + accounts: { + 'gemini-exhausted@example.com': { + email: 'gemini-exhausted@example.com', + tokenFile: 'gemini-exhausted.json', + }, + 'gemini-fallback@example.com': { + email: 'gemini-fallback@example.com', + tokenFile: 'gemini-fallback.json', + }, + }, + }, + }); + + writeConfig({ + mode: 'auto', + auto: { + tier_priority: ['ultra', 'pro', 'free'], + exhaustion_threshold: 5, + cooldown_minutes: 10, + preflight_check: true, + }, + runtime_monitor: { + enabled: true, + normal_interval_seconds: 300, + critical_interval_seconds: 60, + warn_threshold: 20, + exhaustion_threshold: 5, + cooldown_minutes: 10, + }, + }); + + writeGeminiAuth('gemini-exhausted.json', 'gemini-exhausted@example.com', 'exhausted-token'); + writeGeminiAuth('gemini-fallback.json', 'gemini-fallback@example.com', 'fallback-token'); + + global.fetch = mock((_url: string, options?: RequestInit) => { + const authHeader = new Headers(options?.headers).get('Authorization') ?? ''; + const remainingFraction = authHeader === 'Bearer fallback-token' ? 0.9 : 0; + return Promise.resolve( + Response.json({ + buckets: [ + { + model_id: 'gemini-3-flash-preview', + remaining_fraction: remainingFraction, + remaining_amount: Math.round(remainingFraction * 100), + reset_time: '2026-05-01T00:00:00Z', + }, + ], + }) + ); + }) as typeof fetch; + + const result = await handleQuotaExhaustion('gemini', 'gemini-exhausted@example.com', 10); + const { getAccount, getDefaultAccount } = await import( + '../../../src/cliproxy/account-manager' + ); + + expect(result.switchedTo).toBe('gemini-fallback@example.com'); + expect(getDefaultAccount('gemini')?.id).toBe('gemini-fallback@example.com'); + expect(getAccount('gemini', 'gemini-exhausted@example.com')?.paused).toBe(true); + expect( + fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'auth-paused', 'gemini-exhausted.json')) + ).toBe(true); + expect( + fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'auth', 'gemini-exhausted.json')) + ).toBe(false); + }); + + it('should self-pause exhausted GitHub Copilot accounts when a healthy fallback exists', async () => { + writeRegistry({ + ghcp: { + default: 'ghcp-exhausted', + accounts: { + 'ghcp-exhausted': { + tokenFile: 'ghcp-exhausted.json', + }, + 'ghcp-fallback': { + tokenFile: 'ghcp-fallback.json', + }, + }, + }, + }); + + writeConfig({ + mode: 'auto', + auto: { + tier_priority: ['ultra', 'pro', 'free'], + exhaustion_threshold: 5, + cooldown_minutes: 10, + preflight_check: true, + }, + runtime_monitor: { + enabled: true, + normal_interval_seconds: 300, + critical_interval_seconds: 60, + warn_threshold: 20, + exhaustion_threshold: 5, + cooldown_minutes: 10, + }, + }); + + writeGhcpAuth('ghcp-exhausted.json', 'exhausted-token'); + writeGhcpAuth('ghcp-fallback.json', 'fallback-token'); + + global.fetch = mock((_url: string, options?: RequestInit) => { + const authHeader = new Headers(options?.headers).get('Authorization') ?? ''; + const remaining = authHeader === 'token fallback-token' ? 90 : 0; + return Promise.resolve( + Response.json({ + copilot_plan: 'individual', + quota_reset_date: '2026-05-01T00:00:00Z', + quota_snapshots: { + premium_interactions: { entitlement: 100, remaining }, + chat: { entitlement: 100, remaining }, + completions: { entitlement: 100, remaining }, + }, + }) + ); + }) as typeof fetch; + + const result = await handleQuotaExhaustion('ghcp', 'ghcp-exhausted', 10); + const { getAccount, getDefaultAccount } = await import( + '../../../src/cliproxy/account-manager' + ); + + expect(result.switchedTo).toBe('ghcp-fallback'); + expect(getDefaultAccount('ghcp')?.id).toBe('ghcp-fallback'); + expect(getAccount('ghcp', 'ghcp-exhausted')?.paused).toBe(true); + expect( + fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'auth-paused', 'ghcp-exhausted.json')) + ).toBe(true); + }); + + it('should ignore omitted GitHub Copilot snapshots when selecting a fallback', async () => { + writeRegistry({ + ghcp: { + default: 'ghcp-partial-exhausted', + accounts: { + 'ghcp-partial-exhausted': { + tokenFile: 'ghcp-partial-exhausted.json', + }, + 'ghcp-partial-fallback': { + tokenFile: 'ghcp-partial-fallback.json', + }, + }, + }, + }); + + writeConfig({ + mode: 'auto', + auto: { + tier_priority: ['ultra', 'pro', 'free'], + exhaustion_threshold: 5, + cooldown_minutes: 10, + preflight_check: true, + }, + runtime_monitor: { + enabled: true, + normal_interval_seconds: 300, + critical_interval_seconds: 60, + warn_threshold: 20, + exhaustion_threshold: 5, + cooldown_minutes: 10, + }, + }); + + writeGhcpAuth('ghcp-partial-exhausted.json', 'partial-exhausted-token'); + writeGhcpAuth('ghcp-partial-fallback.json', 'partial-fallback-token'); + + global.fetch = mock((_url: string, options?: RequestInit) => { + const authHeader = new Headers(options?.headers).get('Authorization') ?? ''; + const remaining = authHeader === 'token partial-fallback-token' ? 90 : 0; + return Promise.resolve( + Response.json({ + copilot_plan: 'individual', + quota_reset_date: '2026-05-01T00:00:00Z', + quota_snapshots: { + premium_interactions: { entitlement: 100, remaining }, + chat: { entitlement: 100, remaining }, + }, + }) + ); + }) as typeof fetch; + + const result = await handleQuotaExhaustion('ghcp', 'ghcp-partial-exhausted', 10); + const { getAccount, getDefaultAccount } = await import( + '../../../src/cliproxy/account-manager' + ); + + expect(result.switchedTo).toBe('ghcp-partial-fallback'); + expect(getDefaultAccount('ghcp')?.id).toBe('ghcp-partial-fallback'); + expect(getAccount('ghcp', 'ghcp-partial-exhausted')?.paused).toBe(true); + expect( + fs.existsSync( + path.join(tmpDir, '.ccs', 'cliproxy', 'auth-paused', 'ghcp-partial-exhausted.json') + ) ).toBe(true); }); diff --git a/tests/unit/cliproxy/ai-provider-service-stable-id.test.ts b/tests/unit/cliproxy/ai-provider-service-stable-id.test.ts index a4de2cf6..f317a8e1 100644 --- a/tests/unit/cliproxy/ai-provider-service-stable-id.test.ts +++ b/tests/unit/cliproxy/ai-provider-service-stable-id.test.ts @@ -144,4 +144,24 @@ describe('ai-provider service stable ids', () => { 'sk-openrouter' ); }); + + it('normalizes plain openai-compatible model rules without aliases', async () => { + const { listAiProviders } = await loadAiProviderService(); + + writeCliproxyConfig(tempHome, { + 'openai-compatibility': [ + { + name: 'openrouter', + 'base-url': 'https://openrouter.ai/api/v1', + 'api-key-entries': [{ 'api-key': 'sk-openrouter' }], + models: [{ name: 'gpt-4o-mini' }, null, { name: 42 }], + }, + ], + }); + + const listed = await listAiProviders(); + const family = listed.families.find((entry) => entry.id === 'openai-compatibility'); + + expect(family?.entries[0]?.models).toEqual([{ name: 'gpt-4o-mini', alias: '' }]); + }); }); diff --git a/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts index 1f004389..97ae8940 100644 --- a/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts +++ b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts @@ -45,6 +45,48 @@ async function importCompatibilityModule(cacheTag: string) { const identity = (message: string) => message; describe('codex plan compatibility reconcile', () => { + it('warns about the GPT-5.5 free-plan fallback without rewriting saved settings', async () => { + const { tmpDir, settingsPath } = createCodexSettingsFixture(); + const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); + + try { + const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('gpt-5-5-free'); + + await reconcileCodexModelForActivePlan( + { + currentModel: 'gpt-5.5', + verbose: false, + }, + { + getDefaultAccount: () => ({ id: 'free@example.com' }) as never, + fetchCodexQuota: async () => ({ + success: true, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + planType: 'free', + lastUpdated: Date.now(), + accountId: 'free@example.com', + }), + formatInfo: identity, + formatWarn: identity, + } + ); + + const repaired = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + expect(repaired.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); + expect(repaired.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); + expect(repaired.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); + expect(repaired.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); + expect(errorSpy).toHaveBeenCalledWith( + 'Codex free plan detected. Keeping saved model "gpt-5.5" in settings; runtime requests will fall back to "gpt-5.4" when needed.' + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it('keeps saved Codex settings intact and warns about runtime fallback for free-plan accounts', async () => { const { tmpDir, settingsPath } = createCodexSettingsFixture('gpt-5.3-codex-spark'); const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); diff --git a/tests/unit/cliproxy/codex-plan-compatibility.test.ts b/tests/unit/cliproxy/codex-plan-compatibility.test.ts index 2fc1266a..41f52c68 100644 --- a/tests/unit/cliproxy/codex-plan-compatibility.test.ts +++ b/tests/unit/cliproxy/codex-plan-compatibility.test.ts @@ -14,6 +14,8 @@ describe('codex plan compatibility', () => { }); it('maps paid-only free-plan models to safe fallbacks', () => { + expect(getFreePlanFallbackCodexModel('gpt-5.5')).toBe('gpt-5.4'); + expect(getFreePlanFallbackCodexModel('gpt-5.5-xhigh')).toBe('gpt-5.4'); expect(getFreePlanFallbackCodexModel('gpt-5.3-codex')).toBe('gpt-5.4'); expect(getFreePlanFallbackCodexModel('gpt-5.3-codex-xhigh')).toBe('gpt-5.4'); expect(getFreePlanFallbackCodexModel('gpt-5.3-codex(high)')).toBe('gpt-5.4'); @@ -71,6 +73,7 @@ describe('codex plan compatibility', () => { }); it('tracks Codex thinking caps for current safe defaults, paid models, and legacy aliases', () => { + expect(getModelMaxLevel('codex', 'gpt-5.5')).toBe('xhigh'); expect(getModelMaxLevel('codex', 'gpt-5.4')).toBe('xhigh'); expect(getModelMaxLevel('codex', 'gpt-5.4-mini')).toBe('high'); expect(getModelMaxLevel('codex', 'gpt-5-codex')).toBe('xhigh'); diff --git a/tests/unit/cliproxy/management-api-client.test.ts b/tests/unit/cliproxy/management-api-client.test.ts index 12641a25..df498534 100644 --- a/tests/unit/cliproxy/management-api-client.test.ts +++ b/tests/unit/cliproxy/management-api-client.test.ts @@ -128,7 +128,7 @@ describe('management-api-client', () => { expect(strategy).toBe('round-robin'); expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:8317/routing/strategy', + 'http://localhost:8317/v0/management/routing/strategy', expect.objectContaining({ method: 'PUT', body: JSON.stringify({ value: 'round-robin' }), diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js index 704c5764..bc11ce90 100644 --- a/tests/unit/cliproxy/model-catalog.test.js +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -209,6 +209,7 @@ describe('Model Catalog', () => { const { MODEL_CATALOG } = modelCatalog; const ids = MODEL_CATALOG.codex.models.map((m) => m.id); assert.deepStrictEqual(ids, [ + 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex', diff --git a/tests/unit/cliproxy/profile-mapper.test.ts b/tests/unit/cliproxy/profile-mapper.test.ts index c95f2c71..92f688ff 100644 --- a/tests/unit/cliproxy/profile-mapper.test.ts +++ b/tests/unit/cliproxy/profile-mapper.test.ts @@ -74,6 +74,48 @@ describe('Profile Mapper', () => { assert.ok(result); assert.strictEqual(result['base-url'], undefined); }); + + it('appends ANTHROPIC_EXTRA_MODELS as additional models, deduped against primary and each other', () => { + const profile = { + name: 'dashscope', + settingsPath: '/path', + isConfigured: true, + env: { + ANTHROPIC_AUTH_TOKEN: 'sk-test', + ANTHROPIC_BASE_URL: 'https://api.example.com', + ANTHROPIC_MODEL: 'qwen3-coder-plus', + // Includes whitespace, empty entries, a duplicate of the primary + // model, and a repeated extra entry — all should be cleaned up. + ANTHROPIC_EXTRA_MODELS: ' glm-4.6 , kimi-k2 , ,qwen3-coder-plus, kimi-k2 ', + }, + }; + const result = profileMapper.mapProfileToClaudeKey(profile); + + assert.ok(result, 'Should return ClaudeKey'); + assert.deepStrictEqual(result.models, [ + { name: 'qwen3-coder-plus', alias: '' }, + { name: 'glm-4.6', alias: '' }, + { name: 'kimi-k2', alias: '' }, + ]); + }); + + it('ignores empty ANTHROPIC_EXTRA_MODELS', () => { + const profile = { + name: 'test', + settingsPath: '/path', + isConfigured: true, + env: { + ANTHROPIC_AUTH_TOKEN: 'sk-test', + ANTHROPIC_MODEL: 'gpt-4', + ANTHROPIC_EXTRA_MODELS: ' ', + }, + }; + const result = profileMapper.mapProfileToClaudeKey(profile); + + assert.ok(result); + assert.strictEqual(result.models.length, 1); + assert.strictEqual(result.models[0].name, 'gpt-4'); + }); }); describe('loadSyncableProfiles', () => { diff --git a/tests/unit/cliproxy/quota-fetcher-claude.test.ts b/tests/unit/cliproxy/quota-fetcher-claude.test.ts index c1bcc4ae..cdfbddaf 100644 --- a/tests/unit/cliproxy/quota-fetcher-claude.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-claude.test.ts @@ -196,6 +196,17 @@ describe('Claude Quota Fetcher', () => { expect(windows[0].label).toBe('Seven Day Haiku'); expect(windows[0].remainingPercent).toBe(84); }); + + it('treats OAuth usage utilization as percent (regression for issue: Sonnet weekly shown as 0% when 1% used)', () => { + const windows = buildClaudeQuotaWindows({ + five_hour: { utilization: 34.0, resets_at: '2026-04-27T06:50:01Z' }, + seven_day: { utilization: 8.0, resets_at: '2026-04-27T18:00:00Z' }, + seven_day_sonnet: { utilization: 1.0, resets_at: '2026-04-27T18:00:00Z' }, + }); + const sonnet = windows.find((w) => w.rateLimitType === 'seven_day_sonnet'); + expect(sonnet?.usedPercent).toBe(1); + expect(sonnet?.remainingPercent).toBe(99); + }); }); describe('buildClaudeCoreUsageSummary', () => { diff --git a/tests/unit/cliproxy/quota-fetcher-codex.test.ts b/tests/unit/cliproxy/quota-fetcher-codex.test.ts index af6bc479..4bf3e689 100644 --- a/tests/unit/cliproxy/quota-fetcher-codex.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-codex.test.ts @@ -246,6 +246,122 @@ describe('Codex Quota Fetcher', () => { expect(windows[0].usedPercent).toBe(0); expect(windows[0].remainingPercent).toBe(100); }); + + it('should attach category and cadence metadata to standard usage windows', () => { + const response = { + rate_limit: { + primary_window: { used_percent: 5, reset_after_seconds: 18000 }, + secondary_window: { used_percent: 25, reset_after_seconds: 604800 }, + }, + }; + + const windows = buildCodexQuotaWindows(response); + + expect(windows).toHaveLength(2); + expect(windows[0].category).toBe('usage'); + expect(windows[0].cadence).toBe('5h'); + expect(windows[0].featureLabel).toBeUndefined(); + expect(windows[1].category).toBe('usage'); + expect(windows[1].cadence).toBe('weekly'); + }); + + it('should mark code review windows with code-review category and Code Review feature label', () => { + const response = { + code_review_rate_limit: { + primary_window: { used_percent: 12, reset_after_seconds: 1800 }, + secondary_window: { used_percent: 60, reset_after_seconds: 604800 }, + }, + }; + + const windows = buildCodexQuotaWindows(response); + + expect(windows).toHaveLength(2); + expect(windows[0].category).toBe('code-review'); + expect(windows[0].cadence).toBe('5h'); + expect(windows[0].featureLabel).toBe('Code Review'); + expect(windows[1].category).toBe('code-review'); + expect(windows[1].cadence).toBe('weekly'); + expect(windows[1].featureLabel).toBe('Code Review'); + }); + + it('should parse additional_rate_limits entries (e.g. GPT-5.3 Codex Spark)', () => { + const response = { + rate_limit: { + primary_window: { used_percent: 0, reset_after_seconds: 18000 }, + secondary_window: { used_percent: 1, reset_after_seconds: 254493 }, + }, + code_review_rate_limit: null, + additional_rate_limits: [ + { + limit_name: 'GPT-5.3-Codex-Spark', + metered_feature: 'codex_bengalfox', + rate_limit: { + primary_window: { used_percent: 0, reset_after_seconds: 18000 }, + secondary_window: { used_percent: 1, reset_after_seconds: 254493 }, + }, + }, + ], + }; + + const windows = buildCodexQuotaWindows(response); + + // 2 standard usage windows + 2 additional windows. + expect(windows).toHaveLength(4); + + const additionalWindows = windows.filter((w) => w.category === 'additional'); + expect(additionalWindows).toHaveLength(2); + + const sparkPrimary = additionalWindows.find((w) => w.cadence === '5h'); + const sparkSecondary = additionalWindows.find((w) => w.cadence === 'weekly'); + + expect(sparkPrimary).toBeDefined(); + expect(sparkPrimary?.featureLabel).toBe('GPT-5.3-Codex-Spark'); + expect(sparkPrimary?.label).toBe('GPT-5.3-Codex-Spark (Primary)'); + expect(sparkPrimary?.usedPercent).toBe(0); + expect(sparkPrimary?.remainingPercent).toBe(100); + + expect(sparkSecondary).toBeDefined(); + expect(sparkSecondary?.featureLabel).toBe('GPT-5.3-Codex-Spark'); + expect(sparkSecondary?.label).toBe('GPT-5.3-Codex-Spark (Secondary)'); + expect(sparkSecondary?.usedPercent).toBe(1); + expect(sparkSecondary?.remainingPercent).toBe(99); + }); + + it('should handle additional_rate_limits set to null without breaking', () => { + const response = { + rate_limit: { + primary_window: { used_percent: 10, reset_after_seconds: 3600 }, + }, + additional_rate_limits: null, + }; + + const windows = buildCodexQuotaWindows(response); + + expect(windows).toHaveLength(1); + expect(windows[0].category).toBe('usage'); + expect(windows.find((w) => w.category === 'additional')).toBeUndefined(); + }); + + it('should accept camelCase additionalRateLimits and rateLimit fields', () => { + const response = { + additionalRateLimits: [ + { + limitName: 'Custom-Feature', + rateLimit: { + primaryWindow: { usedPercent: 50, resetAfterSeconds: 3600 }, + }, + }, + ], + }; + + const windows = buildCodexQuotaWindows(response); + + expect(windows).toHaveLength(1); + expect(windows[0].category).toBe('additional'); + expect(windows[0].cadence).toBe('5h'); + expect(windows[0].featureLabel).toBe('Custom-Feature'); + expect(windows[0].usedPercent).toBe(50); + }); }); describe('buildCodexCoreUsageSummary', () => { @@ -311,6 +427,36 @@ describe('Codex Quota Fetcher', () => { expect(summary.fiveHour).toBeNull(); expect(summary.weekly).toBeNull(); }); + + it('excludes additional and code-review windows from the core usage summary', () => { + const windows = buildCodexQuotaWindows({ + rate_limit: { + primary_window: { used_percent: 35, reset_after_seconds: 18000 }, + secondary_window: { used_percent: 60, reset_after_seconds: 604800 }, + }, + code_review_rate_limit: { + primary_window: { used_percent: 70, reset_after_seconds: 1800 }, + }, + additional_rate_limits: [ + { + limit_name: 'GPT-5.3-Codex-Spark', + rate_limit: { + primary_window: { used_percent: 90, reset_after_seconds: 100 }, + secondary_window: { used_percent: 95, reset_after_seconds: 7200 }, + }, + }, + ], + }); + + const summary = buildCodexCoreUsageSummary(windows); + + // Should pick the 'usage' windows, NOT the Spark windows even though they + // have shorter reset cadences. + expect(summary.fiveHour?.label).toBe('Primary'); + expect(summary.fiveHour?.resetAfterSeconds).toBe(18000); + expect(summary.weekly?.label).toBe('Secondary'); + expect(summary.weekly?.resetAfterSeconds).toBe(604800); + }); }); describe('getUnknownCodexWindowLabels', () => { diff --git a/tests/unit/cliproxy/quota-fetcher-ghcp.test.ts b/tests/unit/cliproxy/quota-fetcher-ghcp.test.ts index 963c6f11..37d19010 100644 --- a/tests/unit/cliproxy/quota-fetcher-ghcp.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-ghcp.test.ts @@ -76,6 +76,7 @@ describe('GHCP Quota Fetcher', () => { const snapshot = normalizeGhcpSnapshot(); expect(snapshot).toEqual({ + reported: false, entitlement: 0, remaining: 0, used: 0, @@ -88,6 +89,50 @@ describe('GHCP Quota Fetcher', () => { }); }); + it('marks upstream-provided snapshots as reported', () => { + const snapshot = normalizeGhcpSnapshot({ + entitlement: 100, + remaining: 80, + }); + + expect(snapshot.reported).toBe(true); + }); + + it('treats unknown upstream snapshot objects as unreported', () => { + const empty = normalizeGhcpSnapshot({}); + const quotaIdOnly = normalizeGhcpSnapshot({ quota_id: 'chat' }); + const remainingOnly = normalizeGhcpSnapshot({ remaining: 80 }); + + for (const snapshot of [empty, quotaIdOnly, remainingOnly]) { + expect(snapshot.reported).toBe(false); + expect(snapshot.percentRemaining).toBe(0); + expect(snapshot.percentUsed).toBe(100); + } + }); + + it('marks percent-only snapshots as reported', () => { + const snapshot = normalizeGhcpSnapshot({ + percent_remaining: 70, + }); + + expect(snapshot.reported).toBe(true); + expect(snapshot.percentRemaining).toBe(70); + expect(snapshot.percentUsed).toBe(30); + }); + + it('treats unlimited snapshots as 100% remaining', () => { + const snapshot = normalizeGhcpSnapshot({ + unlimited: true, + entitlement: 0, + remaining: 0, + }); + + expect(snapshot.reported).toBe(true); + expect(snapshot.percentRemaining).toBe(100); + expect(snapshot.percentUsed).toBe(0); + expect(snapshot.unlimited).toBe(true); + }); + it('clamps percent_remaining to 0-100 range', () => { const above = normalizeGhcpSnapshot({ entitlement: 100, diff --git a/tests/unit/cliproxy/quota-monitor-runtime.test.ts b/tests/unit/cliproxy/quota-monitor-runtime.test.ts index 7afbcc45..3436c6db 100644 --- a/tests/unit/cliproxy/quota-monitor-runtime.test.ts +++ b/tests/unit/cliproxy/quota-monitor-runtime.test.ts @@ -3,7 +3,7 @@ * * Tests the quota monitor lifecycle: * - startQuotaMonitor / stopQuotaMonitor behavior - * - No-op conditions for non-agy, manual mode, disabled config + * - No-op conditions for unsupported providers, manual mode, disabled config * - Idempotent stopQuotaMonitor */ @@ -16,6 +16,7 @@ import { stopQuotaMonitor, clearQuotaCache, } from '../../../src/cliproxy/quota-manager'; +import { __testExports as executorTestExports } from '../../../src/cliproxy/executor'; // Setup test isolation let tmpDir: string; @@ -40,11 +41,43 @@ afterEach(() => { }); describe('Runtime Quota Monitor', () => { + describe('resolveRuntimeQuotaMonitorProviders', () => { + it('should monitor the default provider for non-composite sessions', () => { + expect(executorTestExports.resolveRuntimeQuotaMonitorProviders('codex', [])).toEqual([ + 'codex', + ]); + }); + + it('should monitor every unique managed provider in composite sessions', () => { + expect( + executorTestExports.resolveRuntimeQuotaMonitorProviders('claude', [ + 'codex', + 'qwen', + 'gemini', + 'codex', + ]) + ).toEqual(['codex', 'gemini']); + }); + + it('should skip unsupported non-composite providers', () => { + expect(executorTestExports.resolveRuntimeQuotaMonitorProviders('qwen', [])).toEqual([]); + }); + }); + describe('startQuotaMonitor', () => { - it('should accept non-agy provider without throwing', () => { - // Non-agy providers should be silently ignored + it('should accept all quota-supported providers without throwing', () => { + for (const provider of ['agy', 'claude', 'codex', 'gemini', 'ghcp'] as const) { + expect(() => { + startQuotaMonitor(provider, 'test@gmail.com'); + stopQuotaMonitor(); + }).not.toThrow(); + } + }); + + it('should ignore unsupported providers without throwing', () => { expect(() => { - startQuotaMonitor('gemini', 'test@gmail.com'); + startQuotaMonitor('qwen', 'test@gmail.com'); + stopQuotaMonitor(); }).not.toThrow(); }); @@ -75,6 +108,54 @@ describe('Runtime Quota Monitor', () => { }).not.toThrow(); }); + it('should start independent monitors for distinct managed provider accounts', () => { + const configDir = path.join(tmpDir, '.ccs', 'config'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'unified-config.json'), + JSON.stringify({ + version: 2, + quota_management: { + mode: 'auto', + runtime_monitor: { + enabled: true, + normal_interval_seconds: 300, + critical_interval_seconds: 60, + warn_threshold: 20, + exhaustion_threshold: 5, + cooldown_minutes: 5, + }, + }, + }) + ); + + const originalSetTimeout = globalThis.setTimeout; + const fakeTimer = { + unref: () => fakeTimer, + ref: () => fakeTimer, + hasRef: () => false, + refresh: () => fakeTimer, + } as unknown as ReturnType; + const observedDelays: Array = []; + + globalThis.setTimeout = ((handler: TimerHandler, timeout?: number) => { + observedDelays.push(timeout); + void handler; + return fakeTimer; + }) as typeof globalThis.setTimeout; + + try { + startQuotaMonitor('codex', 'codex@example.com'); + startQuotaMonitor('gemini', 'gemini@example.com'); + startQuotaMonitor('codex', 'codex@example.com'); + } finally { + stopQuotaMonitor(); + globalThis.setTimeout = originalSetTimeout; + } + + expect(observedDelays).toEqual([300_000, 300_000]); + }); + it('should be no-op when config missing or no quota_management', () => { // No config file — should not throw expect(() => { diff --git a/tests/unit/cliproxy/routing-strategy-http.test.ts b/tests/unit/cliproxy/routing-strategy-http.test.ts new file mode 100644 index 00000000..c3edf31d --- /dev/null +++ b/tests/unit/cliproxy/routing-strategy-http.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'bun:test'; +import type { ProxyTarget } from '../../../src/cliproxy/proxy-target-resolver'; + +async function loadRoutingHttpModule() { + return import( + `../../../src/cliproxy/routing-strategy-http?test=${Date.now()}-${Math.random()}` + ) as Promise; +} + +describe('routing-strategy-http', () => { + it('builds the local management URL for routing strategy reads', async () => { + const { getCliproxyRoutingManagementUrl } = await loadRoutingHttpModule(); + const target: ProxyTarget = { + host: '127.0.0.1', + port: 8317, + protocol: 'http', + isRemote: false, + }; + + expect(getCliproxyRoutingManagementUrl(target)).toBe( + 'http://127.0.0.1:8317/v0/management/routing/strategy' + ); + }); + + it('builds the remote management URL for routing strategy writes', async () => { + const { getCliproxyRoutingManagementUrl } = await loadRoutingHttpModule(); + const target: ProxyTarget = { + host: 'proxy.example.com', + port: 443, + protocol: 'https', + allowSelfSigned: true, + isRemote: true, + }; + + expect(getCliproxyRoutingManagementUrl(target)).toBe( + 'https://proxy.example.com:443/v0/management/routing/strategy' + ); + }); +}); diff --git a/tests/unit/cliproxy/routing-strategy.test.ts b/tests/unit/cliproxy/routing-strategy.test.ts index 3a195e14..73f959f4 100644 --- a/tests/unit/cliproxy/routing-strategy.test.ts +++ b/tests/unit/cliproxy/routing-strategy.test.ts @@ -151,4 +151,138 @@ describe('cliproxy routing strategy service', () => { expect(methodCount).toBe(2); }); }); + + it('normalizes session-affinity booleans and TTL values', async () => { + await withScopedConfig(async () => { + const mod = await loadRoutingModule(); + + expect(mod.normalizeCliproxySessionAffinityEnabled(true)).toBe(true); + expect(mod.normalizeCliproxySessionAffinityEnabled('on')).toBe(true); + expect(mod.normalizeCliproxySessionAffinityEnabled('false')).toBe(false); + expect(mod.normalizeCliproxySessionAffinityEnabled('maybe')).toBeNull(); + + expect(mod.normalizeCliproxySessionAffinityTtl('1h')).toBe('1h'); + expect(mod.normalizeCliproxySessionAffinityTtl('2h30m')).toBe('2h30m'); + expect(mod.normalizeCliproxySessionAffinityTtl(' 15m ')).toBe('15m'); + expect(mod.normalizeCliproxySessionAffinityTtl('0s')).toBeNull(); + expect(mod.normalizeCliproxySessionAffinityTtl('tomorrow')).toBeNull(); + }); + }); + + it('reads saved local session-affinity settings when live CLIProxy is unavailable', async () => { + await withScopedConfig(async () => { + const { mutateUnifiedConfig } = await import('../../../src/config/unified-config-loader'); + mutateUnifiedConfig((config) => { + if (config.cliproxy) { + config.cliproxy.routing = { + strategy: 'round-robin', + session_affinity: true, + session_affinity_ttl: '2h30m', + }; + } + }); + + const mod = await loadRoutingModule(); + const state = await mod.readCliproxySessionAffinityState(); + + expect(state.enabled).toBe(true); + expect(state.ttl).toBe('2h30m'); + expect(state.source).toBe('config'); + expect(state.target).toBe('local'); + expect(state.manageable).toBe(true); + expect(state.reachable).toBe(false); + }); + }); + + it('persists local session-affinity settings even when live CLIProxy is unavailable', async () => { + await withScopedConfig(async () => { + const mod = await loadRoutingModule(); + const result = await mod.applyCliproxySessionAffinitySettings({ + enabled: true, + ttl: '2h', + }); + + expect(result.applied).toBe('config-only'); + expect(result.enabled).toBe(true); + expect(result.ttl).toBe('2h'); + + const { loadUnifiedConfig } = await import('../../../src/config/unified-config-loader'); + const persisted = loadUnifiedConfig(); + expect(persisted?.cliproxy?.routing?.session_affinity).toBe(true); + expect(persisted?.cliproxy?.routing?.session_affinity_ttl).toBe('2h'); + }); + }); + + it('does not claim live session-affinity application just because local CLIProxy is reachable', async () => { + await withScopedConfig(async () => { + responseFactory = async () => + new Response(JSON.stringify({ strategy: 'round-robin' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + const mod = await loadRoutingModule(); + const result = await mod.applyCliproxySessionAffinitySettings({ + enabled: true, + ttl: '30m', + }); + + expect(result.reachable).toBe(true); + expect(result.applied).toBe('config-only'); + expect(result.message).toContain('does not verify live selector state yet'); + }); + }); + + it('reports remote session-affinity management as unsupported', async () => { + await withScopedConfig(async () => { + routingTarget = { + host: 'remote.example.com', + port: 8080, + protocol: 'http', + isRemote: true, + }; + + responseFactory = async () => + new Response(JSON.stringify({ strategy: 'round-robin' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + const mod = await loadRoutingModule(); + const state = await mod.readCliproxySessionAffinityState(); + + expect(state.source).toBe('unsupported'); + expect(state.target).toBe('remote'); + expect(state.manageable).toBe(false); + expect(state.reachable).toBe(true); + expect(state.enabled).toBeUndefined(); + + const result = await mod.applyCliproxySessionAffinitySettings({ + enabled: true, + ttl: '1h', + }); + + expect(result.applied).toBe('unsupported'); + expect(result.manageable).toBe(false); + }); + }); + + it('reports unsupported remote session-affinity as unreachable when remote routing probe fails', async () => { + await withScopedConfig(async () => { + routingTarget = { + host: 'remote.example.com', + port: 8080, + protocol: 'http', + isRemote: true, + }; + responseFactory = null; + + const mod = await loadRoutingModule(); + const state = await mod.readCliproxySessionAffinityState(); + + expect(state.source).toBe('unsupported'); + expect(state.reachable).toBe(false); + expect(state.message).toContain('not reachable'); + }); + }); }); diff --git a/tests/unit/cliproxy/service-manager-startup.test.ts b/tests/unit/cliproxy/service-manager-startup.test.ts index 5b85f9fc..5fb4818e 100644 --- a/tests/unit/cliproxy/service-manager-startup.test.ts +++ b/tests/unit/cliproxy/service-manager-startup.test.ts @@ -1,65 +1,26 @@ -import { describe, expect, it, mock } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; -const ensureBinaryCalls: Array = []; - -mock.module('../../../src/cliproxy/binary-manager', () => ({ - ensureCLIProxyBinary: async (_verbose = false, options?: unknown) => { - ensureBinaryCalls.push(options); - throw new Error( - 'CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.' - ); - }, -})); - -mock.module('../../../src/cliproxy/config-generator', () => ({ - ensureConfigDir: () => undefined, - generateConfig: () => '/tmp/cliproxy-config.yaml', - regenerateConfig: () => '/tmp/cliproxy-config.yaml', - configNeedsRegeneration: () => false, - CLIPROXY_DEFAULT_PORT: 8317, - getCliproxyWritablePath: () => '/tmp', -})); - -mock.module('../../../src/cliproxy/proxy-detector', () => ({ - detectRunningProxy: async () => ({ running: false, verified: false }), - waitForProxyHealthy: async () => false, -})); - -mock.module('../../../src/cliproxy/startup-lock', () => ({ - withStartupLock: async (fn: () => Promise) => await fn(), -})); - -mock.module('../../../src/cliproxy/session-tracker', () => ({ - registerSession: () => undefined, -})); - -mock.module('../../../src/cliproxy/stats-fetcher', () => ({ - isCliproxyRunning: async () => false, -})); - -mock.module('../../../src/cliproxy/auth/token-refresh-config', () => ({ - getTokenRefreshConfig: () => null, -})); - -mock.module('../../../src/cliproxy/auth/token-refresh-worker', () => ({ - TokenRefreshWorker: class { - isActive(): boolean { - return false; - } - start(): void {} - stop(): void {} - }, -})); - -const { ensureCliproxyService } = await import( - `../../../src/cliproxy/service-manager?service-manager-startup=${Date.now()}` -); +import { ensureCliproxyService } from '../../../src/cliproxy/service-manager'; describe('ensureCliproxyService', () => { it('fails fast without attempting a runtime install when the local binary is missing', async () => { - ensureBinaryCalls.length = 0; + // Inject stubs via the deps seam instead of bun's `mock.module()`. The + // module-level mock approach is process-wide and was previously leaking + // stubbed binary-manager / stats-fetcher modules into unrelated test + // suites (notably cliproxy-stats-routes-*) that import them transitively. + const ensureBinaryCalls: Array = []; - const result = await ensureCliproxyService(8317, false); + const result = await ensureCliproxyService(8317, false, { + ensureBinaryFn: async (_verbose, options) => { + ensureBinaryCalls.push(options); + throw new Error( + 'CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.' + ); + }, + detectRunningProxyFn: async () => ({ running: false, verified: false }), + configNeedsRegenerationFn: () => false, + withStartupLockFn: async (fn) => await fn(), + }); expect(result).toEqual({ started: false, diff --git a/tests/unit/cliproxy/version-checker-stale-cache.test.ts b/tests/unit/cliproxy/version-checker-stale-cache.test.ts index c8829faa..fe87bdb1 100644 --- a/tests/unit/cliproxy/version-checker-stale-cache.test.ts +++ b/tests/unit/cliproxy/version-checker-stale-cache.test.ts @@ -91,33 +91,27 @@ describe('version-checker stale cache fallback', () => { it('skips update lookups when runtime startup prefers the installed binary', async () => { const { getExecutableName } = await import('../../../src/cliproxy/platform-detector'); + const { ensureBinary } = await import('../../../src/cliproxy/binary/lifecycle'); + const plusBinDir = path.join(tempHome, '.ccs', 'cliproxy', 'bin', 'plus'); fs.mkdirSync(plusBinDir, { recursive: true }); fs.writeFileSync(path.join(plusBinDir, getExecutableName('plus')), 'binary'); + // Verify the contract via dependency injection rather than mock.module(). + // bun's mock.module() is process-wide and is NOT undone by mock.restore(), + // which previously leaked a stubbed version-checker into unrelated test + // files (cliproxy-stats-routes-*) that transitively import it. let checkForUpdatesCalls = 0; - - mock.module('../../../src/cliproxy/binary/version-checker', () => ({ - checkForUpdates: async () => { - checkForUpdatesCalls += 1; - return { - hasUpdate: false, - currentVersion: '6.8.2-0', - latestVersion: '6.8.2-0', - fromCache: false, - checkedAt: Date.now(), - }; - }, - fetchLatestVersion: async () => { - throw new Error('fetchLatestVersion should not run when skipAutoUpdate is enabled'); - }, - isNewerVersion: () => false, - isVersionFaulty: () => false, - })); - - const { ensureBinary } = await import( - `../../../src/cliproxy/binary/lifecycle?skip-auto-update=${Date.now()}` - ); + const checkForUpdatesSpy = async () => { + checkForUpdatesCalls += 1; + return { + hasUpdate: false, + currentVersion: '6.8.2-0', + latestVersion: '6.8.2-0', + fromCache: false, + checkedAt: Date.now(), + }; + }; const binaryPath = await ensureBinary({ version: '6.8.2-0', @@ -129,6 +123,7 @@ describe('version-checker stale cache fallback', () => { skipAutoUpdate: true, allowInstall: true, backend: 'plus', + checkForUpdatesFn: checkForUpdatesSpy, }); expect(binaryPath).toBe(path.join(plusBinDir, getExecutableName('plus'))); diff --git a/tests/unit/commands/api-command-args.test.ts b/tests/unit/commands/api-command-args.test.ts index 1f5dee30..83387831 100644 --- a/tests/unit/commands/api-command-args.test.ts +++ b/tests/unit/commands/api-command-args.test.ts @@ -163,6 +163,24 @@ describe('api-command arg parser', () => { expect(parsed.extendedContext).toBeUndefined(); expect(parsed.errors).toEqual(['Cannot combine --1m and --no-1m']); }); + + test('parses --extra-models as comma-separated list with whitespace and empty entries trimmed', () => { + const parsed = parseApiCommandArgs([ + 'my-api', + '--extra-models', + ' glm-4.6 , kimi-k2 , ,MiniMax-M2 ', + ]); + + expect(parsed.extraModels).toEqual(['glm-4.6', 'kimi-k2', 'MiniMax-M2']); + expect(parsed.errors).toEqual([]); + }); + + test('records missing-value error for --extra-models with no value', () => { + const parsed = parseApiCommandArgs(['my-api', '--extra-models']); + + expect(parsed.extraModels).toBeUndefined(); + expect(parsed.errors).toEqual(['Missing value for --extra-models']); + }); }); describe('collectUnexpectedApiArgs', () => { diff --git a/tests/unit/commands/completion-backend.test.ts b/tests/unit/commands/completion-backend.test.ts index b683043e..4667dc76 100644 --- a/tests/unit/commands/completion-backend.test.ts +++ b/tests/unit/commands/completion-backend.test.ts @@ -128,6 +128,22 @@ describe('completion backend', () => { expect(values).toContain('my-codex'); }); + test('suggests the correct routing affinity completion shape', () => { + expect(suggestionValues(['cliproxy', 'routing'])).toEqual( + expect.arrayContaining(['set', 'explain', 'affinity']) + ); + expect(suggestionValues(['cliproxy', 'routing', 'affinity'])).toEqual( + expect.arrayContaining(['on', 'off']) + ); + expect(suggestionValues(['cliproxy', 'routing', 'affinity', 'on'])).toEqual( + expect.arrayContaining(['--ttl']) + ); + expect(suggestionValues(['cliproxy', 'routing', 'affinity', 'off'])).toEqual( + expect.arrayContaining(['--ttl']) + ); + expect(suggestionValues(['cliproxy', 'routing', 'affinity', '--ttl'])).not.toContain('--ttl'); + }); + test('suggests env format values after the format flag', () => { const values = suggestionValues(['env', '--format']); expect(values).toEqual( diff --git a/tests/unit/commands/config-command.test.ts b/tests/unit/commands/config-command.test.ts index 2fd3fbcf..bd80a4f0 100644 --- a/tests/unit/commands/config-command.test.ts +++ b/tests/unit/commands/config-command.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import * as os from 'os'; import { handleConfigCommand } from '../../../src/commands/config-command'; import { resolveNamedCommand } from '../../../src/commands/named-command-router'; @@ -104,7 +105,9 @@ describe('config command dashboard startup', () => { throw new Error(`process.exit(${code ?? 0})`); }) as typeof process.exit; - await expect(handleConfigCommand(['help'], createTestDeps())).rejects.toThrow('process.exit(0)'); + await expect(handleConfigCommand(['help'], createTestDeps())).rejects.toThrow( + 'process.exit(0)' + ); expect(startServerCalls).toHaveLength(0); expect(logLines.join('\n')).toContain('Usage: ccs config [command] [options]'); @@ -191,6 +194,25 @@ describe('config command dashboard startup', () => { expect(rendered).toContain('Dashboard will work but Control Panel/Stats may be limited'); }); + it('still opens the dashboard when wildcard network detection is unavailable', async () => { + const networkInterfacesSpy = spyOn(os, 'networkInterfaces').mockImplementation(() => { + throw new Error('uv_interface_addresses returned Unknown system error 13'); + }); + + try { + await handleConfigCommand([], createTestDeps()); + + expect(startServerCalls).toHaveLength(1); + + const rendered = logLines.join('\n'); + expect(rendered).toContain('Dashboard: http://localhost:3000'); + expect(rendered).toContain('Bind host: ::'); + expect(errorLines).toHaveLength(0); + } finally { + networkInterfacesSpy.mockRestore(); + } + }); + it('fails cleanly when the server cannot bind the requested host', async () => { startServerError = new Error( 'Unable to bind 192.0.2.123:4100; the address may be unavailable or the port may already be in use' diff --git a/tests/unit/commands/config-dashboard-host.test.ts b/tests/unit/commands/config-dashboard-host.test.ts index a0f85c4e..f91782ec 100644 --- a/tests/unit/commands/config-dashboard-host.test.ts +++ b/tests/unit/commands/config-dashboard-host.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'bun:test'; +import { describe, expect, it, spyOn } from 'bun:test'; +import * as os from 'os'; import { isLoopbackHost, @@ -66,4 +67,20 @@ describe('config dashboard host helpers', () => { expect(urls.bindHost).toBe('::1'); expect(urls.browserUrl).toBe('http://[::1]:3000'); }); + + it('falls back to localhost-only URLs when interface enumeration fails', () => { + const networkInterfacesSpy = spyOn(os, 'networkInterfaces').mockImplementation(() => { + throw new Error('uv_interface_addresses returned Unknown system error 13'); + }); + + try { + const urls = resolveDashboardUrls('::', 3000); + + expect(urls.bindHost).toBe('::'); + expect(urls.browserUrl).toBe('http://localhost:3000'); + expect(urls.networkUrls).toBeUndefined(); + } finally { + networkInterfacesSpy.mockRestore(); + } + }); }); diff --git a/tests/unit/data-aggregator.test.ts b/tests/unit/data-aggregator.test.ts index 4984cc15..e15d468c 100644 --- a/tests/unit/data-aggregator.test.ts +++ b/tests/unit/data-aggregator.test.ts @@ -5,6 +5,7 @@ import { describe, expect, test } from 'bun:test'; import { aggregateDailyUsage, + aggregateHourlyUsage, aggregateMonthlyUsage, aggregateSessionUsage, } from '../../src/web-server/data-aggregator'; @@ -107,6 +108,48 @@ describe('aggregateDailyUsage', () => { const result = aggregateDailyUsage(entries, 'test-source'); expect(result[0].source).toBe('test-source'); }); + + test('keeps provider identity when same model appears under multiple providers', () => { + const entries: RawUsageEntry[] = [ + createEntry({ model: 'gpt-5.5', target: 'openai' }), + createEntry({ model: 'gpt-5.5', target: 'github-copilot' }), + ]; + + const daily = aggregateDailyUsage(entries); + const hourly = aggregateHourlyUsage(entries); + const monthly = aggregateMonthlyUsage(entries); + const session = aggregateSessionUsage(entries); + + expect(daily[0].modelsUsed).toEqual(['openai/gpt-5.5', 'github-copilot/gpt-5.5']); + expect(hourly[0].modelsUsed).toEqual(['openai/gpt-5.5', 'github-copilot/gpt-5.5']); + expect(monthly[0].modelsUsed).toEqual(['openai/gpt-5.5', 'github-copilot/gpt-5.5']); + expect(session[0].modelsUsed).toEqual(['openai/gpt-5.5', 'github-copilot/gpt-5.5']); + expect(daily[0].modelBreakdowns.map((item) => item.provider)).toEqual([ + 'openai', + 'github-copilot', + ]); + }); + + test('preserves model-only modelsUsed entries when provider is unambiguous', () => { + const result = aggregateDailyUsage([ + createEntry({ model: 'gpt-5.5', target: 'openai' }), + createEntry({ model: 'gpt-5.5', target: 'openai' }), + ]); + + expect(result[0].modelsUsed).toEqual(['gpt-5.5']); + }); + + test('canonicalizes provider aliases before grouping usage', () => { + const result = aggregateDailyUsage([ + createEntry({ model: 'gpt-5.5', target: 'ghcp', inputTokens: 1000 }), + createEntry({ model: 'gpt-5.5', target: 'github-copilot', inputTokens: 2000 }), + ]); + + expect(result[0].modelsUsed).toEqual(['gpt-5.5']); + expect(result[0].modelBreakdowns).toHaveLength(1); + expect(result[0].modelBreakdowns[0].provider).toBe('github-copilot'); + expect(result[0].modelBreakdowns[0].inputTokens).toBe(3000); + }); }); // ============================================================================ diff --git a/tests/unit/hooks/browser-mcp-advanced-interactions.test.ts b/tests/unit/hooks/browser-mcp-advanced-interactions.test.ts index bb6d42a4..c31c4b60 100644 --- a/tests/unit/hooks/browser-mcp-advanced-interactions.test.ts +++ b/tests/unit/hooks/browser-mcp-advanced-interactions.test.ts @@ -1,5 +1,15 @@ import { describe, expect, it } from 'bun:test'; -import { bundledServerPath, runMcpRequests, getResponseText, getMockDropzoneState, mkdtempSync, readFileSync, writeFileSync, tmpdir, join } from './browser-mcp-test-harness'; +import { + bundledServerPath, + runMcpRequests, + getResponseText, + getMockDropzoneState, + mkdtempSync, + readFileSync, + writeFileSync, + tmpdir, + join, +} from './browser-mcp-test-harness'; import type { MockPageState } from './browser-mcp-test-harness'; describe('ccs-browser MCP server - advanced interactions', () => { @@ -88,7 +98,9 @@ describe('ccs-browser MCP server - advanced interactions', () => { }, ]); - expect(getResponseText(responses.find((message) => message.id === 901))).toContain('status: files-dropped'); + expect(getResponseText(responses.find((message) => message.id === 901))).toContain( + 'status: files-dropped' + ); expect(getMockDropzoneState(pages[0]!, '#dropzone')?.receivedEventTypes).toEqual([ 'dragenter', 'dragover', @@ -99,7 +111,8 @@ describe('ccs-browser MCP server - advanced interactions', () => { { name: 'receipt.png', size: 7, type: 'image/png' }, ]); expect( - getMockDropzoneState(pages[0]!, '#frame-dropzone', { frameSelector: '#upload-frame' })?.receivedFiles + getMockDropzoneState(pages[0]!, '#frame-dropzone', { frameSelector: '#upload-frame' }) + ?.receivedFiles ).toEqual([{ name: 'invoice.pdf', size: 7, type: 'application/pdf' }]); expect( getMockDropzoneState(pages[0]!, '#shadow-dropzone', { pierceShadow: true })?.receivedFiles @@ -145,7 +158,12 @@ describe('ccs-browser MCP server - advanced interactions', () => { method: 'tools/call', params: { name: 'browser_drag_files', - arguments: { pageIndex: 0, pageId: 'page-1', selector: '#reject-dropzone', files: [okPath] }, + arguments: { + pageIndex: 0, + pageId: 'page-1', + selector: '#reject-dropzone', + files: [okPath], + }, }, }, { @@ -169,7 +187,9 @@ describe('ccs-browser MCP server - advanced interactions', () => { ] ); - expect(getResponseText(responses.find((message) => message.id === 904))).toContain('file does not exist'); + expect(getResponseText(responses.find((message) => message.id === 904))).toContain( + 'file does not exist' + ); expect(getResponseText(responses.find((message) => message.id === 905))).toContain( 'pageIndex and pageId cannot be used together' ); @@ -297,13 +317,27 @@ describe('ccs-browser MCP server - advanced interactions', () => { }, ]); - expect(getResponseText(responses.find((message) => message.id === 908))).toContain('status: dragged'); - expect(getResponseText(responses.find((message) => message.id === 908))).toContain('targetSelector: #lane-b'); - expect(getResponseText(responses.find((message) => message.id === 909))).toContain('status: dragged'); - expect(getResponseText(responses.find((message) => message.id === 909))).toContain('targetX: 420'); - expect(getResponseText(responses.find((message) => message.id === 909))).toContain('targetY: 180'); - expect(getResponseText(responses.find((message) => message.id === 916))).toContain('pageIndex: 1'); - expect(getResponseText(responses.find((message) => message.id === 916))).toContain('targetSelector: #lane-b'); + expect(getResponseText(responses.find((message) => message.id === 908))).toContain( + 'status: dragged' + ); + expect(getResponseText(responses.find((message) => message.id === 908))).toContain( + 'targetSelector: #lane-b' + ); + expect(getResponseText(responses.find((message) => message.id === 909))).toContain( + 'status: dragged' + ); + expect(getResponseText(responses.find((message) => message.id === 909))).toContain( + 'targetX: 420' + ); + expect(getResponseText(responses.find((message) => message.id === 909))).toContain( + 'targetY: 180' + ); + expect(getResponseText(responses.find((message) => message.id === 916))).toContain( + 'pageIndex: 1' + ); + expect(getResponseText(responses.find((message) => message.id === 916))).toContain( + 'targetSelector: #lane-b' + ); expect(pages[0]!.drag?.recordedActions).toEqual([ { type: 'mouseMoved', x: 70, y: 70, button: 'none' }, { type: 'mousePressed', x: 70, y: 70, button: 'left' }, @@ -360,7 +394,12 @@ describe('ccs-browser MCP server - advanced interactions', () => { method: 'tools/call', params: { name: 'browser_drag_element', - arguments: { selector: '#card-a', targetSelector: '#lane-b', targetX: 400, targetY: 200 }, + arguments: { + selector: '#card-a', + targetSelector: '#lane-b', + targetX: 400, + targetY: 200, + }, }, }, { @@ -387,7 +426,13 @@ describe('ccs-browser MCP server - advanced interactions', () => { method: 'tools/call', params: { name: 'browser_drag_element', - arguments: { pageIndex: 0, pageId: 'page-1', selector: '#card-a', targetX: 400, targetY: 200 }, + arguments: { + pageIndex: 0, + pageId: 'page-1', + selector: '#card-a', + targetX: 400, + targetY: 200, + }, }, }, ] @@ -485,19 +530,21 @@ describe('ccs-browser MCP server - advanced interactions', () => { name: 'browser_pointer_action', arguments: { pageId: 'page-2', - actions: [ - { type: 'move', selector: '#handle' }, - { type: 'down' }, - { type: 'up' }, - ], + actions: [{ type: 'move', selector: '#handle' }, { type: 'down' }, { type: 'up' }], }, }, }, ]); - expect(getResponseText(responses.find((message) => message.id === 913))).toContain('status: pointer-actions-completed'); - expect(getResponseText(responses.find((message) => message.id === 918))).toContain('pageIndex: 1'); - expect(getResponseText(responses.find((message) => message.id === 918))).toContain('status: pointer-actions-completed'); + expect(getResponseText(responses.find((message) => message.id === 913))).toContain( + 'status: pointer-actions-completed' + ); + expect(getResponseText(responses.find((message) => message.id === 918))).toContain( + 'pageIndex: 1' + ); + expect(getResponseText(responses.find((message) => message.id === 918))).toContain( + 'status: pointer-actions-completed' + ); expect(pages[0]!.drag?.recordedActions).toEqual([ { type: 'mouseMoved', x: 50, y: 60, button: 'none' }, { type: 'mousePressed', x: 50, y: 60, button: 'left' }, @@ -592,8 +639,12 @@ describe('ccs-browser MCP server - advanced interactions', () => { }, ]); - expect(getResponseText(responses.find((message) => message.id === 914))).toContain('pointer state error'); - expect(getResponseText(responses.find((message) => message.id === 915))).toContain('drag coordinates unavailable'); + expect(getResponseText(responses.find((message) => message.id === 914))).toContain( + 'pointer state error' + ); + expect(getResponseText(responses.find((message) => message.id === 915))).toContain( + 'drag coordinates unavailable' + ); expect(getResponseText(responses.find((message) => message.id === 919))).toContain( 'pageIndex and pageId cannot be used together' ); @@ -1177,6 +1228,37 @@ describe('ccs-browser MCP server - advanced interactions', () => { ); }); + it('reports a clear error before screenshot capture when the selected page has a zero viewport', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Popup Target', + currentUrl: 'https://example.com/', + viewport: { width: 0, height: 0 }, + screenshot: { data: 'c2NyZWVuc2hvdA==' }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'browser_take_screenshot', + arguments: {}, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 3); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: page has no drawable viewport for screenshot' + ); + }); + it('types into supported editable targets with explicit focus and final-value verification, and rejects unsupported targets', async () => { const responses = await runMcpRequests( [ diff --git a/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts b/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts index 554a25c7..50954827 100644 --- a/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts +++ b/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts @@ -126,15 +126,21 @@ describe('ccs-browser MCP server - session and interception', () => { const setDownloadTool = tools.find((tool) => tool.name === 'browser_set_download_behavior'); expect(setDownloadTool?.inputSchema?.properties?.behavior).toMatchObject({ type: 'string' }); - expect(setDownloadTool?.inputSchema?.properties?.downloadPath).toMatchObject({ type: 'string' }); - expect(setDownloadTool?.inputSchema?.properties?.eventsEnabled).toMatchObject({ type: 'boolean' }); + expect(setDownloadTool?.inputSchema?.properties?.downloadPath).toMatchObject({ + type: 'string', + }); + expect(setDownloadTool?.inputSchema?.properties?.eventsEnabled).toMatchObject({ + type: 'boolean', + }); const listDownloadsTool = tools.find((tool) => tool.name === 'browser_list_downloads'); expect(listDownloadsTool?.inputSchema?.properties?.limit).toMatchObject({ type: 'integer' }); expect(listDownloadsTool?.inputSchema?.properties?.pageId).toBeUndefined(); const cancelDownloadTool = tools.find((tool) => tool.name === 'browser_cancel_download'); - expect(cancelDownloadTool?.inputSchema?.properties?.downloadId).toMatchObject({ type: 'string' }); + expect(cancelDownloadTool?.inputSchema?.properties?.downloadId).toMatchObject({ + type: 'string', + }); expect(cancelDownloadTool?.inputSchema?.properties?.guid).toMatchObject({ type: 'string' }); const uploadTool = tools.find((tool) => tool.name === 'browser_set_file_input'); @@ -157,7 +163,9 @@ describe('ccs-browser MCP server - session and interception', () => { const dragElementTool = tools.find((tool) => tool.name === 'browser_drag_element'); expect(dragElementTool?.inputSchema?.properties?.selector).toMatchObject({ type: 'string' }); - expect(dragElementTool?.inputSchema?.properties?.targetSelector).toMatchObject({ type: 'string' }); + expect(dragElementTool?.inputSchema?.properties?.targetSelector).toMatchObject({ + type: 'string', + }); expect(dragElementTool?.inputSchema?.properties?.targetX).toMatchObject({ type: 'number' }); expect(dragElementTool?.inputSchema?.properties?.targetY).toMatchObject({ type: 'number' }); expect(dragElementTool?.inputSchema?.properties?.steps).toMatchObject({ type: 'integer' }); @@ -166,7 +174,9 @@ describe('ccs-browser MCP server - session and interception', () => { expect(pointerTool?.inputSchema?.properties?.actions).toMatchObject({ type: 'array' }); const startRecordingTool = tools.find((tool) => tool.name === 'browser_start_recording'); - expect(startRecordingTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); + expect(startRecordingTool?.inputSchema?.properties?.pageIndex).toMatchObject({ + type: 'integer', + }); expect(startRecordingTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); const stopRecordingTool = tools.find((tool) => tool.name === 'browser_stop_recording'); @@ -189,15 +199,25 @@ describe('ccs-browser MCP server - session and interception', () => { const cancelReplayTool = tools.find((tool) => tool.name === 'browser_cancel_replay'); expect(cancelReplayTool?.inputSchema).toMatchObject({ type: 'object' }); - const startOrchestrationTool = tools.find((tool) => tool.name === 'browser_start_orchestration'); - expect(startOrchestrationTool?.inputSchema?.properties?.blocks).toMatchObject({ type: 'array' }); - expect(startOrchestrationTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); - expect(startOrchestrationTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); + const startOrchestrationTool = tools.find( + (tool) => tool.name === 'browser_start_orchestration' + ); + expect(startOrchestrationTool?.inputSchema?.properties?.blocks).toMatchObject({ + type: 'array', + }); + expect(startOrchestrationTool?.inputSchema?.properties?.pageIndex).toMatchObject({ + type: 'integer', + }); + expect(startOrchestrationTool?.inputSchema?.properties?.pageId).toMatchObject({ + type: 'string', + }); const getOrchestrationTool = tools.find((tool) => tool.name === 'browser_get_orchestration'); expect(getOrchestrationTool?.inputSchema).toMatchObject({ type: 'object' }); - const cancelOrchestrationTool = tools.find((tool) => tool.name === 'browser_cancel_orchestration'); + const cancelOrchestrationTool = tools.find( + (tool) => tool.name === 'browser_cancel_orchestration' + ); expect(cancelOrchestrationTool?.inputSchema).toMatchObject({ type: 'object' }); const exportArtifactTool = tools.find((tool) => tool.name === 'browser_export_artifact'); @@ -289,6 +309,55 @@ describe('ccs-browser MCP server - session and interception', () => { expect(text).toContain('Docs text'); }); + it('opens a page when Chrome DevTools requires PUT for /json/new', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 820, + method: 'tools/call', + params: { + name: 'browser_open_page', + arguments: { url: 'https://example.com/new' }, + }, + }, + ], + { requirePutForNewPage: true } + ); + + const openText = getResponseText(responses.find((message) => message.id === 820)); + expect(openText).toContain('status: opened'); + expect(openText).toContain('url: https://example.com/new'); + }); + + it('omits non-browser popup targets from session info', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Omnibox Popup', + currentUrl: 'chrome://omnibox-popup.top-chrome/', + }, + { id: 'page-2', title: 'Home', currentUrl: 'https://example.com/' }, + ], + [ + { + jsonrpc: '2.0', + id: 8201, + method: 'tools/call', + params: { name: 'browser_get_session_info', arguments: {} }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 8201)); + expect(text).toContain('0. Home'); + expect(text).toContain('selected: true'); + expect(text).not.toContain('Omnibox Popup'); + expect(text).not.toContain('chrome://omnibox-popup.top-chrome/'); + }); + it('opens a page and makes it selected', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], @@ -717,7 +786,9 @@ describe('ccs-browser MCP server - session and interception', () => { title: 'Docs', currentUrl: 'https://example.com/docs', intercept: { - pausedRequests: [{ requestId: 'req-closed', url: 'https://example.com/api/docs', method: 'GET' }], + pausedRequests: [ + { requestId: 'req-closed', url: 'https://example.com/api/docs', method: 'GET' }, + ], }, }, ], @@ -801,7 +872,9 @@ describe('ccs-browser MCP server - session and interception', () => { const response = responses.find((message) => message.id === 951); expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: pageIndex and pageId cannot be used together'); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: pageIndex and pageId cannot be used together' + ); }); it('rejects browser_add_intercept_rule when action is invalid', async () => { @@ -825,7 +898,9 @@ describe('ccs-browser MCP server - session and interception', () => { const response = responses.find((message) => message.id === 952); expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: action must be one of: continue, fail'); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: action must be one of: continue, fail' + ); }); it('rejects intercept rules when urlPattern and urlRegex are both provided', async () => { @@ -850,7 +925,9 @@ describe('ccs-browser MCP server - session and interception', () => { const response = responses.find((message) => message.id === 981); expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: urlPattern and urlRegex cannot be used together'); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: urlPattern and urlRegex cannot be used together' + ); }); it('rejects intercept rules when priority is not an integer', async () => { @@ -899,7 +976,9 @@ describe('ccs-browser MCP server - session and interception', () => { const response = responses.find((message) => message.id === 983); expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: headerMatchers must be an array'); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: headerMatchers must be an array' + ); }); it('rejects intercept rules when a header matcher is missing name', async () => { @@ -923,7 +1002,9 @@ describe('ccs-browser MCP server - session and interception', () => { const response = responses.find((message) => message.id === 984); expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: headerMatchers.name is required'); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: headerMatchers.name is required' + ); }); it('rejects intercept rules when a header matcher has no value matcher', async () => { @@ -947,7 +1028,9 @@ describe('ccs-browser MCP server - session and interception', () => { const response = responses.find((message) => message.id === 985); expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: headerMatchers entry must include valueIncludes or valueRegex'); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: headerMatchers entry must include valueIncludes or valueRegex' + ); }); it('rejects intercept rules when no matching condition is provided', async () => { @@ -971,7 +1054,9 @@ describe('ccs-browser MCP server - session and interception', () => { const response = responses.find((message) => message.id === 986); expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: at least one matching condition is required'); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: at least one matching condition is required' + ); }); it('adds a resourceType interception rule and lists its richer matching summary', async () => { @@ -1556,7 +1641,9 @@ describe('ccs-browser MCP server - session and interception', () => { const response = responses.find((message) => message.id === 968); expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: statusCode must be an integer between 100 and 599'); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: statusCode must be an integer between 100 and 599' + ); }); it('rejects fulfill rules when responseHeaders is not an array', async () => { @@ -1582,7 +1669,9 @@ describe('ccs-browser MCP server - session and interception', () => { const response = responses.find((message) => message.id === 969); expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: responseHeaders must be an array'); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: responseHeaders must be an array' + ); }); it('rejects fulfill rules when a responseHeaders entry is missing name', async () => { @@ -1608,7 +1697,9 @@ describe('ccs-browser MCP server - session and interception', () => { const response = responses.find((message) => message.id === 970); expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: responseHeaders.name is required'); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: responseHeaders.name is required' + ); }); it('rejects fulfill rules when body is not a string', async () => { @@ -1657,7 +1748,9 @@ describe('ccs-browser MCP server - session and interception', () => { const response = responses.find((message) => message.id === 1001); expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: urlRegex must be a valid regular expression'); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: urlRegex must be a valid regular expression' + ); }); it('rejects intercept rules when headerMatchers.valueRegex is invalid', async () => { @@ -1681,7 +1774,9 @@ describe('ccs-browser MCP server - session and interception', () => { const response = responses.find((message) => message.id === 1002); expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: headerMatchers.valueRegex must be a valid regular expression'); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: headerMatchers.valueRegex must be a valid regular expression' + ); }); it('removes fulfill rules and request summaries after the bound page is closed', async () => { @@ -1694,13 +1789,22 @@ describe('ccs-browser MCP server - session and interception', () => { currentUrl: 'https://example.com/mocked', intercept: { pausedRequests: [ - { requestId: 'req-fulfill-close', url: 'https://example.com/api/mock/close', method: 'GET' }, + { + requestId: 'req-fulfill-close', + url: 'https://example.com/api/mock/close', + method: 'GET', + }, ], }, }, ], [ - { jsonrpc: '2.0', id: 972, method: 'tools/call', params: { name: 'browser_select_page', arguments: { pageIndex: 1 } } }, + { + jsonrpc: '2.0', + id: 972, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, + }, { jsonrpc: '2.0', id: 973, @@ -1715,19 +1819,45 @@ describe('ccs-browser MCP server - session and interception', () => { }, }, }, - { jsonrpc: '2.0', id: 974, method: 'tools/call', params: { name: 'browser_list_requests', arguments: {} } }, - { jsonrpc: '2.0', id: 975, method: 'tools/call', params: { name: 'browser_close_page', arguments: { pageId: 'page-2' } } }, - { jsonrpc: '2.0', id: 976, method: 'tools/call', params: { name: 'browser_list_intercept_rules', arguments: {} } }, - { jsonrpc: '2.0', id: 977, method: 'tools/call', params: { name: 'browser_list_requests', arguments: {} } }, + { + jsonrpc: '2.0', + id: 974, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 975, + method: 'tools/call', + params: { name: 'browser_close_page', arguments: { pageId: 'page-2' } }, + }, + { + jsonrpc: '2.0', + id: 976, + method: 'tools/call', + params: { name: 'browser_list_intercept_rules', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 977, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: {} }, + }, ], { responseTimeoutMs: 12000, } ); - expect(getResponseText(responses.find((message) => message.id === 974))).toContain('requestId: req-fulfill-close'); - expect(getResponseText(responses.find((message) => message.id === 976))).not.toContain('pageId: page-2'); - expect(getResponseText(responses.find((message) => message.id === 977))).not.toContain('requestId: req-fulfill-close'); + expect(getResponseText(responses.find((message) => message.id === 974))).toContain( + 'requestId: req-fulfill-close' + ); + expect(getResponseText(responses.find((message) => message.id === 976))).not.toContain( + 'pageId: page-2' + ); + expect(getResponseText(responses.find((message) => message.id === 977))).not.toContain( + 'requestId: req-fulfill-close' + ); }); it('returns only the requested number of recent requests', async () => { @@ -1739,8 +1869,18 @@ describe('ccs-browser MCP server - session and interception', () => { currentUrl: 'https://example.com/', intercept: { pausedRequests: [ - { requestId: 'req-1', url: 'https://example.com/api/one', method: 'GET', resourceType: 'XHR' }, - { requestId: 'req-2', url: 'https://example.com/api/two', method: 'GET', resourceType: 'XHR' }, + { + requestId: 'req-1', + url: 'https://example.com/api/one', + method: 'GET', + resourceType: 'XHR', + }, + { + requestId: 'req-2', + url: 'https://example.com/api/two', + method: 'GET', + resourceType: 'XHR', + }, ], }, }, @@ -1813,5 +1953,4 @@ describe('ccs-browser MCP server - session and interception', () => { const listText = getResponseText(responses.find((message) => message.id === 956)); expect(listText).toBe('status: empty'); }); - }); diff --git a/tests/unit/hooks/browser-mcp-test-harness.ts b/tests/unit/hooks/browser-mcp-test-harness.ts index fd78c81f..4027ac5a 100644 --- a/tests/unit/hooks/browser-mcp-test-harness.ts +++ b/tests/unit/hooks/browser-mcp-test-harness.ts @@ -268,6 +268,7 @@ type MockPageState = { id: string; title: string; currentUrl: string; + targetType?: string; fileInputs?: Record; browser?: MockBrowserState; frameTree?: MockFrameTree; @@ -288,6 +289,10 @@ type MockPageState = { recording?: MockRecordingPlan; events?: MockPageEventPlan; intercept?: MockInterceptState; + viewport?: { + width: number; + height: number; + }; screenshot?: { expectedClip?: { x: number; @@ -342,6 +347,7 @@ type RunMcpRequestsOptions = { serverPath?: string; childEnv?: NodeJS.ProcessEnv; responseTimeoutMs?: number; + requirePutForNewPage?: boolean; }; function encodeMessage(message: unknown): string { @@ -688,7 +694,7 @@ function createMockBrowser(pagesInput: MockPageState[]) { JSON.stringify([ ...pageEntries.map(([wsPath, page]) => ({ id: page.id, - type: 'page', + type: page.targetType || 'page', title: page.title, url: page.currentUrl, webSocketDebuggerUrl: `ws://127.0.0.1:${serverPort}${wsPath}`, @@ -706,6 +712,12 @@ function createMockBrowser(pagesInput: MockPageState[]) { } if (req.url?.startsWith('/json/new')) { + if (options.requirePutForNewPage && req.method !== 'PUT') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'method not allowed' })); + return; + } + const parsed = new URL(req.url, `http://127.0.0.1:${serverPort}`); const requestedUrl = parsed.searchParams.get('url') || 'about:blank'; const wsPath = `/devtools/page/${nextPageCounter}`; @@ -764,7 +776,8 @@ function createMockBrowser(pagesInput: MockPageState[]) { wsServer = new WebSocketServer({ server: httpServer as http.Server }); wsServer.on('connection', (socket, request) => { if ((request.url || '') === browserSocketPath) { - const browserState = pagesInput[0]?.browser || (pagesInput[0] ? (pagesInput[0].browser = {}) : {}); + const browserState = + pagesInput[0]?.browser || (pagesInput[0] ? (pagesInput[0].browser = {}) : {}); socket.on('message', (raw) => { const message = JSON.parse(raw.toString()) as { @@ -782,9 +795,13 @@ function createMockBrowser(pagesInput: MockPageState[]) { browserState.setDownloadBehaviorCalls.push({ behavior: typeof message.params?.behavior === 'string' ? message.params.behavior : '', downloadPath: - typeof message.params?.downloadPath === 'string' ? message.params.downloadPath : undefined, + typeof message.params?.downloadPath === 'string' + ? message.params.downloadPath + : undefined, eventsEnabled: - typeof message.params?.eventsEnabled === 'boolean' ? message.params.eventsEnabled : undefined, + typeof message.params?.eventsEnabled === 'boolean' + ? message.params.eventsEnabled + : undefined, }); reply({}); return; @@ -801,35 +818,41 @@ function createMockBrowser(pagesInput: MockPageState[]) { for (const page of pagesInput) { for (const [index, download] of (page.events?.downloads || []).entries()) { const guid = download.guid || `${page.id}-download-${index + 1}`; - setTimeout(() => { - socket.send( - JSON.stringify({ - method: 'Browser.downloadWillBegin', - params: { - frameId: download.frameId || `frame-${page.id}`, - guid, - url: download.url, - suggestedFilename: download.suggestedFilename, - }, - }) - ); - }, 10 + index * 40); - - for (const [progressIndex, progress] of (download.progress || []).entries()) { - setTimeout(() => { + setTimeout( + () => { socket.send( JSON.stringify({ - method: 'Browser.downloadProgress', + method: 'Browser.downloadWillBegin', params: { + frameId: download.frameId || `frame-${page.id}`, guid, - totalBytes: progress.totalBytes, - receivedBytes: progress.receivedBytes, - state: progress.state, - filePath: progress.filePath, + url: download.url, + suggestedFilename: download.suggestedFilename, }, }) ); - }, 20 + index * 40 + progressIndex * 20); + }, + 10 + index * 40 + ); + + for (const [progressIndex, progress] of (download.progress || []).entries()) { + setTimeout( + () => { + socket.send( + JSON.stringify({ + method: 'Browser.downloadProgress', + params: { + guid, + totalBytes: progress.totalBytes, + receivedBytes: progress.receivedBytes, + state: progress.state, + filePath: progress.filePath, + }, + }) + ); + }, + 20 + index * 40 + progressIndex * 20 + ); } } } @@ -881,7 +904,31 @@ function createMockBrowser(pagesInput: MockPageState[]) { if (message.method === 'Page.getFrameTree') { const nextFrameTree = page.frameTreeSequence?.shift(); - reply({ frameTree: nextFrameTree || page.frameTree || { frame: { id: `frame-${page.id}` } } }); + reply({ + frameTree: nextFrameTree || page.frameTree || { frame: { id: `frame-${page.id}` } }, + }); + return; + } + + if (message.method === 'Page.getLayoutMetrics') { + const width = page.viewport?.width ?? 1280; + const height = page.viewport?.height ?? 720; + reply({ + visualViewport: { + pageX: 0, + pageY: 0, + clientWidth: width, + clientHeight: height, + scale: 1, + }, + cssVisualViewport: { + pageX: 0, + pageY: 0, + clientWidth: width, + clientHeight: height, + scale: 1, + }, + }); return; } @@ -916,7 +963,8 @@ function createMockBrowser(pagesInput: MockPageState[]) { const type = typeof message.params?.type === 'string' ? message.params.type : ''; const x = Number(message.params?.x); const y = Number(message.params?.y); - const button = typeof message.params?.button === 'string' ? message.params.button : undefined; + const button = + typeof message.params?.button === 'string' ? message.params.button : undefined; for (const hoverPlan of Object.values(page.hover || {})) { if (type === 'mouseMoved') { hoverPlan.lastMouseMove = { x, y }; @@ -1017,28 +1065,33 @@ function createMockBrowser(pagesInput: MockPageState[]) { ? (message.params?.patterns as unknown[]) : []; if (page.intercept.enableError) { - socket.send(JSON.stringify({ id: message.id, error: { message: page.intercept.enableError } })); + socket.send( + JSON.stringify({ id: message.id, error: { message: page.intercept.enableError } }) + ); return; } reply({}); const pauseDispatchDelayMs = page.intercept.pauseDispatchDelayMs ?? 10; for (const [index, paused] of (page.intercept.pausedRequests || []).entries()) { - setTimeout(() => { - socket.send( - JSON.stringify({ - method: 'Fetch.requestPaused', - params: { - requestId: paused.requestId || `fetch-${index + 1}`, - resourceType: paused.resourceType || 'XHR', - request: { - url: paused.url, - method: paused.method, - headers: paused.requestHeaders || {}, + setTimeout( + () => { + socket.send( + JSON.stringify({ + method: 'Fetch.requestPaused', + params: { + requestId: paused.requestId || `fetch-${index + 1}`, + resourceType: paused.resourceType || 'XHR', + request: { + url: paused.url, + method: paused.method, + headers: paused.requestHeaders || {}, + }, }, - }, - }) - ); - }, pauseDispatchDelayMs + index * 10); + }) + ); + }, + pauseDispatchDelayMs + index * 10 + ); } return; } @@ -1056,7 +1109,8 @@ function createMockBrowser(pagesInput: MockPageState[]) { page.intercept.failedRequests = page.intercept.failedRequests || []; page.intercept.failedRequests.push({ requestId: String(message.params?.requestId || ''), - errorReason: typeof message.params?.errorReason === 'string' ? message.params.errorReason : '', + errorReason: + typeof message.params?.errorReason === 'string' ? message.params.errorReason : '', }); reply({}); return; @@ -1068,7 +1122,9 @@ function createMockBrowser(pagesInput: MockPageState[]) { page.intercept.fulfilledRequests.push({ requestId: String(message.params?.requestId || ''), responseCode: - typeof message.params?.responseCode === 'number' ? message.params.responseCode : undefined, + typeof message.params?.responseCode === 'number' + ? message.params.responseCode + : undefined, responseHeaders: Array.isArray(message.params?.responseHeaders) ? (message.params.responseHeaders as Array<{ name: string; value: string }>) : [], @@ -1079,10 +1135,13 @@ function createMockBrowser(pagesInput: MockPageState[]) { } if (message.method === 'DOM.setFileInputFiles') { - const objectId = typeof message.params?.objectId === 'string' ? message.params.objectId : ''; + const objectId = + typeof message.params?.objectId === 'string' ? message.params.objectId : ''; const target = remoteObjects.get(objectId); if (!target) { - socket.send(JSON.stringify({ id: message.id, error: { message: 'file input handle not found' } })); + socket.send( + JSON.stringify({ id: message.id, error: { message: 'file input handle not found' } }) + ); return; } target.assignedFiles = Array.isArray(message.params?.files) @@ -1115,7 +1174,10 @@ function createMockBrowser(pagesInput: MockPageState[]) { return; } - if (recordingPayload && (recordingPayload.events?.length || recordingPayload.warnings?.length)) { + if ( + recordingPayload && + (recordingPayload.events?.length || recordingPayload.warnings?.length) + ) { plan.events = recordingPayload.events || []; plan.warnings = recordingPayload.warnings || []; } @@ -1124,7 +1186,11 @@ function createMockBrowser(pagesInput: MockPageState[]) { return; } - if (expression.includes('globalThis.__CCS_BROWSER_RECORDING_RECORDER__ || { events: [], warnings: [] }')) { + if ( + expression.includes( + 'globalThis.__CCS_BROWSER_RECORDING_RECORDER__ || { events: [], warnings: [] }' + ) + ) { const plan = getMockRecordingPlan(page); if (plan.finalizeError) { socket.send( @@ -1405,7 +1471,8 @@ function createMockBrowser(pagesInput: MockPageState[]) { } if ( resolvedClickPlan.expectedOffset && - (offsetX !== resolvedClickPlan.expectedOffset.x || offsetY !== resolvedClickPlan.expectedOffset.y) + (offsetX !== resolvedClickPlan.expectedOffset.x || + offsetY !== resolvedClickPlan.expectedOffset.y) ) { replyError(`unexpected click offset for selector: ${selector}`); return; @@ -1721,11 +1788,18 @@ function createMockBrowser(pagesInput: MockPageState[]) { return; } - if (expression.includes('window.scrollBy(deltaX, deltaY)') || expression.includes('element.scrollBy(deltaX, deltaY)')) { + if ( + expression.includes('window.scrollBy(deltaX, deltaY)') || + expression.includes('element.scrollBy(deltaX, deltaY)') + ) { const selector = parseJsonArgument(expression, 'selector'); const behavior = parseJsonArgument(expression, 'behavior') || ''; - const deltaX = Number(expression.match(/const deltaX = (-?[0-9]+(?:\.[0-9]+)?);/)?.[1] || 0); - const deltaY = Number(expression.match(/const deltaY = (-?[0-9]+(?:\.[0-9]+)?);/)?.[1] || 0); + const deltaX = Number( + expression.match(/const deltaX = (-?[0-9]+(?:\.[0-9]+)?);/)?.[1] || 0 + ); + const deltaY = Number( + expression.match(/const deltaY = (-?[0-9]+(?:\.[0-9]+)?);/)?.[1] || 0 + ); const scrollPlan = selector ? page.scroll?.[selector] : undefined; if (selector && !scrollPlan) { replyError(`element not found for selector: ${selector}`); @@ -1838,7 +1912,6 @@ async function runMcpRequests( } } - export { bundledServerPath, runMcpRequests, diff --git a/tests/unit/jsonl-parser.test.ts b/tests/unit/jsonl-parser.test.ts index f4f9e402..6980eba1 100644 --- a/tests/unit/jsonl-parser.test.ts +++ b/tests/unit/jsonl-parser.test.ts @@ -146,6 +146,16 @@ describe('parseUsageEntry', () => { test('includes project path in result', () => { const result = parseUsageEntry(VALID_ASSISTANT_ENTRY, '/custom/project/path'); + expect(result!.projectPath).toBe('/home/user/project'); + }); + + test('falls back to the decoded project path when cwd is missing', () => { + const withoutCwd = JSON.stringify({ + ...JSON.parse(VALID_ASSISTANT_ENTRY), + cwd: undefined, + }); + + const result = parseUsageEntry(withoutCwd, '/custom/project/path'); expect(result!.projectPath).toBe('/custom/project/path'); }); @@ -169,7 +179,7 @@ describe('parseUsageEntry', () => { expect(result!.target).toBeUndefined(); }); - test('coerces token fields to non-negative numbers', () => { + test('returns null when required token fields are invalid', () => { const withInvalidUsage = JSON.stringify({ ...JSON.parse(VALID_ASSISTANT_ENTRY), message: { @@ -184,11 +194,16 @@ describe('parseUsageEntry', () => { }); const result = parseUsageEntry(withInvalidUsage, '/test'); - expect(result).not.toBeNull(); - expect(result!.inputTokens).toBe(1500); - expect(result!.outputTokens).toBe(0); - expect(result!.cacheCreationTokens).toBe(0); - expect(result!.cacheReadTokens).toBe(0); + expect(result).toBeNull(); + }); + + test('returns null when timestamp is missing', () => { + const withoutTimestamp = JSON.stringify({ + ...JSON.parse(VALID_ASSISTANT_ENTRY), + timestamp: undefined, + }); + + expect(parseUsageEntry(withoutTimestamp, '/test')).toBeNull(); }); }); @@ -314,8 +329,12 @@ describe('parseProjectDirectory', () => { test('sanitizes derived projectPath from dashed directory names', async () => { const projectDir = path.join(tempDir, '-..-etc-passwd'); + const withoutCwd = JSON.stringify({ + ...JSON.parse(VALID_ASSISTANT_ENTRY), + cwd: undefined, + }); fs.mkdirSync(projectDir); - fs.writeFileSync(path.join(projectDir, 'session.jsonl'), VALID_ASSISTANT_ENTRY); + fs.writeFileSync(path.join(projectDir, 'session.jsonl'), withoutCwd); const entries = await parseProjectDirectory(projectDir); diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 2770439f..779dfac9 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -1,7 +1,10 @@ /** * Unit tests for model-pricing.ts */ -import { describe, it, expect } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, it, expect } from 'bun:test'; import { getModelPricing, calculateCost, @@ -9,6 +12,10 @@ import { hasCustomPricing, type TokenUsage, } from '../../src/web-server/model-pricing'; +import { + clearModelsDevRegistryCache, + setCachedModelsDevRegistry, +} from '../../src/web-server/models-dev/registry-cache'; describe('model-pricing', () => { describe('getModelPricing', () => { @@ -160,6 +167,13 @@ describe('model-pricing', () => { expect(opus47dated.inputPerMillion).toBe(5.0); expect(opus47dated.outputPerMillion).toBe(25.0); }); + + it('should not map unknown future model families onto known family pricing', () => { + const fallback = getModelPricing('unknown-model-xyz'); + + expect(getModelPricing('claude-opus-5-20270101')).toEqual(fallback); + expect(getModelPricing('gemini-3.2-pro')).toEqual(fallback); + }); }); describe('calculateCost', () => { @@ -290,4 +304,148 @@ describe('model-pricing', () => { expect(hasCustomPricing('unknown-model-xyz')).toBe(false); }); }); + + describe('models.dev cache integration', () => { + let tempRoot = ''; + let originalCcsHome: string | undefined; + let originalCcsDir: string | undefined; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-models-dev-pricing-')); + originalCcsHome = process.env.CCS_HOME; + originalCcsDir = process.env.CCS_DIR; + process.env.CCS_HOME = tempRoot; + delete process.env.CCS_DIR; + clearModelsDevRegistryCache(); + setCachedModelsDevRegistry({ + openai: { + id: 'openai', + name: 'OpenAI', + models: { + 'gpt-5.5': { + id: 'gpt-5.5', + name: 'GPT-5.5', + cost: { input: 5, output: 30, cache_read: 0.5 }, + }, + 'gpt-4o': { + id: 'gpt-4o', + name: 'GPT-4o', + cost: { input: 2.5, output: 10, cache_read: 1.25 }, + }, + 'openai-exclusive-model': { + id: 'openai-exclusive-model', + name: 'OpenAI Exclusive Model', + cost: { input: 9, output: 18 }, + }, + }, + }, + 'github-copilot': { + id: 'github-copilot', + name: 'GitHub Copilot', + models: { + 'gpt-5.5': { + id: 'gpt-5.5', + name: 'GPT-5.5', + cost: { input: 0, output: 0 }, + }, + 'gpt-4o': { + id: 'gpt-4o', + name: 'GPT-4o', + cost: { input: 0, output: 0 }, + }, + }, + }, + google: { + id: 'google', + name: 'Google', + models: { + 'gemini-3-flash-preview': { + id: 'gemini-3-flash-preview', + name: 'Gemini 3 Flash Preview', + cost: { input: 99, output: 99 }, + }, + }, + }, + }); + }); + + afterEach(() => { + clearModelsDevRegistryCache(); + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir; + else delete process.env.CCS_DIR; + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + it('resolves provider-prefixed paid API pricing from models.dev', () => { + const pricing = getModelPricing('openai/gpt-5.5'); + expect(pricing.inputPerMillion).toBe(5); + expect(pricing.outputPerMillion).toBe(30); + expect(pricing.cacheReadPerMillion).toBe(0.5); + expect(pricing.cacheCreationPerMillion).toBe(0); + }); + + it('keeps subscription-backed provider pricing distinct from paid API pricing', () => { + const pricing = getModelPricing('gpt-5.5', { provider: 'github-copilot' }); + expect(pricing.inputPerMillion).toBe(0); + expect(pricing.outputPerMillion).toBe(0); + }); + + it('prefers provider-aware models.dev pricing over exact static table matches', () => { + const pricing = getModelPricing('gpt-4o', { provider: 'github-copilot' }); + expect(pricing.inputPerMillion).toBe(0); + expect(pricing.outputPerMillion).toBe(0); + expect(getModelPricing('gpt-4o').inputPerMillion).toBe(2.5); + }); + + it('keeps CCS compatibility aliases ahead of provider-aware models.dev matches', () => { + const pricing = getModelPricing('gemini-3-flash-preview', { provider: 'google' }); + const canonical = getModelPricing('gemini-2.5-flash'); + + expect(pricing).toEqual(canonical); + expect(pricing.inputPerMillion).not.toBe(99); + }); + + it('falls back to CCS static pricing when provider-aware models.dev lookup misses a known model', () => { + const staticPricing = getModelPricing('claude-sonnet-4-5'); + + expect(getModelPricing('anthropic/claude-sonnet-4-5')).toEqual(staticPricing); + expect(getModelPricing('claude-sonnet-4-5', { provider: 'anthropic' })).toEqual( + staticPricing + ); + expect(hasCustomPricing('anthropic/claude-sonnet-4-5')).toBe(true); + }); + + it('does not use ambiguous model-only models.dev matches', () => { + const pricing = getModelPricing('gpt-5.5'); + expect(pricing).toEqual(getModelPricing('unknown-model-xyz')); + expect(hasCustomPricing('gpt-5.5')).toBe(false); + expect(hasCustomPricing('gpt-5.5', { provider: 'openai' })).toBe(true); + }); + + it('does not use another provider pricing when explicit provider lookup misses', () => { + const fallback = getModelPricing('unknown-model-xyz'); + + expect(getModelPricing('openai-exclusive-model', { provider: 'github-copilot' })).toEqual( + fallback + ); + expect(getModelPricing('github-copilot/openai-exclusive-model')).toEqual(fallback); + expect(hasCustomPricing('openai-exclusive-model', { provider: 'github-copilot' })).toBe( + false + ); + }); + + it('calculates cost with provider-aware models.dev pricing', () => { + const usage: TokenUsage = { + inputTokens: 1_000_000, + outputTokens: 1_000_000, + cacheCreationTokens: 1_000_000, + cacheReadTokens: 1_000_000, + }; + + expect(calculateCost(usage, 'gpt-5.5', { provider: 'openai' })).toBe(35.5); + expect(calculateCost(usage, 'gpt-5.5', { provider: 'ghcp' })).toBe(0); + }); + }); }); diff --git a/tests/unit/models-dev-registry-cache.test.ts b/tests/unit/models-dev-registry-cache.test.ts new file mode 100644 index 00000000..20b3dfb5 --- /dev/null +++ b/tests/unit/models-dev-registry-cache.test.ts @@ -0,0 +1,138 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { getCcsDir } from '../../src/utils/config-manager'; +import { + clearModelsDevRegistryCache, + getCachedModelsDevRegistry, + refreshModelsDevRegistry, + setCachedModelsDevRegistry, + startModelsDevRegistryRefresh, +} from '../../src/web-server/models-dev/registry-cache'; + +describe('models.dev registry cache', () => { + let tempRoot = ''; + let originalCcsHome: string | undefined; + let originalCcsDir: string | undefined; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-models-dev-cache-')); + originalCcsHome = process.env.CCS_HOME; + originalCcsDir = process.env.CCS_DIR; + process.env.CCS_HOME = tempRoot; + delete process.env.CCS_DIR; + clearModelsDevRegistryCache(); + }); + + afterEach(() => { + clearModelsDevRegistryCache(); + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir; + else delete process.env.CCS_DIR; + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + it('normalizes and stores provider-keyed models.dev payloads', async () => { + const fetchImpl: typeof fetch = async () => + new Response( + JSON.stringify({ + openai: { + id: 'openai', + name: 'OpenAI', + models: { + 'gpt-5.5': { id: 'gpt-5.5', cost: { input: 5, output: 30 } }, + }, + }, + ignored: { id: 'ignored' }, + }), + { status: 200 } + ); + + const registry = await refreshModelsDevRegistry({ + force: true, + fetchImpl, + now: () => 123, + }); + + expect(registry?.openai.models?.['gpt-5.5']?.cost?.input).toBe(5); + expect(getCachedModelsDevRegistry({ allowStale: false, now: 123 })?.openai.id).toBe('openai'); + }); + + it('uses stale cache when live refresh fails', async () => { + setCachedModelsDevRegistry( + { + openai: { + id: 'openai', + models: { + 'gpt-5.5': { id: 'gpt-5.5', cost: { input: 5, output: 30 } }, + }, + }, + }, + 1 + ); + + const fetchImpl: typeof fetch = async () => { + throw new Error('offline'); + }; + + const registry = await refreshModelsDevRegistry({ + force: true, + fetchImpl, + now: () => 1_000_000_000, + }); + + expect(registry?.openai.models?.['gpt-5.5']?.cost?.output).toBe(30); + }); + + it('ignores malformed cache files', () => { + fs.mkdirSync(getCcsDir(), { recursive: true }); + fs.writeFileSync(path.join(getCcsDir(), 'models-dev-registry-cache.json'), '{not json'); + expect(getCachedModelsDevRegistry({ allowStale: true })).toBeNull(); + }); + + it('starts and coalesces background refreshes without requiring callers to await', async () => { + let fetchCalls = 0; + let resolveResponse: (response: Response) => void = () => undefined; + const responsePromise = new Promise((resolve) => { + resolveResponse = resolve; + }); + const fetchImpl: typeof fetch = async () => { + fetchCalls += 1; + return responsePromise; + }; + + const first = startModelsDevRegistryRefresh({ + force: true, + fetchImpl, + now: () => 456, + }); + const second = startModelsDevRegistryRefresh({ + force: true, + fetchImpl, + now: () => 789, + }); + + expect(first).toBe(second); + expect(fetchCalls).toBe(1); + expect(getCachedModelsDevRegistry({ allowStale: true })).toBeNull(); + + resolveResponse( + new Response( + JSON.stringify({ + openai: { + id: 'openai', + models: { + 'gpt-5.5': { id: 'gpt-5.5', cost: { input: 5, output: 30 } }, + }, + }, + }), + { status: 200 } + ) + ); + + await first; + expect(getCachedModelsDevRegistry({ allowStale: true })?.openai.id).toBe('openai'); + }); +}); diff --git a/tests/unit/proxy/transformers/request-transformer-regressions.test.ts b/tests/unit/proxy/transformers/request-transformer-regressions.test.ts index 4bb3192f..9e4bc5a8 100644 --- a/tests/unit/proxy/transformers/request-transformer-regressions.test.ts +++ b/tests/unit/proxy/transformers/request-transformer-regressions.test.ts @@ -27,7 +27,7 @@ describe('ProxyRequestTransformer regressions', () => { }); expect(result.reasoning_effort).toBe('high'); - expect(result.reasoning).toEqual({ enabled: true, effort: 'high' }); + expect(result.reasoning).toBeUndefined(); }); it('explicitly normalizes anthropic xhigh adaptive effort for OpenAI-compatible upstreams', () => { @@ -38,7 +38,7 @@ describe('ProxyRequestTransformer regressions', () => { }); expect(result.reasoning_effort).toBe('high'); - expect(result.reasoning).toEqual({ enabled: true, effort: 'high' }); + expect(result.reasoning).toBeUndefined(); }); it('rejects unsupported thinking types instead of silently dropping them', () => { diff --git a/tests/unit/proxy/transformers/request-transformer.test.ts b/tests/unit/proxy/transformers/request-transformer.test.ts index 25687c20..543361a1 100644 --- a/tests/unit/proxy/transformers/request-transformer.test.ts +++ b/tests/unit/proxy/transformers/request-transformer.test.ts @@ -28,7 +28,7 @@ describe('ProxyRequestTransformer', () => { expect(result.stream).toBe(true); expect(result.reasoning_effort).toBe('high'); - expect(result.reasoning).toEqual({ enabled: true, effort: 'high' }); + expect(result.reasoning).toBeUndefined(); expect(result.max_tokens).toBe(1024); expect(result.temperature).toBe(0.2); expect(result.top_p).toBe(0.9); diff --git a/tests/unit/scripts/github/dev-release-workflow.test.ts b/tests/unit/scripts/github/dev-release-workflow.test.ts index 5f103afb..e0f4b2cc 100644 --- a/tests/unit/scripts/github/dev-release-workflow.test.ts +++ b/tests/unit/scripts/github/dev-release-workflow.test.ts @@ -24,6 +24,8 @@ describe('dev release workflow', () => { expect(workflow).toContain('name: Dev Release'); expect(workflow).toContain('branches: [dev]'); + expect(workflow.match(/runs-on: \[self-hosted, linux, x64\]/g)).toHaveLength(2); + expect(workflow).not.toContain('runs-on: ubuntu-latest'); expect(checkoutSection).toContain("token: ${{ secrets.PAT_TOKEN }}"); expect(checkoutSection).not.toContain('token: ${{ github.token }}'); expect(releaseSection).toContain('GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}'); @@ -31,4 +33,24 @@ describe('dev release workflow', () => { expect(releaseSection).not.toContain('GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}'); expect(releaseSection).not.toContain('GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}'); }); + + test('keeps release commits visible to promotion PR CI', () => { + const workflowPath = resolvePath('../../../../.github/workflows/dev-release.yml'); + const scriptPath = resolvePath('../../../../scripts/dev-release.sh'); + + const workflow = fs.readFileSync(workflowPath, 'utf8'); + const script = fs.readFileSync(scriptPath, 'utf8'); + + expect(workflow).toContain('should_release: ${{ steps.release-guard.outputs.should_release }}'); + expect(workflow).toContain('"chore(release): "*)'); + expect(workflow).toContain("needs.guard.outputs.should_release == 'true'"); + expect(workflow).not.toContain('github.event.head_commit.message'); + expect(workflow).not.toContain('!startsWith('); + expect(workflow).not.toContain("contains(github.event.head_commit.message, '[skip ci]')"); + expect(script).toContain('LEGACY_CURRENT_RELEASE_SUBJECT="${CURRENT_RELEASE_SUBJECT} [skip ci]"'); + expect(script).toContain('[[ "$HEAD_SUBJECT" == "$CURRENT_RELEASE_SUBJECT" ]]'); + expect(script).toContain('[[ "$HEAD_SUBJECT" == "$LEGACY_CURRENT_RELEASE_SUBJECT" ]]'); + expect(script).toContain('git commit -m "chore(release): ${VERSION}"'); + expect(script).not.toContain('git commit -m "chore(release): ${VERSION} [skip ci]"'); + }); }); diff --git a/tests/unit/targets/ccsd-alias-integration.test.ts b/tests/unit/targets/ccsd-alias-integration.test.ts index 2ce98781..52ba5c4b 100644 --- a/tests/unit/targets/ccsd-alias-integration.test.ts +++ b/tests/unit/targets/ccsd-alias-integration.test.ts @@ -89,17 +89,28 @@ describe('ccsd alias integration', () => { expect(path.basename(argvPath)).toBe('ccsx'); }); + it('should preserve ccsxp symlink basename in argv[1] under node', () => { + if (process.platform === 'win32') { + return; + } + + const argvPath = probeArgvPath('ccsxp'); + expect(path.basename(argvPath)).toBe('ccsxp'); + }); + it('should preserve extension-style alias basenames for wrapper compatibility', () => { const cmdArgvPath = probeArgvPathDirect('ccsd.cmd'); const ps1ArgvPath = probeArgvPathDirect('ccsd.ps1'); const explicitCmdArgvPath = probeArgvPathDirect('ccs-droid.cmd'); const codexCmdArgvPath = probeArgvPathDirect('ccs-codex.cmd'); const codexShortCmdArgvPath = probeArgvPathDirect('ccsx.cmd'); + const codexProxyCmdArgvPath = probeArgvPathDirect('ccsxp.cmd'); expect(path.basename(cmdArgvPath)).toBe('ccsd.cmd'); expect(path.basename(ps1ArgvPath)).toBe('ccsd.ps1'); expect(path.basename(explicitCmdArgvPath)).toBe('ccs-droid.cmd'); expect(path.basename(codexCmdArgvPath)).toBe('ccs-codex.cmd'); expect(path.basename(codexShortCmdArgvPath)).toBe('ccsx.cmd'); + expect(path.basename(codexProxyCmdArgvPath)).toBe('ccsxp.cmd'); }); }); diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index 9d79ebce..318219c6 100644 --- a/tests/unit/targets/codex-runtime-integration.test.ts +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -174,6 +174,7 @@ process.exit(0); CI: '1', NO_COLOR: '1', CCS_HOME: tmpHome, + CODEX_HOME: undefined, CCS_CODEX_PATH: fakeCodexPath, CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath, @@ -377,10 +378,7 @@ process.exit(0); expect(result.status).toBe(0); const calls = readLoggedCodexCalls(codexArgsLogPath); expect(calls[1]).toEqual( - expect.arrayContaining([ - 'mcp_servers.ccs_browser.enabled=true', - 'fix failing tests', - ]) + expect.arrayContaining(['mcp_servers.ccs_browser.enabled=true', 'fix failing tests']) ); } finally { if (originalCcsHome !== undefined) { @@ -523,28 +521,27 @@ process.exit(0); if (process.platform === 'win32') return; const freshCodexHome = path.join(tmpHome, 'fresh-codex-home'); - const result = runCcs(['default', '--target', 'codex', '--effort', 'high', 'fix failing tests'], { - ...process.env, - CI: '1', - NO_COLOR: '1', - CCS_HOME: tmpHome, - CCS_CODEX_PATH: fakeCodexPath, - CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, - CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath, - CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test', - CODEX_HOME: freshCodexHome, - }); + const result = runCcs( + ['default', '--target', 'codex', '--effort', 'high', 'fix failing tests'], + { + ...process.env, + CI: '1', + NO_COLOR: '1', + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath, + CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test', + CODEX_HOME: freshCodexHome, + } + ); expect(result.status).toBe(0); expect(fs.existsSync(freshCodexHome)).toBe(true); expect(fs.statSync(freshCodexHome).isDirectory()).toBe(true); expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ ['-c', 'model="gpt-5"', '--version'], - [ - '-c', - 'model_reasoning_effort="high"', - 'fix failing tests', - ], + ['-c', 'model_reasoning_effort="high"', 'fix failing tests'], ]); const loggedEnv = readLoggedCodexEnv(codexEnvLogPath); expect(loggedEnv).toHaveLength(2); @@ -570,13 +567,13 @@ process.exit(0); const result = runCcs( ['default', '--target', 'codex', '--effort', 'high', 'fix failing tests'], { - ...process.env, - CI: '1', - NO_COLOR: '1', - CCS_HOME: tmpHome, - CCS_CODEX_PATH: fakeCodexPath, - CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, - CODEX_HOME: invalidCodexHome, + ...process.env, + CI: '1', + NO_COLOR: '1', + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + CODEX_HOME: invalidCodexHome, } ); @@ -658,7 +655,135 @@ process.exit(0); expect(result.status).toBe(0); expect(result.stdout).toContain('codex-cli 9.9.9-test'); - expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([['--version']]); + expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ + ['--config', 'model_provider="cliproxy"', '--version'], + ]); + }); + + it('pins ccsxp Codex history to native default instead of inherited CODEX_HOME', () => { + if (process.platform === 'win32') return; + + const inheritedCodexHome = path.join(tmpHome, 'inherited-codex-home'); + const result = runCcsxpAlias(['--version'], { + ...process.env, + CI: '1', + NO_COLOR: '1', + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath, + CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test', + CODEX_HOME: inheritedCodexHome, + }); + + expect(result.status).toBe(0); + expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ + { + CODEX_HOME: path.join(os.homedir(), '.codex'), + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, + }, + ]); + }); + + it('routes default ccsxp launches through native Codex with the cliproxy provider override', () => { + if (process.platform === 'win32') return; + + const result = runCcsxpAlias(['fix failing tests'], { + ...process.env, + CI: '1', + NO_COLOR: '1', + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath, + }); + + expect(result.status).toBe(0); + expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ + ['--config', 'model_provider="cliproxy"', 'fix failing tests'], + ]); + expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ + { + CODEX_HOME: path.join(os.homedir(), '.codex'), + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, + }, + ]); + }); + + it('rejects conflicting native provider config overrides for ccsxp', () => { + if (process.platform === 'win32') return; + + const result = runCcsxpAlias(['--config', 'model_provider="openai"', '--version'], { + ...process.env, + CI: '1', + NO_COLOR: '1', + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('ccsxp does not allow'); + expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([]); + }); + + it('rejects native profile selection flags for ccsxp', () => { + if (process.platform === 'win32') return; + + const result = runCcsxpAlias(['--profile', 'other', '--version'], { + ...process.env, + CI: '1', + NO_COLOR: '1', + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('ccsxp does not allow'); + expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([]); + }); + + it('honors CCSXP_CODEX_HOME for intentionally separate ccsxp history', () => { + if (process.platform === 'win32') return; + + const explicitCodexHome = path.join(tmpHome, 'explicit-ccsxp-codex-home'); + const result = runCcsxpAlias(['--version'], { + ...process.env, + CI: '1', + NO_COLOR: '1', + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath, + CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test', + CODEX_HOME: path.join(tmpHome, 'inherited-codex-home'), + CCSXP_CODEX_HOME: explicitCodexHome, + }); + + expect(result.status).toBe(0); + expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ + { + CODEX_HOME: explicitCodexHome, + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, + }, + ]); }); it('fails with a clean CLI error when ccsxp receives a malformed --target flag', () => { @@ -740,11 +865,7 @@ process.exit(0); expect(result.status).toBe(0); expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ ['-c', 'model="gpt-5"', '--version'], - [ - '-c', - 'model_reasoning_effort="high"', - 'fix failing tests', - ], + ['-c', 'model_reasoning_effort="high"', 'fix failing tests'], ]); }); diff --git a/tests/unit/targets/settings-profile-browser-launch.test.ts b/tests/unit/targets/settings-profile-browser-launch.test.ts index e89e8855..73d04f8c 100644 --- a/tests/unit/targets/settings-profile-browser-launch.test.ts +++ b/tests/unit/targets/settings-profile-browser-launch.test.ts @@ -5,6 +5,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader'; +import { stopOpenAICompatProxy } from '../../../src/proxy/proxy-daemon'; const BROWSER_PROMPT_SNIPPET = 'prefer the CCS MCP Browser tool'; setDefaultTimeout(30000); @@ -30,6 +31,14 @@ function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult { }; } +function readLaunchedArgs(argsLogPath: string): string[] { + return fs + .readFileSync(argsLogPath, 'utf8') + .split('\n') + .map((arg) => arg.trim()) + .filter((arg) => arg.length > 0); +} + function reserveClosedPort(): number { const server = Bun.serve({ port: 0, @@ -83,6 +92,8 @@ describe('settings profile browser launch', () => { let fakeClaudePath = ''; let claudeArgsLogPath = ''; let claudeEnvLogPath = ''; + let claudeSettingsPathLogPath = ''; + let claudeSettingsSnapshotPath = ''; let browserProfileDir = ''; let devtoolsServer: ChildProcess | undefined; let baseEnv: NodeJS.ProcessEnv; @@ -98,6 +109,8 @@ describe('settings profile browser launch', () => { fakeClaudePath = path.join(tmpHome, 'fake-claude.sh'); claudeArgsLogPath = path.join(tmpHome, 'claude-args.txt'); claudeEnvLogPath = path.join(tmpHome, 'claude-env.txt'); + claudeSettingsPathLogPath = path.join(tmpHome, 'claude-settings-path.txt'); + claudeSettingsSnapshotPath = path.join(tmpHome, 'claude-settings-snapshot.json'); browserProfileDir = path.join(tmpHome, 'chrome-user-data'); fs.mkdirSync(ccsDir, { recursive: true }); @@ -129,6 +142,19 @@ describe('settings profile browser launch', () => { fakeClaudePath, `#!/bin/sh printf "%s\n" "$@" > "${claudeArgsLogPath}" +settings_path="" +prev="" +for arg in "$@"; do + if [ "$prev" = "--settings" ]; then + settings_path="$arg" + break + fi + prev="$arg" +done +printf "%s" "$settings_path" > "${claudeSettingsPathLogPath}" +if [ -n "$settings_path" ] && [ -f "$settings_path" ]; then + cat "$settings_path" > "${claudeSettingsSnapshotPath}" +fi { printf "userDataDir=%s\n" "$CCS_BROWSER_USER_DATA_DIR" printf "legacyProfileDir=%s\n" "$CCS_BROWSER_PROFILE_DIR" @@ -167,7 +193,7 @@ exit 0 }; }); - afterEach(() => { + afterEach(async () => { if (devtoolsServer) { devtoolsServer.kill(); devtoolsServer = undefined; @@ -176,6 +202,7 @@ exit 0 return; } + await stopOpenAICompatProxy(); fs.rmSync(tmpHome, { recursive: true, force: true }); }); @@ -257,6 +284,76 @@ exit 0 } }); + it('passes a sanitized settings copy for local OpenAI-compatible proxy launches', () => { + if (process.platform === 'win32') return; + + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'profile-token', + ANTHROPIC_API_KEY: 'profile-api-key', + ANTHROPIC_MODEL: 'gpt-5.4', + CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345', + }, + hooks: { + PreToolUse: [ + { + matcher: 'Read', + hooks: [{ type: 'command', command: 'echo keep-profile-settings' }], + }, + ], + }, + }, + null, + 2 + ) + '\n' + ); + + const result = runCcs(['glm', 'smoke'], baseEnv); + + expect(result.status).toBe(0); + + const launchedArgs = readLaunchedArgs(claudeArgsLogPath); + const settingsIndex = launchedArgs.indexOf('--settings'); + expect(settingsIndex).toBeGreaterThanOrEqual(0); + + const launchSettingsPath = launchedArgs[settingsIndex + 1]; + expect(launchSettingsPath).toBeDefined(); + expect(launchSettingsPath).not.toBe(settingsPath); + + const persistedLaunchSettings = JSON.parse( + fs.readFileSync(claudeSettingsSnapshotPath, 'utf8') + ) as { + env?: Record; + hooks?: { + PreToolUse?: Array<{ + matcher?: string; + hooks?: Array<{ command?: string }>; + }>; + }; + }; + + expect(persistedLaunchSettings.env?.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(persistedLaunchSettings.env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(persistedLaunchSettings.env?.ANTHROPIC_API_KEY).toBeUndefined(); + expect(persistedLaunchSettings.env?.ANTHROPIC_MODEL).toBe('gpt-5.4'); + expect( + persistedLaunchSettings.hooks?.PreToolUse?.[0]?.hooks?.[0]?.command + ).toBe('echo keep-profile-settings'); + + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).toContain('stripAnthropic='); + expect(launchedEnv).toContain('anthropicBaseUrl=http://127.0.0.1:'); + expect(launchedEnv).not.toContain('anthropicBaseUrl=https://api.openai.com/v1'); + expect(launchedEnv).toContain('anthropicModel=gpt-5.4'); + expect(launchedEnv).toContain('maxOutputTokens=12345'); + expect(fs.readFileSync(claudeSettingsPathLogPath, 'utf8')).toBe(launchSettingsPath); + expect(fs.existsSync(launchSettingsPath as string)).toBe(false); + }); + it('does not auto-enable browser reuse for settings-profile launches from env overrides alone', async () => { if (process.platform === 'win32') return; diff --git a/tests/unit/targets/target-resolver.test.ts b/tests/unit/targets/target-resolver.test.ts index d63c79d8..72bfbdaa 100644 --- a/tests/unit/targets/target-resolver.test.ts +++ b/tests/unit/targets/target-resolver.test.ts @@ -98,6 +98,11 @@ describe('resolveTargetType', () => { expect(resolveTargetType([])).toBe('codex'); }); + it('should detect built-in ccsxp argv[0] alias', () => { + process.argv = ['node', 'ccsxp']; + expect(resolveTargetType([])).toBe('codex'); + }); + it('should detect custom target aliases from CCS_TARGET_ALIASES', () => { process.env.CCS_TARGET_ALIASES = 'droid=droidx,my-droid'; process.argv = ['node', 'my-droid']; @@ -273,6 +278,9 @@ describe('resolveTargetType', () => { process.argv = ['node', 'ccsx']; expect(resolveTargetType([])).toBe('codex'); + process.argv = ['node', 'ccsxp']; + expect(resolveTargetType([])).toBe('codex'); + process.argv = ['node', 'mydroid']; expect(resolveTargetType([])).toBe('droid'); diff --git a/tests/unit/unified-config.test.ts b/tests/unit/unified-config.test.ts index 1fc660c5..b8566a28 100644 --- a/tests/unit/unified-config.test.ts +++ b/tests/unit/unified-config.test.ts @@ -285,6 +285,74 @@ describe('continuity-inheritance-config', () => { }); }); +describe('cliproxy session-affinity ttl normalization', () => { + it('normalizes invalid stored ttl values back to the safe default', () => { + const originalCcsHome = process.env.CCS_HOME; + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-affinity-ttl-home-')); + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'cliproxy:', + ' routing:', + ' strategy: round-robin', + ' session_affinity: true', + ' session_affinity_ttl: forever', + '', + ].join('\n') + ); + + process.env.CCS_HOME = tempHome; + try { + const config = loadOrCreateUnifiedConfig(); + expect(config.cliproxy.routing.session_affinity_ttl).toBe('1h'); + } finally { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('normalizes non-positive stored ttl values back to the safe default', () => { + const originalCcsHome = process.env.CCS_HOME; + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-affinity-zero-ttl-home-')); + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'cliproxy:', + ' routing:', + ' strategy: round-robin', + ' session_affinity: true', + ' session_affinity_ttl: 0s', + '', + ].join('\n') + ); + + process.env.CCS_HOME = tempHome; + try { + const config = loadOrCreateUnifiedConfig(); + expect(config.cliproxy.routing.session_affinity_ttl).toBe('1h'); + } finally { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); +}); + describe('official-channels-config', () => { it('keeps explicit channels.selected empty even when legacy discord_channels.enabled is true', () => { const originalCcsHome = process.env.CCS_HOME; diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index bb64e71d..165314b2 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -31,6 +31,7 @@ const STEERING_PROMPT_SNIPPET = 'prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches'; const spawnCalls: SpawnCall[] = []; const spawnSyncCalls: SpawnSyncCall[] = []; +const launchSettingsSnapshots: Array<{ path: string; content: string }> = []; const originalPlatform = process.platform; let baselineSigintListeners: Array<(...args: unknown[]) => void> = []; let baselineSigtermListeners: Array<(...args: unknown[]) => void> = []; @@ -99,6 +100,16 @@ function registerChildProcessMock(): void { } spawnCalls.push({ command, args, options }); + const settingsIndex = args.indexOf('--settings'); + if (settingsIndex >= 0) { + const settingsPath = args[settingsIndex + 1]; + if (settingsPath && fs.existsSync(settingsPath)) { + launchSettingsSnapshots.push({ + path: settingsPath, + content: fs.readFileSync(settingsPath, 'utf8'), + }); + } + } const child = createMockChild(); setTimeout(() => child.emit('close', 0), 0); @@ -193,6 +204,7 @@ describe('CLAUDECODE environment stripping', () => { beforeEach(() => { spawnCalls.length = 0; spawnSyncCalls.length = 0; + launchSettingsSnapshots.length = 0; process.env.CCS_QUIET = '1'; // Save original env values for restoration in afterEach @@ -651,6 +663,14 @@ describe('CLAUDECODE environment stripping', () => { ANTHROPIC_MODEL: 'gpt-5.4', CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345', }, + hooks: { + PreToolUse: [ + { + matcher: 'Read', + hooks: [{ type: 'command', command: 'echo headless-bridge-hook' }], + }, + ], + }, }, null, 2 @@ -679,6 +699,37 @@ describe('CLAUDECODE environment stripping', () => { expect(env.ANTHROPIC_AUTH_TOKEN).not.toBe('parent-routing-token'); expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4'); expect(env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBe('12345'); + + const args = spawnCalls[0].args; + const settingsIndex = args.indexOf('--settings'); + expect(settingsIndex).toBeGreaterThanOrEqual(0); + + const launchSettingsPath = args[settingsIndex + 1]; + expect(launchSettingsPath).toBeDefined(); + expect(launchSettingsPath).not.toBe(path.join(ccsDir, 'bridge.settings.json')); + const launchSettingsSnapshot = launchSettingsSnapshots.find( + (snapshot) => snapshot.path === launchSettingsPath + ); + expect(launchSettingsSnapshot).toBeDefined(); + + const persistedLaunchSettings = JSON.parse(launchSettingsSnapshot?.content || '{}') as { + env?: Record; + hooks?: { + PreToolUse?: Array<{ + matcher?: string; + hooks?: Array<{ command?: string }>; + }>; + }; + }; + + expect(persistedLaunchSettings.env?.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(persistedLaunchSettings.env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(persistedLaunchSettings.env?.ANTHROPIC_API_KEY).toBeUndefined(); + expect(persistedLaunchSettings.env?.ANTHROPIC_MODEL).toBe('gpt-5.4'); + expect( + persistedLaunchSettings.hooks?.PreToolUse?.[0]?.hooks?.[0]?.command + ).toBe('echo headless-bridge-hook'); + expect(fs.existsSync(launchSettingsPath)).toBe(false); }); it('headless executor prepares image-analysis MCP and suppresses the legacy hook on healthy launches', async () => { diff --git a/tests/unit/web-server/cliproxy-routing-routes.test.ts b/tests/unit/web-server/cliproxy-routing-routes.test.ts index ffaa71f6..a3dd9bd6 100644 --- a/tests/unit/web-server/cliproxy-routing-routes.test.ts +++ b/tests/unit/web-server/cliproxy-routing-routes.test.ts @@ -7,6 +7,8 @@ describe('cliproxy routing routes', () => { let baseUrl = ''; let readStateMock: ReturnType; let applyStrategyMock: ReturnType; + let readAffinityStateMock: ReturnType; + let applyAffinityMock: ReturnType; beforeEach(async () => { readStateMock = mock(async () => ({ @@ -22,16 +24,43 @@ describe('cliproxy routing routes', () => { reachable: true, applied: 'live-and-config', })); + readAffinityStateMock = mock(async () => ({ + enabled: true, + ttl: '1h', + source: 'config', + target: 'local', + reachable: true, + manageable: true, + })); + applyAffinityMock = mock(async () => ({ + enabled: false, + ttl: '30m', + source: 'config', + target: 'local', + reachable: true, + manageable: true, + applied: 'config-only', + })); mock.module('../../../src/cliproxy/routing-strategy', () => ({ readCliproxyRoutingState: readStateMock, applyCliproxyRoutingStrategy: applyStrategyMock, + readCliproxySessionAffinityState: readAffinityStateMock, + applyCliproxySessionAffinitySettings: applyAffinityMock, normalizeCliproxyRoutingStrategy: (value: unknown) => { if (value === 'round-robin' || value === 'fill-first') { return value; } return null; }, + normalizeCliproxySessionAffinityEnabled: (value: unknown) => { + if (value === true || value === false) return value; + return null; + }, + normalizeCliproxySessionAffinityTtl: (value: unknown) => { + if (value === '30m' || value === '1h') return value; + return null; + }, })); const { default: routingRoutes } = await import( @@ -103,4 +132,52 @@ describe('cliproxy routing routes', () => { applied: 'live-and-config', }); }); + + it('returns the current session-affinity state', async () => { + const response = await fetch(`${baseUrl}/api/cliproxy/routing/session-affinity`); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + enabled: true, + ttl: '1h', + source: 'config', + target: 'local', + reachable: true, + manageable: true, + }); + expect(readAffinityStateMock).toHaveBeenCalledTimes(1); + }); + + it('rejects invalid session-affinity payloads', async () => { + const response = await fetch(`${baseUrl}/api/cliproxy/routing/session-affinity`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: 'auto', ttl: 'forever' }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'Invalid session affinity payload. Use enabled=true|false and ttl like 30m or 1h.', + }); + expect(applyAffinityMock).not.toHaveBeenCalled(); + }); + + it('applies valid session-affinity settings', async () => { + const response = await fetch(`${baseUrl}/api/cliproxy/routing/session-affinity`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: false, ttl: '30m' }), + }); + + expect(response.status).toBe(200); + expect(applyAffinityMock).toHaveBeenCalledWith({ enabled: false, ttl: '30m' }); + expect(await response.json()).toEqual({ + enabled: false, + ttl: '30m', + source: 'config', + target: 'local', + reachable: true, + manageable: true, + applied: 'config-only', + }); + }); }); diff --git a/tests/unit/web-server/cliproxy-usage-syncer.test.ts b/tests/unit/web-server/cliproxy-usage-syncer.test.ts index 699bcd25..54b192ce 100644 --- a/tests/unit/web-server/cliproxy-usage-syncer.test.ts +++ b/tests/unit/web-server/cliproxy-usage-syncer.test.ts @@ -20,6 +20,48 @@ function fetchRawResponse(): Promise { return Promise.resolve(rawResponse); } +function buildResponse(inputTokens: number, timestamp = '2026-03-02T12:00:00.000Z'): CliproxyUsageApiResponse { + return { + usage: { + apis: { + gemini: { + models: { + 'gemini-2.5-pro': { + details: [ + { + timestamp, + source: 'account-a', + auth_index: 0, + tokens: { + input_tokens: inputTokens, + output_tokens: 20, + reasoning_tokens: 0, + cached_tokens: 10, + total_tokens: inputTokens + 30, + }, + failed: false, + }, + ], + }, + }, + }, + }, + }, + }; +} + +function createDeferredFetch(response: CliproxyUsageApiResponse | null) { + let resolveFetch: ((value: CliproxyUsageApiResponse | null) => void) | undefined; + return { + fetch: () => + new Promise((resolve) => { + fetchCalls++; + resolveFetch = resolve; + }), + resolve: () => resolveFetch?.(response), + }; +} + beforeEach(() => { ccsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-syncer-')); fetchCalls = 0; @@ -92,4 +134,182 @@ describe('cliproxy usage syncer', () => { stopCliproxySync(); intervalSpy.mockRestore(); }); + + it('keeps stale cached snapshots available for historical analytics reads', async () => { + await runWithScopedConfigDir(ccsDir, async () => { + await syncCliproxyUsage(fetchRawResponse); + + const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json'); + const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8')) as { + timestamp: number; + }; + + snapshot.timestamp = Date.now() - 8 * 24 * 60 * 60 * 1000; + fs.writeFileSync(snapshotPath, JSON.stringify(snapshot), 'utf-8'); + + const cached = await loadCachedCliproxyData(); + expect(cached.daily).toHaveLength(1); + expect(cached.hourly).toHaveLength(1); + expect(cached.monthly).toHaveLength(1); + }); + }); + + it('merges unique history from overlapping syncs even when the older sync finishes last', async () => { + const olderSync = createDeferredFetch(buildResponse(100, '2026-03-01T10:00:00.000Z')); + const newerSync = createDeferredFetch(buildResponse(200, '2026-03-02T11:00:00.000Z')); + + await runWithScopedConfigDir(ccsDir, async () => { + const olderWrite = syncCliproxyUsage(olderSync.fetch); + const newerWrite = syncCliproxyUsage(newerSync.fetch); + + newerSync.resolve(); + await Promise.resolve(); + olderSync.resolve(); + + await Promise.all([olderWrite, newerWrite]); + + const cached = await loadCachedCliproxyData(); + expect(cached.daily).toHaveLength(2); + expect(cached.daily.find((entry) => entry.date === '2026-03-01')?.inputTokens).toBe(100); + expect(cached.daily[0].inputTokens).toBe(200); + }); + }); + + it('migrates legacy v1 and v2 snapshots forward before merging new history', async () => { + for (const version of [1, 2]) { + await runWithScopedConfigDir(ccsDir, async () => { + const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json'); + fs.mkdirSync(path.dirname(snapshotPath), { recursive: true }); + fs.writeFileSync( + snapshotPath, + JSON.stringify({ + version, + timestamp: Date.now() - 60_000, + daily: [ + { + date: '2026-03-01', + source: 'cliproxy', + inputTokens: 100, + outputTokens: 20, + cacheCreationTokens: 0, + cacheReadTokens: 10, + cost: 0.2, + totalCost: 0.2, + modelsUsed: ['gemini-2.5-pro'], + modelBreakdowns: [ + { + modelName: 'gemini-2.5-pro', + inputTokens: 100, + outputTokens: 20, + cacheCreationTokens: 0, + cacheReadTokens: 10, + cost: 0.2, + }, + ], + }, + ], + hourly: [ + { + hour: '2026-03-01 12:00', + source: 'cliproxy', + requestCount: 7, + inputTokens: 100, + outputTokens: 20, + cacheCreationTokens: 0, + cacheReadTokens: 10, + cost: 0.2, + totalCost: 0.2, + modelsUsed: ['gemini-2.5-pro'], + modelBreakdowns: [ + { + modelName: 'gemini-2.5-pro', + inputTokens: 100, + outputTokens: 20, + cacheCreationTokens: 0, + cacheReadTokens: 10, + cost: 0.2, + }, + ], + }, + ], + monthly: [ + { + month: '2026-03', + source: 'cliproxy', + inputTokens: 100, + outputTokens: 20, + cacheCreationTokens: 0, + cacheReadTokens: 10, + totalCost: 0.2, + modelsUsed: ['gemini-2.5-pro'], + modelBreakdowns: [ + { + modelName: 'gemini-2.5-pro', + inputTokens: 100, + outputTokens: 20, + cacheCreationTokens: 0, + cacheReadTokens: 10, + cost: 0.2, + }, + ], + }, + ], + }), + 'utf-8' + ); + + await syncCliproxyUsage(() => + Promise.resolve(buildResponse(200, '2026-03-02T12:00:00.000Z')) + ); + + const cached = await loadCachedCliproxyData(); + expect(cached.daily.map((entry) => entry.date)).toEqual(['2026-03-02', '2026-03-01']); + expect(cached.hourly.find((entry) => entry.hour === '2026-03-01 12:00')?.requestCount).toBe(7); + }); + } + }); + + it('prunes history details older than the configured retention window', async () => { + await runWithScopedConfigDir(ccsDir, async () => { + await syncCliproxyUsage(() => + Promise.resolve(buildResponse(100, '2024-01-01T12:00:00.000Z')) + ); + await syncCliproxyUsage(() => + Promise.resolve(buildResponse(200, new Date().toISOString())) + ); + + const cached = await loadCachedCliproxyData(); + expect(cached.daily.some((entry) => entry.date === '2024-01-01')).toBe(false); + }); + }); + + it('preserves prior-day history when a later sync only returns the current window', async () => { + await runWithScopedConfigDir(ccsDir, async () => { + await syncCliproxyUsage(() => + Promise.resolve(buildResponse(100, '2026-03-01T12:00:00.000Z')) + ); + await syncCliproxyUsage(() => + Promise.resolve(buildResponse(200, '2026-03-02T12:00:00.000Z')) + ); + + const cached = await loadCachedCliproxyData(); + expect(cached.daily).toHaveLength(2); + expect(cached.daily.map((entry) => entry.date)).toEqual(['2026-03-02', '2026-03-01']); + expect(cached.daily.find((entry) => entry.date === '2026-03-01')?.inputTokens).toBe(100); + expect(cached.daily.find((entry) => entry.date === '2026-03-02')?.inputTokens).toBe(200); + }); + }); + + it('does not double count when the same snapshot window is synced twice', async () => { + await runWithScopedConfigDir(ccsDir, async () => { + const repeatedResponse = buildResponse(250, '2026-03-02T12:00:00.000Z'); + await syncCliproxyUsage(() => Promise.resolve(repeatedResponse)); + await syncCliproxyUsage(() => Promise.resolve(repeatedResponse)); + + const cached = await loadCachedCliproxyData(); + expect(cached.daily).toHaveLength(1); + expect(cached.daily[0].inputTokens).toBe(250); + expect(cached.hourly[0].requestCount).toBe(1); + }); + }); }); diff --git a/tests/unit/web-server/cliproxy-usage-transformer.test.ts b/tests/unit/web-server/cliproxy-usage-transformer.test.ts index ac2e6eec..5c937023 100644 --- a/tests/unit/web-server/cliproxy-usage-transformer.test.ts +++ b/tests/unit/web-server/cliproxy-usage-transformer.test.ts @@ -1,7 +1,16 @@ -import { describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import type { CliproxyUsageApiResponse } from '../../../src/cliproxy/stats-fetcher'; import { - flattenCliproxyDetails, + clearModelsDevRegistryCache, + setCachedModelsDevRegistry, +} from '../../../src/web-server/models-dev/registry-cache'; +import { + buildCliproxyUsageHistoryAggregates, + extractCliproxyUsageHistoryDetails, + mergeCliproxyUsageHistoryDetails, transformCliproxyToDailyUsage, transformCliproxyToHourlyUsage, transformCliproxyToMonthlyUsage, @@ -40,6 +49,19 @@ const sampleResponse: CliproxyUsageApiResponse = { }, failed: true, }, + { + timestamp: '2026-03-01T12:15:00.000Z', + source: 'account-a', + auth_index: 0, + tokens: { + input_tokens: 0, + output_tokens: 0, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 0, + }, + failed: true, + }, { timestamp: '2026-03-01T10:45:00.000Z', source: 'account-a', @@ -83,10 +105,58 @@ const sampleResponse: CliproxyUsageApiResponse = { }; describe('cliproxy usage transformer', () => { - it('flattens nested API details and skips failed requests', () => { - const flat = flattenCliproxyDetails(sampleResponse); - expect(flat).toHaveLength(3); - expect(flat.every((entry) => entry.detail.failed === false)).toBe(true); + it('retains failed requests when they carry usage and skips zero-usage failures', () => { + const flat = extractCliproxyUsageHistoryDetails(sampleResponse); + expect(flat).toHaveLength(4); + expect(flat[0].provider).toBe('google'); + expect( + flat.some( + (entry) => + entry.failed === true && + entry.inputTokens === 40 && + entry.outputTokens === 10 + ) + ).toBe(true); + expect( + flat.some( + (entry) => + entry.failed === true && + entry.inputTokens === 0 && + entry.outputTokens === 0 + ) + ).toBe(false); + }); + + it('deduplicates repeated snapshot details when merging history', () => { + const details = extractCliproxyUsageHistoryDetails(sampleResponse); + const merged = mergeCliproxyUsageHistoryDetails(details, details); + + expect(merged).toHaveLength(details.length); + }); + + it('preserves legitimate duplicate requests when the incoming batch has more occurrences', () => { + const details = extractCliproxyUsageHistoryDetails(sampleResponse); + const repeated = [details[0], { ...details[0] }]; + const merged = mergeCliproxyUsageHistoryDetails([details[0]], repeated); + + expect(merged).toHaveLength(2); + }); + + it('uses persisted cost from history instead of recomputing from current pricing', () => { + const details = extractCliproxyUsageHistoryDetails(sampleResponse); + const seeded = details.map((detail) => ({ ...detail, cost: 999 })); + const { daily } = buildCliproxyUsageHistoryAggregates(seeded); + + expect(daily[0].modelBreakdowns[0]?.cost).toBe(999); + }); + + it('rebuilds daily history aggregates from merged detail history', () => { + const details = extractCliproxyUsageHistoryDetails(sampleResponse); + const { daily } = buildCliproxyUsageHistoryAggregates(details); + + expect(daily).toHaveLength(2); + expect(daily[0].date).toBe('2026-03-02'); + expect(daily[1].date).toBe('2026-03-01'); }); it('transforms daily usage with aggregated model totals', () => { @@ -98,21 +168,25 @@ describe('cliproxy usage transformer', () => { expect(daily[1].date).toBe('2026-03-01'); const marchFirst = daily.find((d) => d.date === '2026-03-01'); - expect(marchFirst?.inputTokens).toBe(130); - expect(marchFirst?.outputTokens).toBe(70); - expect(marchFirst?.cacheReadTokens).toBe(30); + expect(marchFirst?.inputTokens).toBe(170); + expect(marchFirst?.outputTokens).toBe(80); + expect(marchFirst?.cacheReadTokens).toBe(35); expect(marchFirst?.modelsUsed).toContain('gemini-2.5-pro'); }); it('transforms hourly usage with hour buckets', () => { const hourly = transformCliproxyToHourlyUsage(sampleResponse); - expect(hourly).toHaveLength(2); + expect(hourly).toHaveLength(3); expect(hourly[0].hour).toBe('2026-03-02 01:00'); const tenAm = hourly.find((h) => h.hour === '2026-03-01 10:00'); expect(tenAm?.inputTokens).toBe(130); expect(tenAm?.outputTokens).toBe(70); + + const elevenAm = hourly.find((h) => h.hour === '2026-03-01 11:00'); + expect(elevenAm?.inputTokens).toBe(40); + expect(elevenAm?.outputTokens).toBe(10); }); it('transforms monthly usage with cliproxy source', () => { @@ -121,8 +195,172 @@ describe('cliproxy usage transformer', () => { expect(monthly).toHaveLength(1); expect(monthly[0].month).toBe('2026-03'); expect(monthly[0].source).toBe('cliproxy'); - expect(monthly[0].inputTokens).toBe(200); - expect(monthly[0].outputTokens).toBe(100); - expect(monthly[0].cacheReadTokens).toBe(30); + expect(monthly[0].inputTokens).toBe(240); + expect(monthly[0].outputTokens).toBe(110); + expect(monthly[0].cacheReadTokens).toBe(35); + }); + + describe('provider-aware pricing', () => { + let tempRoot = ''; + let originalCcsHome: string | undefined; + let originalCcsDir: string | undefined; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-models-dev-')); + originalCcsHome = process.env.CCS_HOME; + originalCcsDir = process.env.CCS_DIR; + process.env.CCS_HOME = tempRoot; + delete process.env.CCS_DIR; + setCachedModelsDevRegistry({ + openai: { + id: 'openai', + models: { + 'gpt-5.5': { id: 'gpt-5.5', cost: { input: 5, output: 30, cache_read: 0.5 } }, + }, + }, + 'github-copilot': { + id: 'github-copilot', + models: { + 'gpt-5.5': { id: 'gpt-5.5', cost: { input: 0, output: 0 } }, + }, + }, + }); + }); + + afterEach(() => { + clearModelsDevRegistryCache(); + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir; + else delete process.env.CCS_DIR; + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + it('keeps same model IDs separated by provider in CLIProxy usage', () => { + const response: CliproxyUsageApiResponse = { + usage: { + apis: { + openai: { + models: { + 'gpt-5.5': { + details: [ + { + timestamp: '2026-03-03T10:00:00.000Z', + source: 'api-account', + auth_index: 0, + tokens: { + input_tokens: 1_000_000, + output_tokens: 1_000_000, + reasoning_tokens: 0, + cached_tokens: 1_000_000, + total_tokens: 3_000_000, + }, + failed: false, + }, + ], + }, + }, + }, + 'github-copilot': { + models: { + 'gpt-5.5': { + details: [ + { + timestamp: '2026-03-03T11:00:00.000Z', + source: 'copilot-account', + auth_index: 1, + tokens: { + input_tokens: 1_000_000, + output_tokens: 1_000_000, + reasoning_tokens: 0, + cached_tokens: 1_000_000, + total_tokens: 3_000_000, + }, + failed: false, + }, + ], + }, + }, + }, + }, + }, + }; + + const [daily] = transformCliproxyToDailyUsage(response); + const paid = daily.modelBreakdowns.find((breakdown) => breakdown.provider === 'openai'); + const subscription = daily.modelBreakdowns.find( + (breakdown) => breakdown.provider === 'github-copilot' + ); + + expect(daily.totalCost).toBe(35.5); + expect(paid?.cost).toBe(35.5); + expect(subscription?.cost).toBe(0); + }); + + it('canonicalizes CLIProxy provider aliases before grouping history details', () => { + const response: CliproxyUsageApiResponse = { + usage: { + apis: { + ghcp: { + models: { + 'gpt-5.5': { + details: [ + { + timestamp: '2026-03-03T10:00:00.000Z', + source: 'copilot-alias', + auth_index: 0, + tokens: { + input_tokens: 1_000_000, + output_tokens: 0, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 1_000_000, + }, + failed: false, + }, + ], + }, + }, + }, + 'github-copilot': { + models: { + 'gpt-5.5': { + details: [ + { + timestamp: '2026-03-03T11:00:00.000Z', + source: 'copilot-canonical', + auth_index: 1, + tokens: { + input_tokens: 2_000_000, + output_tokens: 0, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 2_000_000, + }, + failed: false, + }, + ], + }, + }, + }, + }, + }, + }; + + const details = extractCliproxyUsageHistoryDetails(response); + expect(details.map((detail) => detail.provider)).toEqual([ + 'github-copilot', + 'github-copilot', + ]); + + const [daily] = transformCliproxyToDailyUsage(response); + expect(daily.modelBreakdowns).toHaveLength(1); + expect(daily.modelBreakdowns[0]).toMatchObject({ + modelName: 'gpt-5.5', + provider: 'github-copilot', + inputTokens: 3_000_000, + }); + expect(daily.modelsUsed).toEqual(['gpt-5.5']); + }); }); }); diff --git a/tests/unit/web-server/codex-native-usage-collector.test.ts b/tests/unit/web-server/codex-native-usage-collector.test.ts new file mode 100644 index 00000000..aa32db7d --- /dev/null +++ b/tests/unit/web-server/codex-native-usage-collector.test.ts @@ -0,0 +1,187 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { scanCodexNativeUsageEntries } from '../../../src/web-server/usage/codex-native-usage-collector'; + +function writeCodexRollout( + baseDir: string, + options: { + sessionId?: string; + modelProvider?: string; + model?: string; + cwd?: string; + } = {} +): void { + const sessionId = options.sessionId ?? 'codex-session-1'; + const rolloutDir = path.join(baseDir, 'sessions', '2026', '03', '02'); + const rolloutPath = path.join(rolloutDir, `rollout-${sessionId}.jsonl`); + + const lines = [ + JSON.stringify({ + timestamp: '2026-03-02T10:00:00.000Z', + type: 'session_meta', + payload: { + id: sessionId, + timestamp: '2026-03-02T10:00:00.000Z', + cwd: options.cwd ?? '/tmp/codex-project', + cli_version: '0.126.0', + source: 'cli', + model_provider: options.modelProvider ?? 'openai', + }, + }), + JSON.stringify({ + timestamp: '2026-03-02T10:00:01.000Z', + type: 'turn_context', + payload: { + turn_id: 'turn-1', + cwd: options.cwd ?? '/tmp/codex-project', + model: options.model ?? 'gpt-5', + effort: 'high', + }, + }), + JSON.stringify({ + timestamp: '2026-03-02T10:00:02.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: null, + }, + }), + JSON.stringify({ + timestamp: '2026-03-02T10:05:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 100, + cached_input_tokens: 20, + output_tokens: 5, + reasoning_output_tokens: 2, + total_tokens: 127, + }, + last_token_usage: { + input_tokens: 100, + cached_input_tokens: 20, + output_tokens: 5, + reasoning_output_tokens: 2, + total_tokens: 127, + }, + model_context_window: 200000, + }, + }, + }), + JSON.stringify({ + timestamp: '2026-03-02T10:05:30.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 100, + cached_input_tokens: 20, + output_tokens: 5, + reasoning_output_tokens: 2, + total_tokens: 127, + }, + last_token_usage: { + input_tokens: 100, + cached_input_tokens: 20, + output_tokens: 5, + reasoning_output_tokens: 2, + total_tokens: 127, + }, + model_context_window: 200000, + }, + }, + }), + JSON.stringify({ + timestamp: '2026-03-02T10:10:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 150, + cached_input_tokens: 30, + output_tokens: 10, + reasoning_output_tokens: 3, + total_tokens: 193, + }, + last_token_usage: { + input_tokens: 50, + cached_input_tokens: 10, + output_tokens: 5, + reasoning_output_tokens: 1, + total_tokens: 66, + }, + model_context_window: 200000, + }, + }, + }), + ]; + + fs.mkdirSync(rolloutDir, { recursive: true }); + fs.writeFileSync(rolloutPath, `${lines.join('\n')}\n`, 'utf8'); +} + +describe('codex native usage collector', () => { + let tempRoot = ''; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-native-')); + }); + + afterEach(() => { + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + it('parses token_count events into raw usage entries and suppresses duplicates', async () => { + writeCodexRollout(tempRoot); + + const entries = await scanCodexNativeUsageEntries({ + env: { CODEX_HOME: tempRoot }, + homeDir: tempRoot, + }); + + expect(entries).toHaveLength(2); + expect(entries[0]).toMatchObject({ + sessionId: 'codex-session-1', + projectPath: '/tmp/codex-project', + model: 'gpt-5', + version: '0.126.0', + target: 'codex', + inputTokens: 100, + cacheReadTokens: 20, + outputTokens: 7, + }); + expect(entries[1]).toMatchObject({ + inputTokens: 50, + cacheReadTokens: 10, + outputTokens: 6, + }); + }); + + it('skips cliproxy-backed codex sessions by default to avoid double counting', async () => { + writeCodexRollout(tempRoot, { modelProvider: 'cliproxy' }); + + const entries = await scanCodexNativeUsageEntries({ + env: { CODEX_HOME: tempRoot }, + homeDir: tempRoot, + }); + + expect(entries).toHaveLength(0); + }); + + it('also skips ccs_runtime-backed codex bridge sessions by default', async () => { + writeCodexRollout(tempRoot, { modelProvider: 'ccs_runtime' }); + + const entries = await scanCodexNativeUsageEntries({ + env: { CODEX_HOME: tempRoot }, + homeDir: tempRoot, + }); + + expect(entries).toHaveLength(0); + }); +}); diff --git a/tests/unit/web-server/droid-native-usage-collector.test.ts b/tests/unit/web-server/droid-native-usage-collector.test.ts new file mode 100644 index 00000000..21f31680 --- /dev/null +++ b/tests/unit/web-server/droid-native-usage-collector.test.ts @@ -0,0 +1,184 @@ +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + scanDroidNativeUsageEntries, + type DroidSqliteQuery, +} from '../../../src/web-server/usage/droid-native-usage-collector'; + +function writeDroidSessionFixture( + tempRoot: string, + options: { + sessionId?: string; + selector?: string; + cwd?: string; + } = {} +): string { + const sessionId = options.sessionId ?? 'droid-session-1'; + const sessionDir = path.join(tempRoot, '.factory', 'sessions', 'demo-project'); + fs.mkdirSync(sessionDir, { recursive: true }); + + fs.writeFileSync( + path.join(sessionDir, `${sessionId}.jsonl`), + `${JSON.stringify({ + type: 'session_start', + id: sessionId, + version: 2, + cwd: options.cwd ?? '/tmp/droid-project', + })}\n`, + 'utf8' + ); + + fs.writeFileSync( + path.join(sessionDir, `${sessionId}.settings.json`), + JSON.stringify({ + model: options.selector ?? 'custom:Demo-0', + providerLock: 'openai', + }), + 'utf8' + ); + + return sessionId; +} + +function writeDroidGlobalSettings(tempRoot: string): void { + const factoryDir = path.join(tempRoot, '.factory'); + fs.mkdirSync(factoryDir, { recursive: true }); + fs.writeFileSync( + path.join(factoryDir, 'settings.json'), + JSON.stringify({ + customModels: [ + { + displayName: 'Demo', + model: 'gpt-4.1', + provider: 'openai', + baseUrl: 'https://api.openai.com/v1', + }, + { + displayName: 'CCS codex', + model: 'gpt-5', + provider: 'openai', + baseUrl: 'https://api.openai.com/v1', + }, + ], + }), + 'utf8' + ); + fs.writeFileSync(path.join(factoryDir, 'costs.db'), '', 'utf8'); +} + +describe('droid native usage collector', () => { + let tempRoot = ''; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-native-')); + }); + + afterEach(() => { + mock.restore(); + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + it('hydrates cost rows with session metadata and resolved custom model IDs', async () => { + writeDroidGlobalSettings(tempRoot); + const sessionId = writeDroidSessionFixture(tempRoot); + + const querySqliteJson: DroidSqliteQuery = async () => [ + { + session_id: sessionId, + timestamp: '2026-03-02T10:00:00.000Z', + input_tokens: 120, + output_tokens: 30, + }, + ]; + + const entries = await scanDroidNativeUsageEntries({ + env: { CCS_HOME: tempRoot }, + homeDir: tempRoot, + querySqliteJson, + }); + + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + sessionId, + projectPath: '/tmp/droid-project', + model: 'gpt-4.1', + inputTokens: 120, + outputTokens: 30, + target: 'droid', + }); + }); + + it('skips CCS-managed selectors to avoid double counting CLIProxy-backed Droid runs', async () => { + writeDroidGlobalSettings(tempRoot); + const sessionId = writeDroidSessionFixture(tempRoot, { selector: 'custom:CCS-codex-1' }); + + const querySqliteJson: DroidSqliteQuery = async () => [ + { + session_id: sessionId, + timestamp: '2026-03-02T10:00:00.000Z', + input_tokens: 120, + output_tokens: 30, + }, + ]; + + const entries = await scanDroidNativeUsageEntries({ + env: { CCS_HOME: tempRoot }, + homeDir: tempRoot, + querySqliteJson, + }); + + expect(entries).toHaveLength(0); + }); + + it('surfaces sqlite failures instead of flattening them into an empty result', async () => { + writeDroidGlobalSettings(tempRoot); + writeDroidSessionFixture(tempRoot); + + const querySqliteJson: DroidSqliteQuery = async () => { + throw new Error('sqlite blew up'); + }; + + await expect( + scanDroidNativeUsageEntries({ + env: { CCS_HOME: tempRoot }, + homeDir: tempRoot, + querySqliteJson, + }) + ).rejects.toThrow('sqlite blew up'); + }); + + it('warns when cost rows are skipped because local session metadata is missing', async () => { + writeDroidGlobalSettings(tempRoot); + const sessionId = writeDroidSessionFixture(tempRoot); + const consoleSpy = spyOn(console, 'log').mockImplementation(() => {}); + + const querySqliteJson: DroidSqliteQuery = async () => [ + { + session_id: 'missing-session', + timestamp: '2026-03-02T09:00:00.000Z', + input_tokens: 50, + output_tokens: 10, + }, + { + session_id: sessionId, + timestamp: '2026-03-02T10:00:00.000Z', + input_tokens: 120, + output_tokens: 30, + }, + ]; + + const entries = await scanDroidNativeUsageEntries({ + env: { CCS_HOME: tempRoot }, + homeDir: tempRoot, + querySqliteJson, + }); + + expect(entries).toHaveLength(1); + expect(entries[0]?.sessionId).toBe(sessionId); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Skipped 1 Droid native cost row(s) without local session metadata') + ); + }); +}); diff --git a/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts b/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts index 3b5cd6cf..fb6ab03f 100644 --- a/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts +++ b/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts @@ -6,9 +6,12 @@ import * as path from 'path'; let tempRoot = ''; let ccsDir = ''; let claudeDir = ''; +let codexDir = ''; let aggregator: typeof import('../../../src/web-server/usage/aggregator'); let originalCcsDir: string | undefined; let originalClaudeConfigDir: string | undefined; +let originalCcsHome: string | undefined; +let originalCodexHome: string | undefined; function writeClaudeJsonlFixture(): void { const projectDir = path.join(claudeDir, 'projects', 'project-one'); @@ -37,8 +40,20 @@ function writeCliproxySnapshotFixture(): void { fs.mkdirSync(snapshotDir, { recursive: true }); const snapshot = { - version: 1, + version: 3, timestamp: Date.now(), + details: [ + { + model: 'gemini-2.5-pro', + timestamp: '2026-03-02T10:00:00.000Z', + source: 'account-a', + authIndex: '0', + inputTokens: 50, + outputTokens: 10, + cacheReadTokens: 5, + failed: false, + }, + ], daily: [ { date: '2026-03-02', @@ -140,11 +155,16 @@ beforeEach(async () => { tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-usage-agg-')); ccsDir = path.join(tempRoot, '.ccs'); claudeDir = path.join(tempRoot, '.claude'); + codexDir = path.join(tempRoot, '.codex'); originalCcsDir = process.env.CCS_DIR; originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + originalCcsHome = process.env.CCS_HOME; + originalCodexHome = process.env.CODEX_HOME; process.env.CCS_DIR = ccsDir; + process.env.CCS_HOME = tempRoot; process.env.CLAUDE_CONFIG_DIR = claudeDir; + process.env.CODEX_HOME = codexDir; writeUnifiedConfigFixture(); writeClaudeJsonlFixture(); @@ -170,6 +190,18 @@ afterEach(() => { delete process.env.CLAUDE_CONFIG_DIR; } + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (originalCodexHome !== undefined) { + process.env.CODEX_HOME = originalCodexHome; + } else { + delete process.env.CODEX_HOME; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); }); @@ -193,4 +225,292 @@ describe('usage aggregator cliproxy integration', () => { aggregator.clearUsageCache(); expect(aggregator.getLastFetchTimestamp()).toBeNull(); }); + + it('avoids double counting when CLAUDE_CONFIG_DIR points at a CCS instance', async () => { + const instancePath = path.join(ccsDir, 'instances', 'work-profile'); + const instanceProjectDir = path.join(instancePath, 'projects', 'profile-one'); + fs.mkdirSync(instanceProjectDir, { recursive: true }); + + const globalLine = JSON.stringify({ + type: 'assistant', + sessionId: 'session-global', + timestamp: '2026-03-02T10:00:00.000Z', + cwd: '/tmp/global', + message: { + model: 'claude-sonnet-4-5', + usage: { + input_tokens: 10, + output_tokens: 1, + }, + }, + }); + fs.writeFileSync(path.join(claudeDir, 'projects', 'project-one', 'global.jsonl'), `${globalLine}\n`); + + const instanceLine = JSON.stringify({ + type: 'assistant', + sessionId: 'session-instance', + timestamp: '2026-03-02T11:00:00.000Z', + cwd: '/tmp/instance', + message: { + model: 'gemini-2.5-pro', + usage: { + input_tokens: 100, + output_tokens: 10, + }, + }, + }); + fs.writeFileSync(path.join(instanceProjectDir, 'usage.jsonl'), `${instanceLine}\n`); + + process.env.CLAUDE_CONFIG_DIR = instancePath; + aggregator.clearUsageCache(); + + const daily = await aggregator.getCachedDailyData(); + + expect(daily).toHaveLength(1); + expect(daily[0].inputTokens).toBe(260); + expect(daily[0].outputTokens).toBe(61); + expect(daily[0].modelsUsed).toContain('claude-sonnet-4-5'); + expect(daily[0].modelsUsed).toContain('gemini-2.5-pro'); + }); + + it('merges monthly model breakdowns when sources collide', () => { + const result = aggregator.mergeMonthlyData([ + [ + { + month: '2026-03', + source: 'default', + inputTokens: 10, + outputTokens: 1, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalCost: 1, + modelsUsed: ['claude-sonnet-4-5'], + modelBreakdowns: [ + { + modelName: 'claude-sonnet-4-5', + inputTokens: 10, + outputTokens: 1, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 1, + }, + ], + }, + ], + [ + { + month: '2026-03', + source: 'instance', + inputTokens: 20, + outputTokens: 2, + cacheCreationTokens: 5, + cacheReadTokens: 3, + totalCost: 2, + modelsUsed: ['gemini-2.5-pro'], + modelBreakdowns: [ + { + modelName: 'gemini-2.5-pro', + inputTokens: 20, + outputTokens: 2, + cacheCreationTokens: 5, + cacheReadTokens: 3, + cost: 2, + }, + ], + }, + ], + ]); + + expect(result).toHaveLength(1); + expect(result[0].inputTokens).toBe(30); + expect(result[0].outputTokens).toBe(3); + expect(result[0].cacheCreationTokens).toBe(5); + expect(result[0].cacheReadTokens).toBe(3); + expect(result[0].totalCost).toBe(3); + expect(result[0].modelBreakdowns).toHaveLength(2); + }); + + it('coalesces legacy providerless breakdowns into the only known provider', () => { + const result = aggregator.mergeDailyData([ + [ + { + date: '2026-03-02', + source: 'legacy-cache', + inputTokens: 10, + outputTokens: 1, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 1, + totalCost: 1, + modelsUsed: ['gpt-5.5'], + modelBreakdowns: [ + { + modelName: 'gpt-5.5', + inputTokens: 10, + outputTokens: 1, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 1, + }, + ], + }, + ], + [ + { + date: '2026-03-02', + source: 'cliproxy', + inputTokens: 20, + outputTokens: 2, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 2, + totalCost: 2, + modelsUsed: ['gpt-5.5'], + modelBreakdowns: [ + { + modelName: 'gpt-5.5', + provider: 'openai', + inputTokens: 20, + outputTokens: 2, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 2, + }, + ], + }, + ], + ]); + + expect(result[0].modelBreakdowns).toHaveLength(1); + expect(result[0].modelBreakdowns[0]).toMatchObject({ + modelName: 'gpt-5.5', + provider: 'openai', + inputTokens: 30, + outputTokens: 3, + cost: 3, + }); + expect(result[0].modelsUsed).toEqual(['gpt-5.5']); + }); + + it('keeps legacy providerless breakdowns separate when providers are ambiguous', () => { + const result = aggregator.mergeHourlyData([ + [ + { + hour: '2026-03-02 10:00', + source: 'legacy-cache', + inputTokens: 10, + outputTokens: 1, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 1, + totalCost: 1, + modelsUsed: ['gpt-5.5'], + modelBreakdowns: [ + { + modelName: 'gpt-5.5', + inputTokens: 10, + outputTokens: 1, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 1, + }, + ], + }, + ], + [ + { + hour: '2026-03-02 10:00', + source: 'providers', + inputTokens: 50, + outputTokens: 5, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 5, + totalCost: 5, + modelsUsed: ['openai/gpt-5.5', 'github-copilot/gpt-5.5'], + modelBreakdowns: [ + { + modelName: 'gpt-5.5', + provider: 'openai', + inputTokens: 20, + outputTokens: 2, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 2, + }, + { + modelName: 'gpt-5.5', + provider: 'github-copilot', + inputTokens: 30, + outputTokens: 3, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 3, + }, + ], + }, + ], + ]); + + expect(result[0].modelBreakdowns).toHaveLength(3); + expect(result[0].modelsUsed).toEqual([ + 'gpt-5.5', + 'openai/gpt-5.5', + 'github-copilot/gpt-5.5', + ]); + }); + + it('falls back to model cardinality when merging legacy hourly buckets without requestCount', () => { + const result = aggregator.mergeHourlyData([ + [ + { + hour: '2026-03-02 10:00', + source: 'legacy-a', + inputTokens: 10, + outputTokens: 1, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 1, + totalCost: 1, + modelsUsed: ['claude-sonnet-4-5'], + modelBreakdowns: [ + { + modelName: 'claude-sonnet-4-5', + inputTokens: 10, + outputTokens: 1, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 1, + }, + ], + }, + ], + [ + { + hour: '2026-03-02 10:00', + source: 'legacy-b', + inputTokens: 20, + outputTokens: 2, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 2, + totalCost: 2, + modelsUsed: ['gemini-2.5-pro'], + modelBreakdowns: [ + { + modelName: 'gemini-2.5-pro', + inputTokens: 20, + outputTokens: 2, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 2, + }, + ], + }, + ], + ]); + + expect(result).toHaveLength(1); + expect(result[0].requestCount).toBe(2); + }); }); diff --git a/tests/unit/web-server/usage-aggregator-native-runtime-integration.test.ts b/tests/unit/web-server/usage-aggregator-native-runtime-integration.test.ts new file mode 100644 index 00000000..d4885e8f --- /dev/null +++ b/tests/unit/web-server/usage-aggregator-native-runtime-integration.test.ts @@ -0,0 +1,298 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +let tempRoot = ''; +let ccsDir = ''; +let claudeDir = ''; +let codexDir = ''; +let aggregator: typeof import('../../../src/web-server/usage/aggregator'); +let originalCcsDir: string | undefined; +let originalClaudeConfigDir: string | undefined; +let originalCcsHome: string | undefined; +let originalCodexHome: string | undefined; + +function writeUnifiedConfigFixture(): void { + const yaml = `version: 2 +accounts: {} +profiles: {} +preferences: + theme: system + telemetry: false + auto_update: true +cliproxy: + oauth_accounts: {} + providers: + - gemini + - codex + - agy + variants: {} +cliproxy_server: + local: + port: 65534 +`; + + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync(path.join(ccsDir, 'config.yaml'), yaml, 'utf8'); +} + +function writeClaudeJsonlFixture(): void { + const projectDir = path.join(claudeDir, 'projects', 'project-one'); + fs.mkdirSync(projectDir, { recursive: true }); + const line = JSON.stringify({ + type: 'assistant', + sessionId: 'claude-session', + timestamp: '2026-03-02T10:00:00.000Z', + cwd: '/tmp/project', + message: { + model: 'claude-sonnet-4-5', + usage: { + input_tokens: 100, + output_tokens: 40, + }, + }, + }); + fs.writeFileSync(path.join(projectDir, 'usage.jsonl'), `${line}\n`, 'utf8'); +} + +function writeCliproxySnapshotFixture(): void { + const snapshotDir = path.join(ccsDir, 'cache', 'cliproxy-usage'); + fs.mkdirSync(snapshotDir, { recursive: true }); + fs.writeFileSync( + path.join(snapshotDir, 'latest.json'), + JSON.stringify({ + version: 3, + timestamp: Date.now(), + details: [ + { + model: 'gemini-2.5-pro', + timestamp: '2026-03-02T10:00:00.000Z', + source: 'account-a', + authIndex: '0', + inputTokens: 50, + outputTokens: 10, + cacheReadTokens: 5, + requestCount: 1, + cost: 0.2, + failed: false, + }, + ], + daily: [ + { + date: '2026-03-02', + source: 'cliproxy', + inputTokens: 50, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 5, + cost: 0.2, + totalCost: 0.2, + modelsUsed: ['gemini-2.5-pro'], + modelBreakdowns: [ + { + modelName: 'gemini-2.5-pro', + inputTokens: 50, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 5, + cost: 0.2, + }, + ], + }, + ], + hourly: [ + { + hour: '2026-03-02 10:00', + source: 'cliproxy', + inputTokens: 50, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 5, + cost: 0.2, + totalCost: 0.2, + modelsUsed: ['gemini-2.5-pro'], + modelBreakdowns: [ + { + modelName: 'gemini-2.5-pro', + inputTokens: 50, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 5, + cost: 0.2, + }, + ], + }, + ], + monthly: [ + { + month: '2026-03', + source: 'cliproxy', + inputTokens: 50, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 5, + totalCost: 0.2, + modelsUsed: ['gemini-2.5-pro'], + modelBreakdowns: [ + { + modelName: 'gemini-2.5-pro', + inputTokens: 50, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 5, + cost: 0.2, + }, + ], + }, + ], + }), + 'utf8' + ); +} + +function writeCodexFixture(modelProvider = 'openai'): void { + const rolloutDir = path.join(codexDir, 'sessions', '2026', '03', '02'); + fs.mkdirSync(rolloutDir, { recursive: true }); + const rolloutPath = path.join(rolloutDir, 'rollout-native-codex.jsonl'); + const lines = [ + JSON.stringify({ + timestamp: '2026-03-02T10:00:00.000Z', + type: 'session_meta', + payload: { + id: 'codex-session', + timestamp: '2026-03-02T10:00:00.000Z', + cwd: '/tmp/codex-project', + cli_version: '0.126.0', + source: 'cli', + model_provider: modelProvider, + }, + }), + JSON.stringify({ + timestamp: '2026-03-02T10:00:01.000Z', + type: 'turn_context', + payload: { + turn_id: 'turn-1', + cwd: '/tmp/codex-project', + model: 'gpt-5', + effort: 'high', + }, + }), + JSON.stringify({ + timestamp: '2026-03-02T10:05:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 30, + cached_input_tokens: 5, + output_tokens: 2, + reasoning_output_tokens: 1, + total_tokens: 38, + }, + last_token_usage: { + input_tokens: 30, + cached_input_tokens: 5, + output_tokens: 2, + reasoning_output_tokens: 1, + total_tokens: 38, + }, + model_context_window: 200000, + }, + }, + }), + ]; + + fs.writeFileSync(rolloutPath, `${lines.join('\n')}\n`, 'utf8'); +} + +beforeEach(async () => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-usage-native-agg-')); + ccsDir = path.join(tempRoot, '.ccs'); + claudeDir = path.join(tempRoot, '.claude'); + codexDir = path.join(tempRoot, '.codex-native'); + + originalCcsDir = process.env.CCS_DIR; + originalCcsHome = process.env.CCS_HOME; + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + originalCodexHome = process.env.CODEX_HOME; + process.env.CCS_DIR = ccsDir; + process.env.CCS_HOME = tempRoot; + process.env.CLAUDE_CONFIG_DIR = claudeDir; + process.env.CODEX_HOME = codexDir; + + writeUnifiedConfigFixture(); + writeClaudeJsonlFixture(); + writeCliproxySnapshotFixture(); + writeCodexFixture(); + + aggregator = await import('../../../src/web-server/usage/aggregator'); + aggregator.clearUsageCache(); +}); + +afterEach(() => { + aggregator.shutdownUsageAggregator(); + aggregator.clearUsageCache(); + + if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir; + else delete process.env.CCS_DIR; + + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + if (originalClaudeConfigDir !== undefined) process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + else delete process.env.CLAUDE_CONFIG_DIR; + + if (originalCodexHome !== undefined) process.env.CODEX_HOME = originalCodexHome; + else delete process.env.CODEX_HOME; + + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +describe('usage aggregator native runtime integration', () => { + it('merges native codex usage with claude and cliproxy analytics', async () => { + const daily = await aggregator.getCachedDailyData(); + + expect(daily).toHaveLength(1); + expect(daily[0]).toMatchObject({ + date: '2026-03-02', + inputTokens: 180, + outputTokens: 53, + cacheReadTokens: 10, + }); + expect(daily[0].modelsUsed).toContain('claude-sonnet-4-5'); + expect(daily[0].modelsUsed).toContain('gemini-2.5-pro'); + expect(daily[0].modelsUsed).toContain('gpt-5'); + }); + + it('ignores cliproxy-backed codex rollouts to avoid double counting', async () => { + writeCodexFixture('cliproxy'); + aggregator.clearUsageCache(); + + const daily = await aggregator.getCachedDailyData(); + + expect(daily).toHaveLength(1); + expect(daily[0]).toMatchObject({ + inputTokens: 150, + outputTokens: 50, + cacheReadTokens: 5, + }); + expect(daily[0].modelsUsed).not.toContain('gpt-5'); + }); + + it('ignores ccs_runtime-backed codex bridge rollouts to avoid double counting', async () => { + writeCodexFixture('ccs_runtime'); + aggregator.clearUsageCache(); + + const daily = await aggregator.getCachedDailyData(); + + expect(daily).toHaveLength(1); + expect(daily[0]).toMatchObject({ + inputTokens: 150, + outputTokens: 50, + cacheReadTokens: 5, + }); + expect(daily[0].modelsUsed).not.toContain('gpt-5'); + }); +}); diff --git a/tests/unit/web-server/usage-handlers-semantics.test.ts b/tests/unit/web-server/usage-handlers-semantics.test.ts new file mode 100644 index 00000000..9b9fe47d --- /dev/null +++ b/tests/unit/web-server/usage-handlers-semantics.test.ts @@ -0,0 +1,339 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +type HandlersModule = typeof import('../../../src/web-server/usage/handlers'); +type AggregatorModule = typeof import('../../../src/web-server/usage/aggregator'); + +interface AssistantFixture { + project: string; + sessionId: string; + timestamp: string; + model: string; + inputTokens?: number; + outputTokens?: number; + cacheCreationTokens?: number; + cacheReadTokens?: number; +} + +interface MockResponse { + payload: unknown; + statusCode: number; + status: (code: number) => MockResponse; + json: (body: unknown) => MockResponse; +} + +let tempHome = ''; +let claudeDir = ''; +let codexDir = ''; +let handlers: HandlersModule; +let aggregator: AggregatorModule; +let originalCcsHome: string | undefined; +let originalClaudeConfigDir: string | undefined; +let originalCodexHome: string | undefined; + +function writeUnifiedConfigFixture(): void { + const yaml = `version: 2 +accounts: {} +profiles: {} +preferences: + theme: system + telemetry: false + auto_update: true +cliproxy: + oauth_accounts: {} + providers: + - gemini + - codex + - agy + variants: {} +cliproxy_server: + local: + port: 65534 +`; + + fs.mkdirSync(path.join(tempHome, '.ccs'), { recursive: true }); + fs.writeFileSync(path.join(tempHome, '.ccs', 'config.yaml'), yaml, 'utf-8'); +} + +function writeAssistantEntries(entries: AssistantFixture[]): void { + for (const entry of entries) { + const projectDir = path.join(claudeDir, 'projects', entry.project); + fs.mkdirSync(projectDir, { recursive: true }); + + const line = JSON.stringify({ + type: 'assistant', + sessionId: entry.sessionId, + timestamp: entry.timestamp, + version: '1.0.0', + cwd: `/tmp/${entry.project}`, + message: { + model: entry.model, + usage: { + input_tokens: entry.inputTokens ?? 0, + output_tokens: entry.outputTokens ?? 0, + cache_creation_input_tokens: entry.cacheCreationTokens ?? 0, + cache_read_input_tokens: entry.cacheReadTokens ?? 0, + }, + }, + }); + + fs.writeFileSync( + path.join(projectDir, `${entry.sessionId}.jsonl`), + `${line}\n`, + 'utf-8' + ); + } +} + +function createMockResponse(): MockResponse { + return { + payload: undefined, + statusCode: 200, + status(code: number) { + this.statusCode = code; + return this; + }, + json(body: unknown) { + this.payload = body; + return this; + }, + }; +} + +beforeEach(async () => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-usage-handlers-')); + claudeDir = path.join(tempHome, '.claude'); + codexDir = path.join(tempHome, '.codex'); + + originalCcsHome = process.env.CCS_HOME; + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + originalCodexHome = process.env.CODEX_HOME; + process.env.CCS_HOME = tempHome; + process.env.CLAUDE_CONFIG_DIR = claudeDir; + process.env.CODEX_HOME = codexDir; + + writeUnifiedConfigFixture(); + + handlers = await import('../../../src/web-server/usage/handlers'); + aggregator = await import('../../../src/web-server/usage/aggregator'); + aggregator.shutdownUsageAggregator(); + aggregator.clearUsageCache(); +}); + +afterEach(() => { + aggregator.shutdownUsageAggregator(); + aggregator.clearUsageCache(); + + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (originalClaudeConfigDir !== undefined) { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + } else { + delete process.env.CLAUDE_CONFIG_DIR; + } + + if (originalCodexHome !== undefined) { + process.env.CODEX_HOME = originalCodexHome; + } else { + delete process.env.CODEX_HOME; + } + + fs.rmSync(tempHome, { recursive: true, force: true }); +}); + +describe('usage handlers semantics', () => { + it('includes cache tokens in summary totals and uses calendar-day averages', async () => { + writeAssistantEntries([ + { + project: 'project-one', + sessionId: 'session-a', + timestamp: '2026-03-02T10:00:00.000Z', + model: 'claude-sonnet-4-5', + inputTokens: 1_000_000, + outputTokens: 100_000, + cacheCreationTokens: 100_000, + cacheReadTokens: 200_000, + }, + ]); + + const res = createMockResponse(); + await handlers.handleSummary( + { query: { since: '20260301', until: '20260303' } } as never, + res as never + ); + + expect(res.statusCode).toBe(200); + expect(res.payload).toMatchObject({ + success: true, + data: { + totalTokens: 1_400_000, + totalCacheTokens: 300_000, + totalDays: 3, + activeDays: 1, + averageTokensPerDay: 466_667, + averageTokensPerActiveDay: 1_400_000, + averageCostPerDay: 1.65, + averageCostPerActiveDay: 4.93, + }, + }); + }); + + it('counts hourly requests from raw entries instead of distinct models', async () => { + writeAssistantEntries([ + { + project: 'project-one', + sessionId: 'session-a', + timestamp: '2026-03-02T10:05:00.000Z', + model: 'claude-sonnet-4-5', + inputTokens: 100, + outputTokens: 10, + }, + { + project: 'project-two', + sessionId: 'session-b', + timestamp: '2026-03-02T10:15:00.000Z', + model: 'claude-sonnet-4-5', + inputTokens: 120, + outputTokens: 15, + }, + { + project: 'project-three', + sessionId: 'session-c', + timestamp: '2026-03-02T10:30:00.000Z', + model: 'gemini-2.5-pro', + inputTokens: 80, + outputTokens: 20, + }, + ]); + + const res = createMockResponse(); + await handlers.handleHourly( + { query: { since: '20260302', until: '20260302' } } as never, + res as never + ); + + const payload = res.payload as { success: boolean; data: Array<{ hour: string; requests: number }> }; + const targetHour = payload.data.find((row) => row.hour === '2026-03-02 10:00'); + + expect(targetHour?.requests).toBe(3); + }); + + it('uses overlapping months for monthly filtering', async () => { + writeAssistantEntries([ + { + project: 'march-project', + sessionId: 'session-march', + timestamp: '2026-03-20T10:00:00.000Z', + model: 'claude-sonnet-4-5', + inputTokens: 100, + outputTokens: 10, + }, + { + project: 'april-project', + sessionId: 'session-april', + timestamp: '2026-04-05T10:00:00.000Z', + model: 'claude-sonnet-4-5', + inputTokens: 200, + outputTokens: 20, + }, + ]); + + const res = createMockResponse(); + await handlers.handleMonthly( + { query: { since: '20260315', until: '20260410' } } as never, + res as never + ); + + expect(res.payload).toMatchObject({ + success: true, + data: [ + expect.objectContaining({ month: '2026-03' }), + expect.objectContaining({ month: '2026-04' }), + ], + }); + }); + + it('reports actual cache size after warming the usage cache', async () => { + writeAssistantEntries([ + { + project: 'project-one', + sessionId: 'session-a', + timestamp: '2026-03-02T10:00:00.000Z', + model: 'claude-sonnet-4-5', + inputTokens: 100, + outputTokens: 10, + }, + ]); + + await aggregator.getCachedDailyData(); + + const res = createMockResponse(); + handlers.handleStatus({} as never, res as never); + + const payload = res.payload as { + success: boolean; + data: { lastFetch: number | null; cacheSize: unknown }; + }; + expect(payload.success).toBe(true); + expect(payload.data.lastFetch).not.toBeNull(); + expect(payload.data.cacheSize).toBe(aggregator.getUsageCacheSize()); + expect(aggregator.getUsageCacheSize()).toBeGreaterThan(0); + }); + + it('includes cache-only model activity in model token percentages', async () => { + writeAssistantEntries([ + { + project: 'project-one', + sessionId: 'session-a', + timestamp: '2026-03-02T10:00:00.000Z', + model: 'claude-sonnet-4-5', + inputTokens: 100, + outputTokens: 0, + }, + { + project: 'project-two', + sessionId: 'session-b', + timestamp: '2026-03-02T11:00:00.000Z', + model: 'gemini-2.5-pro', + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 100, + }, + ]); + + const res = createMockResponse(); + await handlers.handleModels( + { query: { since: '20260302', until: '20260302' } } as never, + res as never + ); + + expect(res.payload).toMatchObject({ + success: true, + data: expect.arrayContaining([ + expect.objectContaining({ model: 'claude-sonnet-4-5', tokens: 100, percentage: 50 }), + expect.objectContaining({ model: 'gemini-2.5-pro', tokens: 100, percentage: 50 }), + ]), + }); + }); + + it('rejects reversed date ranges before computing summary totals', async () => { + const res = createMockResponse(); + + await handlers.handleSummary( + { query: { since: '20260410', until: '20260401' } } as never, + res as never + ); + + expect(res.statusCode).toBe(400); + expect(res.payload).toMatchObject({ + success: false, + error: 'The "since" date must be earlier than or equal to "until"', + }); + }); +}); diff --git a/ui/docs/design-decisions.md b/ui/docs/design-decisions.md new file mode 100644 index 00000000..e3b64052 --- /dev/null +++ b/ui/docs/design-decisions.md @@ -0,0 +1,183 @@ +# Design System — Decisions Log + +The 6 open questions from the brainstorm phase, resolved before implementation. + +| # | Question | Decision | Rationale | +|---|----------|----------|-----------| +| 1 | Storybook vs in-app `/_styleguide` | **In-app `/_styleguide`** | Zero-config, lives in the repo, gated by `import.meta.env.DEV`. Storybook is a heavy second build pipeline for marginal benefit at this scale. | +| 2 | Archetype B name (Monitor vs Dashboard) | **Monitor** | "Dashboard" is the whole product. "Monitor" matches existing health/analytics framing and avoids overloading the term. | +| 3 | JsonPane editability scope | **Read-only by default**, opt-in `editable` prop | Cliproxy is the only page that genuinely needs in-pane editing today. Opt-in keeps the API safe for codex/copilot/cursor (read-only). | +| 4 | Health terminal aesthetic | **Keep** as `MonitorCard variant="terminal"` | Preserves the `ccs health --watch` feel. Implemented as opt-in variant, not a separate primitive. | +| 5 | i18n key namespace convention | **Per-page namespaces** (`pages..*`) + shared (`common.*`) | Current i18n already uses ad-hoc per-page keys; this just formalizes it. Primitives use `common.*` so they're translation-stable across pages. | +| 6 | SectionRail activation | **Scroll-spy** via `IntersectionObserver` | Preserves long-form config feel + shows all validation errors at once. Click-to-switch hides errors in inactive sections, which is worse UX for forms. | + +--- + +## v1.1 revision (2026-04-25) — identity-strip patterns + +Phase 2 attempted to migrate `home` and `cliproxy` to a one-size-fits-all `PageShell + PageHeader` chrome. Both regressed on density: + +- **home** had a single-row hero (logo + title + version + 4 inline stats) — splitting it into a stacked PageHeader + KpiRow doubled vertical footprint and lost scannability +- **cliproxy** had identity in the left rail — adding a top PageHeader duplicated branding and stole ~80px from the 3-pane body + +**Resolution:** the design system is restructured around three identity-strip patterns extracted from the existing canonical references: + +| # | Pattern | Reference | When | +|---|---------|-----------|------| +| 7a | `HeroBar` (1-row dense) | `pages/home.tsx` | Dashboard pages with ≤4 hero stats | +| 7b | Rail-anchored identity (no top chrome) | `pages/cliproxy.tsx` | Multi-entity Config pages where rail carries brand | +| 7c | `PageHeader` (current) | `pages/health.tsx` | Pages where description / status info is non-redundant | + +Phase 2's home + cliproxy migrations are reverted. Health stays migrated (Monitor archetype + PageHeader works there). Future page migrations adapt to whichever pattern fits, NOT the other way around. + +**Why bottom-up:** the existing references already proved their patterns work in production. The job of the design system is to formalize what works, not impose what should. + +--- + +## v1.2 revision (2026-04-25) — health redesign and bespoke pattern + +After the v1.1 restructure, the `health` page received a separate, focused redesign per a dedicated handoff brief (`plans/reports/handoff-260425-1417-health-page-redesign.md`). The redesign went bespoke: + +- New domain components: `HealthStatusRibbon`, `HealthPriorityCard`, `HealthPriorityList`, `HealthAuditSection` +- Severity-driven layout: priority surfaces (errors/warnings) at the top with prominent fix affordances, audit accordion below +- Dynamic colored background tied to overall status, glassmorphic accents +- No `PageShell`, no `PageHeader`, no `MonitorLayout` — full custom shell + +**Resolution:** add a fourth identity-strip pattern — **§1d Bespoke** — for pages whose content shape doesn't fit the three primary patterns. The v1.1 row's claim that "health stays migrated (Monitor archetype + PageHeader works there)" no longer holds; corrected here. + +**Side effects:** +- `PageHeader` lost its canonical reference (was health). New reference TBD when another page adopts it. +- `MonitorLayout` lost its canonical reference. Primitives remain available for future Monitor pages. +- `Bespoke` is explicitly an escape hatch — not a default. Code review enforces "you tried the three patterns first." + +--- + +## v1.8 revision (2026-04-26) — PR-Agent round 2: storageKey default, MaskedInput type-safety, keyboard-accessible reveal toggle + +Three more substantive issues from upstream review on PR #1109 after the v1.7 fix push. Each is encoded in code + spec: + +**Required `storageKey` was the wrong fix.** v1.7 made `storageKey: string` REQUIRED to prevent cross-page state bleed, but that turned every future production-page migration into a build-breaking event. Better engineering: keep the prop optional and **default to a key derived from `window.location.pathname`** so each route gets its own localStorage slot automatically. The cross-page bleed concern still holds for hardcoded shared keys, but a pathname-derived default is unique by construction. SSR-safe (falls back to a stable string when `window` is unavailable). Pages can still pass an explicit `storageKey` to opt out of pathname coupling (e.g. when sub-routes should share split state). + +**`MaskedInput` type was overridable by callers.** The spread `{...props}` came AFTER the hardcoded `type={revealed ? 'text' : 'password'}`, so a caller passing `type="text"` would silently render a credential in plaintext — a real leakage risk that defeated the component's purpose. Two-layer fix: +- Compile-time: `MaskedInputProps` now extends `Omit` so callers literally cannot pass `type`. Type system enforces the contract. +- Runtime: the `type` attribute on `` is now placed AFTER the spread so even if the compile-time check is bypassed (e.g. via `as`-cast), the component still wins. Belt-and-braces. + +**Reveal toggle was keyboard-inaccessible.** Both `MaskedInput` and `Field` had `tabIndex={-1}` on the eye-icon button, which removed it from the tab order — keyboard-only users couldn't show or hide the secret. Resolution: +- Drop `tabIndex={-1}` so the button joins the natural tab order. +- Add `aria-pressed={revealed}` so screen readers announce toggle state. +- Add a focus-visible accent ring so the focused state is visible without a mouse. + +These are accessibility regressions that should never have shipped; the new tests-of-record for §5g are: (a) `type` cannot be overridden, (b) the toggle is reachable via Tab and announces state. + +--- + +## v1.7 revision (2026-04-26) — PR-Agent feedback: enforce mins, require storageKey, broaden sensitive heuristic + +Three substantive issues surfaced by upstream PR review on PR #1109. Each is encoded in code + spec: + +**Width floor — pixel claim was unenforceable.** The previous spec wording said "form ≥ 360px / json ≥ 320px" but `react-resizable-panels` v3 only accepts percentage `minSize`. On a 1280px viewport this could let a user drag a pane down to ~250px — well below the documented floor. Resolution: bump `minSize` from 25 to **30**, restate the floor as percentage (≥ 30% of body width after the rail), document the actual 300–360px range across realistic viewports, note the v3 API constraint, and leave the door open for a future `onResize` clamp if hard pixel floors become necessary. + +**`storageKey` was no longer optional.** The previous default `storageKey="ccs.config-layout"` meant any `` without an explicit key would share localStorage state with every other Config page — split ratios bleeding across unrelated pages. Resolution: make `storageKey` REQUIRED in the `ConfigLayout` props (no default). TypeScript now enforces explicit per-page keys. Pages MUST pass e.g. `storageKey="config-layout.cliproxy"` — this is checked at compile time, not at runtime. + +**Sensitive-field heuristic was too narrow.** The previous regex `AUTH_TOKEN|API_KEY|SECRET|PASSWORD|PRIVATE_KEY` missed common secret names: `ACCESS_TOKEN`, `REFRESH_TOKEN`, `BEARER_TOKEN`, `CLIENT_SECRET`, `CLIENT_ID`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, GCP/Azure/GitHub/OpenAI/Anthropic variants, `JWT`, `OAUTH`, `CREDENTIAL`, `PAT`, `WEBHOOK_SECRET`, `HMAC_KEY`, `SIGNING_KEY`, `SSH_KEY`. Resolution: extract the heuristic to `src/lib/sensitive-label.ts` (`isSensitiveLabel(label)`) and broaden the pattern to cover all of those; case-insensitive; tolerates `_`/`-` separators. The Field component imports the shared helper. Adding a new pattern means editing one regex; every consumer inherits. + +The shared helper is the Single Source of Truth so future drift can't reintroduce per-component heuristics that disagree with each other. + +--- + +## v1.6 revision (2026-04-26) — content-fit rail (unified envelope, not fixed width) + +Live review of the rail-anchored pattern on the API Profiles page surfaced a regression: at the previously-mandated fixed `260px` rail width, the rail header "API Profiles" wrapped onto two lines, the description band wrapped, and the action buttons crowded each other — i.e. the rail was overflowing its own content even though the system mandated that exact width. + +**Decision:** the left rail is **unified across pages but content-fit**, not fixed-width. Same primitives, same style treatment (§5), same vertical structure (header → search → list → footer) — but the *width* adapts to its header content within a unified envelope: + +- `w-fit` — natural width grows to the largest atomic header element (title + buttons stay on one row) +- `min-w-[240px]` — floor so a sparse rail (e.g. 1-section SectionRail with no badges) doesn't squeeze controls +- `max-w-[360px]` — cap so the rail never dominates the body when an entity label is unusually long; per-item `truncate` inside `ListPane` handles labels beyond that + +Pages MUST NOT override this envelope. If a page legitimately needs a wider rail, that is a system-wide envelope change (one PR raises the cap for everyone — uniformity is the point). + +**Why not user-resizable?** The form↔json split *is* user-resizable (§0e) because users have different reading-vs-editing preferences mid-task. The rail isn't, because rail content is page-author-controlled and should be sized by the page author at design time. Letting users drag the rail width adds drag affordances inside an identity strip (visual noise) without solving a real recurring need. + +**Encoded as §0a** in `design-system.md` (mandatory layout invariant; the prior "fixed minimum width" wording in §0e was rewritten to reference the new envelope). `ConfigLayout` ships the envelope as `