Merge pull request #1125 from kaitranntt/dev

feat(release): promote dev to main
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-04-28 22:44:20 -04:00
commit f2b8b0e0c9
187 files changed
+13507 -1598

No files matched your search

+191
View File
@@ -0,0 +1,191 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Issue 1118 Evidence - AI Providers model rules</title>
<style>
:root {
color-scheme: light;
--bg: #f8fafc;
--panel: #ffffff;
--ink: #0f172a;
--muted: #475569;
--line: #cbd5e1;
--bad: #b91c1c;
--good: #047857;
--code: #111827;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font:
15px/1.55 Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
}
main {
width: min(1040px, calc(100vw - 48px));
margin: 0 auto;
padding: 48px 0;
}
header {
margin-bottom: 24px;
}
h1,
h2 {
margin: 0;
letter-spacing: 0;
}
h1 {
font-size: 30px;
line-height: 1.15;
}
h2 {
font-size: 18px;
}
p {
margin: 8px 0 0;
color: var(--muted);
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-top: 24px;
}
.panel {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
padding: 20px;
}
.status {
display: inline-flex;
align-items: center;
gap: 8px;
margin-top: 14px;
border-radius: 999px;
padding: 4px 10px;
font-size: 13px;
font-weight: 700;
}
.status.bad {
background: #fee2e2;
color: var(--bad);
}
.status.good {
background: #dcfce7;
color: var(--good);
}
pre {
overflow-x: auto;
margin: 16px 0 0;
border-radius: 8px;
background: var(--code);
color: #f8fafc;
padding: 16px;
font-size: 13px;
line-height: 1.45;
}
ul {
margin: 12px 0 0;
padding-left: 20px;
color: var(--muted);
}
li + li {
margin-top: 6px;
}
@media (max-width: 760px) {
main {
width: min(100vw - 32px, 1040px);
padding: 32px 0;
}
.grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<main>
<header>
<h1>Issue 1118 Evidence</h1>
<p>
AI Providers route should tolerate plain OpenAI-compatible model rules
where an alias is omitted.
</p>
</header>
<section class="grid" aria-label="before and after evidence">
<article class="panel">
<h2>Before: installed dev build</h2>
<p>
The current installed CLI dashboard API crashes while reading the
local AI provider config.
</p>
<div class="status bad">HTTP 500</div>
<pre><code>before: HTTP 500
{
"error": "Cannot read properties of undefined (reading 'trim')"
}</code></pre>
</article>
<article class="panel">
<h2>After: patched worktree build</h2>
<p>
The same local config now loads, and omitted aliases normalize to an
empty string.
</p>
<div class="status good">HTTP 200</div>
<pre><code>after: HTTP 200
{
"familyCount": 5,
"openaiCompatibilityEntryCount": 1,
"normalizedModelAliases": [
{
"hasName": true,
"alias": ""
}
]
}</code></pre>
</article>
</section>
<section class="panel" style="margin-top: 16px">
<h2>Validation</h2>
<ul>
<li>Regression covers model entries with missing aliases.</li>
<li>Malformed model entries are ignored instead of crashing the API.</li>
<li>The AI Providers dashboard route loads against the patched server.</li>
</ul>
</section>
</main>
</body>
</html>
+26 -2
View File
@@ -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
+1 -1
View File
@@ -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
+13 -7
View File
@@ -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 |
+4 -1
View File
@@ -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
+1 -1
View File
@@ -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).
+6 -4
View File
@@ -261,7 +261,7 @@ async function checkRemoteProxyHealth(config: ResolvedProxyConfig): Promise<bool
### Overview
Hybrid quota management enables automatic detection of exhausted accounts and failover to next available account.
When CCS detects exhaustion and a healthy fallback exists, it also temporarily pauses the exhausted account out of CLIProxy rotation and automatically resumes that pause after the configured cooldown expires.
When CCS detects exhaustion and a healthy fallback exists, it temporarily pauses the exhausted account out of CLIProxy rotation and automatically resumes that pause after the configured cooldown expires. This durable self-pause uses the same account registry and token movement path as dashboard/manual pause, so the dashboard shows the account as paused and CLIProxy cannot rediscover its token from the live `auth/` folder.
```
+===========================================================================+
@@ -287,9 +287,11 @@ When CCS detects exhaustion and a healthy fallback exists, it also temporarily p
| +---> 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)
| |
+3 -2
View File
@@ -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
+309 -121
View File
@@ -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');
}
+1 -1
View File
@@ -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",
+4 -2
View File
@@ -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
+14 -5
View File
@@ -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);
}
+82 -3
View File
@@ -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');
+4 -2
View File
@@ -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<void> {
),
};
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<void> {
settingsPath: expandedSettingsPath,
});
execClaude(claudeCli, launchArgs, { ...proxyEnv, ...traceEnv });
execClaude(claudeCli, launchArgs, { ...proxyEnv, ...traceEnv }, launchSettings.cleanup);
return;
}
const launchArgs = [
+6 -1
View File
@@ -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);
+13 -4
View File
@@ -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<Record<keyof AiProviderModelAlias, unknown>>)[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);
}
+2 -1
View File
@@ -57,7 +57,8 @@ function clampToMaxStable(version: string | undefined, verbose: boolean): string
async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean): Promise<void> {
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;
+1
View File
@@ -18,6 +18,7 @@ const KNOWN_CODEX_MODELS = new Set(
);
const FREE_PLAN_FALLBACKS = new Map<string, string>([
['gpt-5.5', FREE_SAFE_DEFAULT_MODEL],
['gpt-5.3-codex', FREE_SAFE_DEFAULT_MODEL],
['gpt-5.3-codex-spark', FREE_SAFE_FAST_MODEL],
]);
+29 -1
View File
@@ -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
+33 -5
View File
@@ -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;
+2 -2
View File
@@ -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<void> {
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) {
+3 -2
View File
@@ -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<CliproxyRoutingStrategy> {
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<CliproxyRoutingStrategy> {
await this.request('PUT', '/routing/strategy', { value: strategy });
await this.request('PUT', ROUTING_STRATEGY_PATH, { value: strategy });
return strategy;
}
+13
View File
@@ -188,6 +188,19 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
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',
+38 -12
View File
@@ -89,7 +89,27 @@ function clampUnit(value: number): number {
return Math.max(0, Math.min(1, value));
}
function normalizeUtilization(raw: Record<string, unknown>): {
/**
* 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<string, unknown>,
unit: UtilizationUnit
): {
utilization: number | null;
usedPercent: number;
remainingPercent: number;
@@ -99,11 +119,12 @@ function normalizeUtilization(raw: Record<string, unknown>): {
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<string, unknown> | null {
function normalizeRestriction(
raw: Record<string, unknown>,
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<string, unknown>): 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<string, unknown>)) {
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<string, unknown>): 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);
}
}
+104 -24
View File
@@ -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<CodexQuotaWindow['category']>;
cadence: NonNullable<CodexQuotaWindow['cadence']>;
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;
}
+20 -8
View File
@@ -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)
+219 -67
View File
@@ -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<ManagedQuotaResult> {
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<typeof window> => !!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<Prefli
// Calculate normalized quota percentage.
// Null means quota data is unavailable (e.g. provider does not expose limits for this account).
const quotaPercent = calculateQuotaPercent(quota);
const quotaPercent = calculateQuotaPercent(provider, quota);
if (quotaPercent === null) {
return {
proceed: true,
@@ -462,11 +594,13 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise<Prefli
};
}
pauseAccountForQuotaCooldown(
provider,
defaultAccount.id,
quotaConfig.auto?.cooldown_minutes ?? 5
);
if (alternative.lastQuota !== null && alternative.lastQuota >= 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<typeof setTimeout> | null = null;
interface RuntimeMonitorState {
timer: ReturnType<typeof setTimeout> | 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<string, RuntimeMonitorState>();
/**
* 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();
}
+16 -2
View File
@@ -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 "<feature> (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 */
+6 -1
View File
@@ -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<string, string>
): Promise<Response> {
const url = buildProxyUrl(target, '/routing/strategy');
const url = getCliproxyRoutingManagementUrl(target);
const headers = buildManagementHeaders(
target,
body ? { 'Content-Type': 'application/json' } : {}
+183 -1
View File
@@ -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<CliproxySessionAffinitySettings> {
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<CliproxyRoutingStrategy> {
const response = await fetchCliproxyRoutingResponse(getCliproxyRoutingTarget(), 'GET');
if (!response.ok) {
@@ -95,6 +172,38 @@ export async function readCliproxyRoutingState(): Promise<CliproxyRoutingState>
}
}
export async function readCliproxySessionAffinityState(): Promise<CliproxySessionAffinityState> {
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<CliproxyRoutingApplyResult> {
@@ -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<CliproxySessionAffinityApplyResult> {
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<void> {
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<boolean> {
try {
await fetchLiveCliproxyRoutingStrategy();
return true;
} catch {
return false;
}
}
+23 -5
View File
@@ -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<ServiceStartResult> {
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,
});
+22
View File
@@ -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<string>([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;
+16
View File
@@ -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<UpdateCheckResult>;
}
/**
@@ -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;
+13 -1
View File
@@ -493,7 +493,15 @@ export async function handleApiCreateCommand(args: string[]): Promise<void> {
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<void> {
` 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) {
+8
View File
@@ -55,6 +55,9 @@ export async function showApiCommandHelp(writeLine: HelpWriter = console.log): P
writeLine(` ${color('--base-url <url>', 'command')} API base URL (create)`);
writeLine(` ${color('--api-key <key>', 'command')} API key (create)`);
writeLine(` ${color('--model <model>', 'command')} Default model (create)`);
writeLine(
` ${color('--extra-models <list>', '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(
+17
View File
@@ -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'],
+3 -1
View File
@@ -59,8 +59,10 @@ export async function showHelp(): Promise<void> {
['quota', 'Show quota status for all providers (Codex/Claude include 5h + weekly reset)'],
['quota --provider <name>', `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 <mode>', 'Explicitly set round-robin or fill-first'],
['routing affinity', 'Show local session-affinity status and TTL'],
['routing affinity <on|off> [--ttl <duration>]', 'Toggle local session-affinity settings'],
],
],
[
+20 -1
View File
@@ -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<void> {
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;
}
+85 -27
View File
@@ -238,7 +238,10 @@ function getCodexWindowKind(label: string): CodexWindowKind {
return 'unknown';
}
type CodexWindowSummary = Pick<CodexQuotaResult['windows'][number], 'label' | 'resetAfterSeconds'>;
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);
+169 -1
View File
@@ -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<void> {
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<void> {
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<void> {
@@ -49,6 +113,8 @@ export async function handleRoutingExplain(): Promise<void> {
console.log(header('CLIProxy Routing Guide'));
console.log('');
printStrategyGuide();
printSessionAffinityGuide();
printSessionRecognitionGuide();
}
export async function handleRoutingSet(args: string[]): Promise<void> {
@@ -78,3 +144,105 @@ export async function handleRoutingSet(args: string[]): Promise<void> {
}
console.log('');
}
export async function handleRoutingAffinityStatus(): Promise<void> {
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<void> {
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<void> {
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 <on|off> [--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('');
}
+4 -1
View File
@@ -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 = [
+18 -1
View File
@@ -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']);
+16 -3
View File
@@ -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<string>();
@@ -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}]`;
+35
View File
@@ -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>): 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: {
+6
View File
@@ -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: {
+19 -1
View File
@@ -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<string, string>;
runtimeEnvVars?: NodeJS.ProcessEnv;
traceEnv?: Record<string, string>;
launchCleanup?: () => void;
}
): Promise<ExecutionResult> {
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}`));
});
@@ -362,7 +362,7 @@ function transformToolChoice(
function mapThinkingToReasoning(
thinking: AnthropicThinking | undefined,
outputConfig: AnthropicOutputConfig | undefined
): Pick<ProxyOpenAIRequest, 'reasoning' | 'reasoning_effort'> {
): Pick<ProxyOpenAIRequest, 'reasoning_effort'> {
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,
},
};
}
+1 -1
View File
@@ -21,7 +21,7 @@ export const TARGET_METADATA: Record<TargetType, TargetMetadata> = {
},
codex: {
displayName: 'Codex CLI',
runtimeAliases: ['ccs-codex', 'ccsx'],
runtimeAliases: ['ccs-codex', 'ccsx', 'ccsxp'],
legacyAliasEnvVar: 'CCS_CODEX_ALIASES',
persistedTarget: false,
},
@@ -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,
};
}
+13 -1
View File
@@ -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}`);
+25 -4
View File
@@ -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'
+105 -30
View File
@@ -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
);
}
@@ -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<string, string> = {
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<string, ModelsDevModel>();
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<string>();
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));
}
+149
View File
@@ -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<ModelsDevRegistry | null> | 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<string, unknown> {
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<ModelsDevProvider['models']>)
: 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<ModelsDevRegistry | null> {
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<ModelsDevRegistry | null> {
if (!pendingBackgroundRefresh) {
pendingBackgroundRefresh = refreshModelsDevRegistry(options)
.catch(() => null)
.finally(() => {
pendingBackgroundRefresh = null;
});
}
return pendingBackgroundRefresh;
}
+42
View File
@@ -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<string, unknown>;
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<string, ModelsDevModel>;
[key: string]: unknown;
}
export type ModelsDevRegistry = Record<string, ModelsDevProvider>;
export interface ModelsDevCacheData {
version: 1;
fetchedAt: number;
providers: ModelsDevRegistry;
}
@@ -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<voi
}
});
router.get('/routing/session-affinity', async (_req: Request, res: Response): Promise<void> => {
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<void> => {
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;
+18 -3
View File
@@ -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,
+12
View File
@@ -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 ||
+140 -20
View File
@@ -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<string, CacheEntry<unknown>>();
@@ -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();
+229 -79
View File
@@ -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<typeof setInterval> | 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<string>();
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<void> {
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<void> {
@@ -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<void> = () => syncCliproxyUsage()): void {
if (syncIntervalId !== null) {
return;
@@ -162,7 +316,6 @@ export function startCliproxySync(syncNow: () => Promise<void> = () => 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<void> = () => syncClipr
}, SYNC_INTERVAL_MS);
}
/**
* Stop periodic CLIProxy usage sync.
*/
export function stopCliproxySync(): void {
if (syncIntervalId !== null) {
clearInterval(syncIntervalId);
@@ -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<string, { detail: CliproxyUsageHistoryDetail; count: number }>();
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<string, { detail: CliproxyUsageHistoryDetail; count: number }>();
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<T>(
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<string, Map<string, ModelAccumulator>>();
// bucket: timeKey -> provider/model key -> accumulator
const buckets = new Map<
string,
Map<string, { modelName: string; provider?: string; acc: ModelAccumulator }>
>();
const requestCounts = new Map<string, number>();
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<string, ModelAccumulator>;
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)
),
};
}
@@ -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<string, unknown> {
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<string[]> {
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<RawUsageEntry[]> {
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<string, unknown>;
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<string, unknown>).total_token_usage
: null
);
const lastSnapshot = normalizeTokenSnapshot(
parsed.payload.info && isObject(parsed.payload.info)
? (parsed.payload.info as Record<string, unknown>).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<RawUsageEntry[]> {
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;
}
+55 -45
View File
@@ -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,
@@ -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<string, unknown> {
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<string, unknown>): Array<Record<string, unknown>> {
const raw = settings.customModels ?? settings.custom_models;
if (Array.isArray(raw)) {
return raw.filter((entry): entry is Record<string, unknown> => isObject(entry));
}
if (isObject(raw)) {
return Object.values(raw).filter((entry): entry is Record<string, unknown> => isObject(entry));
}
return [];
}
function buildCustomModelMap(settingsPath: string): Map<string, string> {
const selectors = new Map<string, string>();
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<string, DroidSessionMetadata> {
const metadata = new Map<string, DroidSessionMetadata>();
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<RawUsageEntry[]> {
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<Record<string, unknown>> = [];
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;
}
+256 -45
View File
@@ -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<void>
}
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);
+94
View File
@@ -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>): 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<string, Set<string>>();
for (const item of items) {
const providers = providersByModel.get(item.modelName) ?? new Set<string>();
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<T extends MergeableProviderModelBreakdown>(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<T extends MergeableProviderModelBreakdown>(
items: T[]
): T[] {
const byModel = new Map<string, T[]>();
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;
}
+40
View File
@@ -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<string, unknown>;
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<SqliteJsonRow[]> {
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}`);
}
}
+6
View File
@@ -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) */
+26 -17
View File
@@ -137,6 +137,30 @@ function resetMockState(): void {
/**
* Invoke the hook with JSON input
*/
function buildHookEnv(env: Record<string, string> = {}): 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<string, string> = {}
@@ -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'],
});
@@ -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<string> {
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<number> {
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);
});
@@ -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();
});
});
+45 -5
View File
@@ -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']);
});
});
@@ -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<string, unknown>): 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);
});
@@ -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: '' }]);
});
});
@@ -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<string, string>;
};
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(() => {});
@@ -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');
@@ -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' }),
@@ -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',
@@ -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', () => {
@@ -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', () => {
@@ -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', () => {
@@ -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,
@@ -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<typeof setTimeout>;
const observedDelays: Array<number | undefined> = [];
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(() => {
@@ -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<typeof import('../../../src/cliproxy/routing-strategy-http')>;
}
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'
);
});
});
@@ -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');
});
});
});
@@ -1,65 +1,26 @@
import { describe, expect, it, mock } from 'bun:test';
import { describe, expect, it } from 'bun:test';
const ensureBinaryCalls: Array<unknown> = [];
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 <T>(fn: () => Promise<T>) => 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<unknown> = [];
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,
@@ -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')));
@@ -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', () => {
@@ -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(
+24 -2
View File
@@ -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'
@@ -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();
}
});
});
+43
View File
@@ -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);
});
});
// ============================================================================
@@ -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(
[
@@ -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');
});
});
+128 -55
View File
@@ -268,6 +268,7 @@ type MockPageState = {
id: string;
title: string;
currentUrl: string;
targetType?: string;
fileInputs?: Record<string, MockFileInputPlan>;
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,
+26 -7
View File
@@ -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);
+159 -1
View File
@@ -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);
});
});
});
@@ -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<Response>((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');
});
});
@@ -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', () => {
@@ -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);
Loaded 100 of 187 files, more files were not shown because too many files have changed in this diff. Show more