mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 20:17:03 +00:00
Merge pull request #1125 from kaitranntt/dev
feat(release): promote dev to main
This commit is contained in:
187 files changed
+13507
-1598
No files matched your search
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
| |
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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' } : {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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'],
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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}]`;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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) */
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
Reference in new issue
Block a user