@timo972/cc-router 0.12.1-rc.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,8 +8,16 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ---
12
+
13
+ ## [0.12.1] — 2026-09-14
14
+
11
15
  ### Added
12
16
 
17
+ - Dashboard `Ctrl+R` confirms redemption of one banked usage-limit reset for
18
+ the focused ChatGPT account, with duplicate-submission protection and stable
19
+ retry IDs for uncertain outcomes. No custom Meta key mapping is needed.
20
+ Account usage is refreshed after redemption.
13
21
  - Dashboard `[R]` reloads account usage and due tokens without restarting the
14
22
  router or dropping active requests and sticky sessions. It also refreshes
15
23
  Grok snapshots, CLI routing state, and an already-loaded model list, with
@@ -23,6 +31,10 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
23
31
 
24
32
  ### Changed
25
33
 
34
+ - Streamlined the README and split detailed setup and reference material into
35
+ dedicated guides, including the full dashboard UI and keybindings, first-class
36
+ Codex CLI setup, and Grok/xAI account monitoring (overview-only, not proxied).
37
+ Updated the dashboard screenshot with anonymized account names.
26
38
  - The configured proxy request timeout now bounds Codex response headers and
27
39
  upstream stream inactivity on both `/v1/responses` and OpenAI-routed
28
40
  `/v1/messages`, including failover attempts. Progressing streams can outlive
@@ -709,6 +721,7 @@ cache-aware session routing and a round of security hardening.
709
721
  - `http-proxy-middleware` 3.0.5 → 3.0.7 for GHSA-gcq2-9pq2-cxqm (high). The
710
722
  affected APIs are not used here.
711
723
 
724
+ [0.12.1]: https://github.com/Timo972/cc-router/releases/tag/v0.12.1
712
725
  [0.12.0]: https://github.com/Timo972/cc-router/releases/tag/v0.12.0
713
726
  [0.11.0]: https://github.com/Timo972/cc-router/releases/tag/v0.11.0
714
727
  [0.9.0]: https://github.com/Timo972/cc-router/releases/tag/v0.9.0
package/README.md CHANGED
@@ -1,676 +1,151 @@
1
+ <div align="center">
2
+
1
3
  # CC-Router
2
4
 
3
- **Local multi-account router for Claude Max and OpenAI ChatGPT/Codex subscriptions.**
4
- Distribute Claude Code requests across Claude subscriptions, and expose an OpenAI Responses-compatible route for Codex CLI through the same proxy.
5
+ **One local proxy. All of your Claude and ChatGPT subscriptions.**
6
+
7
+ Route Claude Code, Codex CLI and Claude Desktop across every subscription you own —
8
+ cache-aware, with automatic failover, and without changing how you work.
5
9
 
6
10
  [![npm](https://img.shields.io/npm/v/@timo972/cc-router)](https://www.npmjs.com/package/@timo972/cc-router)
7
11
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
12
+ [![Node](https://img.shields.io/badge/node-%E2%89%A520-brightgreen)](https://nodejs.org)
8
13
 
9
- > **An actively maintained fork of [VictorMinemu/CC-Router](https://github.com/VictorMinemu/CC-Router)**, published as
10
- > [`@timo972/cc-router`](https://www.npmjs.com/package/@timo972/cc-router) with bug fixes and added features — most
11
- > notably **cache-aware sticky session routing**, which pins each Claude Code session to one account so a conversation
12
- > keeps hitting the same prompt cache instead of scattering its shared prefix across accounts. Also includes
13
- > load-aware account leases, byte-transparent streaming fixes, and a round of security hardening.
14
- > See [CHANGELOG.md](CHANGELOG.md) for the full list.
14
+ [Quickstart](#quickstart) · [Documentation](docs/) · [Changelog](CHANGELOG.md) · [Disclaimer](#disclaimer)
15
15
 
16
16
  ![CC-Router Dashboard](assets/dashboard.png)
17
17
 
18
- ### Features
19
-
20
- - **Cache-aware session routing** — keep each Claude Code session on one account while distributing new sessions across 2-20 Claude Max accounts
21
- - **Multi-provider routing** — route `openai/*` and unprefixed `gpt-*` models to OpenAI ChatGPT/Codex subscription accounts and Claude models to Claude subscriptions
22
- - **Transparent Claude proxy** — Claude Code works normally; streaming, thinking, tool use, prompt caching all pass through
23
- - **Codex CLI support** — configure Codex to use CC-Router as a Responses-compatible provider
24
- - **Automatic token refresh** — OAuth tokens are refreshed before they expire, saved atomically to disk
25
- - **Model-aware rate limits** — avoids accounts whose requested-model or global allowance is exhausted, and respects scoped cooldowns
26
- - **Automatic failover & retry** — a 429 fails over to another account and a 5xx is retried inside the router, before any response byte is relayed, on both the Claude and Codex routes; on by default, opt out with `"autoFailover": false`
27
- - **Client mode** — connect another device you own to your private CC-Router (`cc-router client connect <url>`)
28
- - **Claude Desktop support** — route Cowork / Agent-mode traffic through CC-Router via mitmproxy interception (macOS, Windows, Linux)
29
- - **Guided setup wizard** — interactive `cc-router setup` extracts tokens from Keychain or credentials file, configures everything
30
- - **Live dashboard** — real-time terminal UI showing account health, request counts, token usage, recent activity
31
- - **Proxy authentication** — Bearer / x-api-key secret; required when binding a non-loopback interface
32
- - **Update notifications** — new releases are announced in the CLI; installing is opt-in (`autoUpdate: true`)
33
- - **Multiple deployment modes** — background daemon, native OS auto-start (launchd/systemd), foreground, Docker Compose
34
- - **Cross-platform** — macOS, Linux, Windows; Node.js 20+
18
+ </div>
35
19
 
36
20
  ---
37
21
 
38
- > **Warning**
39
- > Read the [disclaimer](#disclaimer) before using this tool.
22
+ ## Why
40
23
 
41
- ---
24
+ One Claude Max subscription is one rate limit. If you spend your day in Claude Code you
25
+ know the shape of it: a long session, a wall of `429`, and a cooldown you have to sit out.
26
+ Current Claude Code builds no longer retry rate limits themselves, so it surfaces as an
27
+ error mid-conversation.
42
28
 
43
- ## How it works
29
+ CC-Router is a local proxy that sits between your tooling and the providers. It spreads
30
+ sessions across every subscription you own and fails a rate-limited request over to
31
+ another account *before a single response byte reaches the client*.
44
32
 
33
+ ```text
34
+ 1 account → hit the limit, the session errors out
35
+ 3 accounts → sessions spread across all three; a 429 fails over mid-flight
45
36
  ```
46
- Claude Code (terminal) ─┐
47
- │ ANTHROPIC_BASE_URL=http://localhost:3456
48
-
49
- Claude Desktop ─[mitmproxy]─┐ (optional — intercepts api.anthropic.com)
50
-
51
-
52
- ┌─────────────────────────────────────┐
53
- │ CC-Router :3456 │
54
- │ │
55
- │ 1. Receives /v1/messages or │
56
- │ /v1/responses │
57
- │ 2. Parses model provider prefix │
58
- │ 3. Picks a Claude or OpenAI account│
59
- │ 4. Refreshes token if expiring │
60
- │ 5. Injects Authorization: Bearer │
61
- │ 6. Forwards to Anthropic, OpenAI │
62
- │ Codex backend, or LiteLLM │
63
- └──────────────┬──────────────────────┘
64
-
65
-
66
- api.anthropic.com
67
- (authenticated with
68
- OAuth token of account N)
69
- ```
70
-
71
- All standard Claude Code features work transparently on the Claude route: streaming, extended thinking, tool use, prompt caching. OpenAI subscription routing is available for Codex-compatible Responses requests and Claude Code cross-routing with the limitations documented below.
72
-
73
- ### Cache-aware Claude account routing
74
-
75
- CC-Router keeps requests from one Claude Code session on the same eligible Anthropic subscription account. This session affinity remains account-based and preserves prompt-cache locality instead of scattering a conversation's shared prefix across account-specific caches. The model requested by each Messages call affects whether the bound account is still eligible; changing models does not create a second binding, but it can make the existing binding fail over when that account cannot serve the new model. New sessions prefer the account with the fewest in-flight requests, then the fewest bound sessions, then included allowance over paid extra usage, then the most applicable global and requested-model headroom; exact ties use a rotating round-robin order.
76
37
 
77
- Anthropic cooldowns, effective global or requested-model quota exhaustion, disabled accounts, invalid authentication, and unhealthy accounts are hard exclusions. The configured per-account percentage caps are softer policy controls: when at least one account is otherwise usable but every usable account is over a configured cap, CC-Router may explicitly fall back to the least-loaded capped account. It never uses that fallback to bypass an Anthropic cooldown or exhausted effective quota.
38
+ The part that makes it usable day to day: each conversation stays **pinned to one
39
+ account**. A Claude Code turn resends its whole prior context, and Anthropic caches that
40
+ prefix *per account* — scatter the turns and you re-pay for the cache every time. CC-Router
41
+ distributes *new* sessions instead of individual requests, so your cache stays warm.
78
42
 
79
- If an upstream account returns 429 or any 5xx before a single response byte has been relayed, CC-Router applies the failure's cooldown and affinity bookkeeping and then retries the request itself, up to 3 upstream attempts per request. A 429 (or an overload the provider cools down: Anthropic 529; Codex 503/529) always fails over to a *different* account. Any other 5xx keeps a session-bound request on its own account, retrying after a short pause; a session-less request re-routes the way a fresh request would — typically an idle other account, exactly where the client's own retry used to land. The failover is on by default; set `"autoFailover": false` in `~/.cc-router/config.json` (and restart the router) to opt out — every upstream failure then passes through unchanged and clients own all retries, as before. Be aware that current Claude Code builds no longer retry 429s themselves, so with failover off a rate limit surfaces directly in the session as an error. One trade-off worth knowing: once the router commits to a retry it abandons the original failure response, so a network error on the retry attempt surfaces as a local 502 rather than the original 429. When no other account is eligible or the budget is exhausted, the last failed upstream response is passed through unchanged, exactly as before. A 401 is always passed through (with a background token refresh), and the router never retries after response bytes have started — mid-stream failures reach the client untouched. If no account is usable before forwarding begins, the router instead returns a local Anthropic-shaped 429 whenever any account is blocked by a rate limit or exhausted quota. That 429 includes `Retry-After` only when a trustworthy unblock time is known. A local 503 is reserved for entirely non-rate-limit unavailability, such as all accounts being disabled or unhealthy. Either local response makes no Anthropic Messages request. Affinity mappings exist only in process memory, expire after one hour of inactivity, and are capped in size. Session IDs are never persisted or logged.
43
+ Everything else is unchanged. Same `claude`, same `codex`, same streaming, extended
44
+ thinking, tool use and prompt caching — passed through byte for byte.
80
45
 
81
- Streaming remains byte-transparent. In particular, CC-Router never appends a synthetic `message_stop` event. `proxyRequestTimeoutMs` protects only the phase before Anthropic response headers arrive; once a response starts, its body continues through the native byte-exact proxy pipe. Automatic `cc-router configure` setup manages Claude Code's event-level and byte-level stream idle watchdogs at 30 minutes. Restart any existing Claude Code process after configuration so it inherits those values.
46
+ ## Features
82
47
 
83
- **Claude Desktop support** is opt-in and requires a small interceptor (mitmproxy) because Claude Desktop doesn't expose a custom API endpoint setting. See [Claude Desktop support](#claude-desktop-support).
48
+ - **Cache-aware session routing** pins each session to one account, spreads new sessions across 2–20 accounts ([details](docs/session-routing.md))
49
+ - **Automatic failover & retry** — a `429` moves to another account, a `5xx` is retried in-router, always before the first response byte
50
+ - **Multi-provider routing** — model prefixes send `claude/*` to Claude subscriptions and `openai/*` / `gpt-*` to ChatGPT/Codex ([details](docs/codex.md))
51
+ - **Model-aware rate limits** — skips accounts whose global or per-model allowance is spent, and respects scoped cooldowns
52
+ - **Automatic token refresh** — OAuth tokens refresh before expiry and are written atomically ([details](docs/oauth-tokens.md))
53
+ - **Live dashboard** — terminal UI for account health, usage windows, live activity, and routing toggles ([details](docs/dashboard.md))
54
+ - **Grok CLI overview** — your Grok/xAI subscription's plan and session state in the same dashboard, alongside the accounts that are routed ([details](docs/grok.md))
55
+ - **Guided setup** — `cc-router setup` pulls tokens from the Keychain or credentials file and wires up your clients
56
+ - **Client mode** — point another device you own at your private router ([details](docs/client-mode.md))
57
+ - **Flexible deployment** — background daemon, native auto-start (launchd/systemd), foreground, or Docker Compose ([details](docs/installation.md))
58
+ - **Locked down by default** — tokens stay on your machine, proxy auth is required on non-loopback binds ([details](docs/security.md))
84
59
 
85
- ---
60
+ ## Supported platforms and harnesses
86
61
 
87
- ## Use cases
62
+ **Routed** requests are proxied to these:
88
63
 
89
- ### Heavy user one account isn't enough
64
+ | Platform | Auth | Route |
65
+ |---|---|---|
66
+ | Claude Max / Pro subscriptions | OAuth (subscription) | `/v1/messages` |
67
+ | OpenAI ChatGPT / Codex subscriptions | OAuth device code | `/v1/responses` |
68
+ | Anything LiteLLM supports (optional) | API keys, via LiteLLM | `/v1/messages` ([setup](docs/litellm-setup.md)) |
90
69
 
91
- Claude Max has rate limits per account. If you hit them regularly mid-session — waiting for cooldowns, getting 429s — you're a good candidate.
70
+ **Monitored** tracked and shown in the dashboard, not proxied:
92
71
 
93
- With two accounts you double your effective rate limit. With three, you triple it. The proxy distributes requests automatically; you don't change how you use Claude Code at all.
72
+ | Platform | Auth | What you get |
73
+ |---|---|---|
74
+ | Grok / xAI subscriptions | Device code, or import from Grok CLI | Plan, code access, active sessions, token health ([details](docs/grok.md)) |
94
75
 
95
- ```text
96
- 1 account → hit limit, session errors out (current Claude Code no longer retries 429s)
97
- 3 accounts → sessions spread across all three; a rate-limited request fails over mid-flight
98
- ```
76
+ **Harnesses**
99
77
 
100
- ---
78
+ | Harness | Support | Notes |
79
+ |---|---|---|
80
+ | Claude Code | First class | Configured automatically by `cc-router setup` |
81
+ | Codex CLI | First class | Configured by `cc-router configure codex` ([setup](docs/codex.md)) |
82
+ | Claude Desktop (chat + Cowork) | Opt-in | Needs a mitmproxy interceptor ([setup](docs/claude-desktop.md)) |
83
+ | Any Anthropic Messages client | Works | Point `ANTHROPIC_BASE_URL` at the router |
84
+ | Any OpenAI Responses client | Works | Point the base URL at `/v1` |
101
85
 
102
86
  ## Quickstart
103
87
 
88
+ Requires **Node.js 20 or 22** on macOS, Linux or Windows.
89
+
104
90
  ```bash
105
91
  # 1. Install
106
92
  npm install -g @timo972/cc-router
107
93
 
108
- # 2. Wizard: extract tokens + configure Claude Code automatically
94
+ # 2. Extract tokens and configure your clients
109
95
  cc-router setup
110
96
 
111
97
  # 3. Start the proxy
112
98
  cc-router start
113
99
 
114
- # 4. Use Claude Code normally — the proxy is transparent
100
+ # 4. Use Claude Code as usual — the proxy is transparent
115
101
  claude
116
102
  ```
117
103
 
118
- That's it. Claude Code will route through the proxy without any further changes.
119
-
120
- On first run, `cc-router start` asks how you want to run (background/foreground, auto-start on boot, server mode) and remembers your choice. Next time, it just starts. To change preferences later:
121
- ```bash
122
- cc-router start --reconfigure
123
- ```
124
-
125
- ---
126
-
127
- ## Installation
128
-
129
- **Requirements:** Node.js 20 or 22.
130
-
131
- ```bash
132
- npm install -g @timo972/cc-router
133
- ```
134
-
135
- Verify:
136
- ```bash
137
- cc-router --version
138
- ```
139
-
140
- ---
141
-
142
- ## Setup by platform
143
-
144
- ### macOS
145
-
146
- cc-router can extract OAuth tokens directly from the macOS Keychain — no manual copy-pasting needed.
147
-
148
- ```bash
149
- cc-router setup
150
- # Select "Extract automatically from macOS Keychain"
151
- ```
152
-
153
- For multiple accounts, you need to switch accounts in Claude Code between extractions:
154
- ```bash
155
- # Account 1 is already logged in — run setup and extract
156
- cc-router setup
157
-
158
- # To add account 2:
159
- claude logout && claude login # log in with account 2
160
- cc-router setup --add # extract and merge
161
- claude logout && claude login # log back in with account 1
162
- ```
163
-
164
- ### Linux
165
-
166
- Tokens are read from `~/.claude/.credentials.json`:
167
- ```bash
168
- cc-router setup
169
- # Select "Read from ~/.claude/.credentials.json"
170
- ```
171
-
172
- Make sure Claude Code is installed and you have run `claude login` at least once.
173
-
174
- ### Windows
175
-
176
- Same as Linux — tokens are read from `~/.claude/.credentials.json` (Windows path: `%USERPROFILE%\.claude\.credentials.json`).
177
-
178
- ```bash
179
- cc-router setup
180
- ```
181
-
182
- ---
183
-
184
- ## CLI Reference
185
-
186
- ```text
187
- cc-router setup Interactive wizard: extract tokens + configure Claude Code
188
- cc-router setup --add Add another account to an existing configuration
189
-
190
- cc-router start Start proxy (asks preferences on first run, then remembers)
191
- cc-router start --foreground Run in the foreground (stays in terminal)
192
- cc-router start --reconfigure Re-ask run preferences (background/service/server mode)
193
- cc-router start --litellm Start with LiteLLM in Docker (advanced mode)
194
-
195
- cc-router stop Stop proxy (offers to remove auto-start / config)
196
- cc-router stop --keep-config Stop proxy only (keep settings.json)
197
- cc-router stop --full Stop + remove auto-start + revert Claude Code (no prompts)
198
- cc-router revert Same as stop --full
199
-
200
- cc-router status Live dashboard (updates every 2s, press q to quit)
201
- cc-router status --json Print current stats as JSON and exit
202
-
203
- cc-router models list List models discovered live from provider APIs
204
- cc-router models list --json Print discovered models + routing as JSON
205
- cc-router models set --claude-model anthropic/claude-sonnet-4-6
206
- cc-router models set --openai-model openai/gpt-5-codex
207
-
208
- cc-router logs View proxy logs (background mode)
209
- cc-router logs -f Follow log output in real time
210
- cc-router logs --lines 100 Show last 100 lines
211
-
212
- cc-router accounts list List configured accounts (live stats if proxy is running)
213
- cc-router accounts add Add an account interactively
214
- cc-router accounts login-openai Sign in to OpenAI subscription auth with device code
215
- cc-router accounts add-openai Add an OpenAI subscription account manually (experimental)
216
- cc-router accounts remove <id> Remove a Claude or OpenAI account
217
-
218
- cc-router configure (Re)write ~/.claude/settings.json
219
- cc-router configure codex (Re)write ~/.codex/config.toml for Codex CLI
220
- cc-router configure codex --model openai/gpt-5-codex
221
- cc-router configure models --claude-model claude-sonnet-4-6 --openai-model gpt-5-codex
222
- cc-router configure --show Show current Claude Code proxy settings
223
- cc-router configure --remove Remove cc-router settings from Claude Code (proxy stays up)
224
- cc-router configure codex --remove Remove the Codex managed block (proxy stays up)
225
-
226
- cc-router cli Show whether Claude Code and Codex are routing through the proxy
227
- cc-router cli claude start Point Claude Code at the running proxy
228
- cc-router cli claude stop Restore Claude Code to native Anthropic auth (proxy stays up)
229
- cc-router cli claude resume Same as cli claude start
230
- cc-router cli codex start Point Codex CLI at the running proxy
231
- cc-router cli codex stop Restore Codex CLI to native OpenAI auth (proxy stays up)
232
- cc-router cli codex resume Same as cli codex start
233
- cc-router claude … / cc-router codex … Hidden shortcuts for the same commands
234
-
235
- cc-router client connect <url> Connect Claude Code to a remote CC-Router
236
- cc-router client connect --desktop Also configure Claude Desktop interception
237
- cc-router client disconnect Revert all client configuration
238
- cc-router client status Show connection + remote server health
239
- cc-router client start-desktop Start mitmproxy interceptor for Claude Desktop
240
- cc-router client stop-desktop Stop mitmproxy interceptor
241
-
242
- cc-router docker up Start full Docker stack (cc-router + LiteLLM)
243
- cc-router docker up --build Rebuild cc-router image before starting
244
- cc-router docker down Stop Docker containers
245
- cc-router docker logs Tail all Docker logs
246
- cc-router docker ps Show container status
247
- cc-router docker restart [service] Restart a service
248
- ```
249
-
250
- ---
251
-
252
- ## Modes of operation
253
-
254
- ### Standalone (default — no Docker)
255
-
256
- ```text
257
- Claude Code → cc-router:3456 → api.anthropic.com
258
- ```
259
-
260
- Best for personal use. No Docker required. Runs in the background by default, auto-starts on boot if you choose.
261
-
262
- ```bash
263
- cc-router start
264
- ```
265
-
266
- ### Full mode with LiteLLM (optional — requires Docker)
267
-
268
- ```text
269
- Claude Code → cc-router:3456 → LiteLLM:4000 → api.anthropic.com
270
- ```
271
-
272
- Adds a LiteLLM layer for usage logging, rate limiting, and a web dashboard at `http://localhost:4000/ui`.
273
-
274
- ```bash
275
- cc-router docker up
276
- # or: cc-router start --litellm
277
- ```
278
-
279
- See [docs/litellm-setup.md](docs/litellm-setup.md) for details.
280
-
281
- ---
282
-
283
- ## Codex CLI support (experimental)
284
-
285
- CC-Router exposes an OpenAI Responses-compatible endpoint for Codex CLI at `/v1/responses`. This lets Codex use OpenAI ChatGPT/Codex subscription accounts through the same local router that Claude Code uses for Claude subscriptions.
286
-
287
- **Features:** Sticky sessions pin each Codex conversation to one account for prompt-cache locality. Load- and headroom-aware account selection spreads new sessions across available capacity. Usage tracking from response headers reports account-level 5-hour and 7-day windows, dynamically discovered model-scoped metered buckets, credits, and plan. User caps (`sessionLimitPercent`/`weeklyLimitPercent`) apply to the default Codex bucket. The dashboard shows per-bucket rows, usage bars, credits, plan, and cooldown state for OpenAI accounts.
288
-
289
- Configure Codex:
290
-
291
- ```bash
292
- cc-router configure codex --model openai/gpt-5-codex
293
- ```
294
-
295
- This writes a managed provider block to `~/.codex/config.toml`:
296
-
297
- ```toml
298
- model = "openai/gpt-5-codex"
299
- model_provider = "cc-router"
300
-
301
- [model_providers.cc-router]
302
- name = "CC-Router"
303
- base_url = "http://localhost:3456/v1"
304
- wire_api = "responses"
305
- env_key = "CC_ROUTER_TOKEN"
306
- ```
307
-
308
- Configure router-side model defaults and aliases:
309
-
310
- ```bash
311
- cc-router configure models \
312
- --claude-model claude-sonnet-4-6 \
313
- --openai-model gpt-5-codex
314
- ```
315
-
316
- This writes `modelRouting` to `~/.cc-router/config.json`. It sets the Claude default, the OpenAI default, and practical aliases so `claude/sonnet`, `sonnet`, `openai/default`, and `openai/codex` resolve to the models you selected. Restart the router after changing these values.
317
-
318
- Model discovery is dynamic. `GET /v1/models` returns an OpenAI-compatible model list by querying the configured Anthropic and OpenAI subscription APIs live:
319
-
320
- ```bash
321
- curl http://localhost:3456/v1/models
322
- ```
323
-
324
- Results are provider-prefixed, for example `anthropic/claude-sonnet-4-6` and `openai/gpt-5-codex`. Configured aliases such as `openai/codex` are added when their upstream model is available. If one provider is temporarily unavailable, CC-Router still returns the models discovered from the other providers.
325
-
326
- Then run Codex with the proxy secret in `CC_ROUTER_TOKEN` when your router is password-protected:
327
-
328
- ```bash
329
- CC_ROUTER_TOKEN=cc-rtr-your-secret codex -m openai/gpt-5.5
330
- ```
331
-
332
- Model prefixes:
333
-
334
- | Model | Upstream |
335
- |--------|----------|
336
- | `openai/*` | OpenAI ChatGPT/Codex subscription route |
337
- | `gpt-*` (no prefix) | OpenAI ChatGPT/Codex subscription route |
338
- | `claude/*` | Claude subscription route |
339
- | `anthropic/*` | Claude subscription route |
340
- | anything else with no prefix | Claude subscription route |
341
-
342
- The unprefixed `gpt-*` rule exists for clients that do not speak this
343
- convention. The Codex CLI writes the bare slug from its own registry — either
344
- `model = "gpt-5.6-sol"` in `~/.codex/config.toml` or whatever its `/model`
345
- picker selects — so those names arrive without a prefix and would otherwise be
346
- routed to Claude, where `/v1/responses` answers `501`. Configured
347
- `openAIAliases` apply to the bare form too.
348
-
349
- Examples after the configuration above:
350
-
351
- | Public model | Routed upstream model |
352
- |--------------|----------------------|
353
- | `openai/codex` | `gpt-5-codex` |
354
- | `openai/default` | `gpt-5-codex` |
355
- | `claude/sonnet` | `claude-sonnet-4-6` |
356
-
357
- Claude Code can also send a `/v1/messages` request with an `openai/*` model. CC-Router translates that Anthropic Messages request into an OpenAI Responses request and converts JSON or basic text SSE responses back into Anthropic-shaped message responses.
358
-
359
- OpenAI-to-Anthropic conversion supports text and function tool calls in both streaming and non-streaming responses.
360
-
361
- OpenAI subscription account records are separated from Claude accounts with `provider: "openai_subscription"` so they do not enter the Anthropic token pool:
362
-
363
- ```json
364
- {
365
- "id": "openai-primary",
366
- "provider": "openai_subscription",
367
- "accessToken": "eyJ...",
368
- "refreshToken": "...",
369
- "expiresAt": 1999999999000,
370
- "scopes": ["openid", "profile", "email", "offline_access"]
371
- }
372
- ```
373
-
374
- Recommended OpenAI subscription login:
375
-
376
- ```bash
377
- cc-router accounts login-openai
378
- ```
379
-
380
- This uses the Codex device-code auth flow: the CLI prints a verification URL and one-time code, you approve the login in your browser, and CC-Router saves the resulting OpenAI subscription account record.
381
-
382
- Manual account entry is also available for debugging:
383
-
384
- ```bash
385
- cc-router accounts add-openai
386
- ```
387
-
388
- This prompts for the OpenAI access token, refresh token, expiry timestamp, and scopes, validates the record shape, and saves it without overwriting Claude accounts.
389
-
390
- ---
391
-
392
- ## Client mode — connecting your own devices
393
-
394
- Client mode lets you connect another device you own to your private CC-Router over a trusted private network. It is not intended for sharing subscription accounts or proxy access with other people, or for exposing CC-Router to the public internet.
395
-
396
- The setup wizard asks about this at the very first step:
397
-
398
- ```bash
399
- cc-router setup
400
- # → What do you want to do?
401
- # • Host CC-Router on this machine
402
- # • Connect to your existing CC-Router server ← pick this
403
- ```
404
-
405
- Or use the dedicated command directly:
406
-
407
- ```bash
408
- # Connect another device you own over your private network
409
- cc-router client connect http://192.168.1.50:3456 --secret cc-rtr-abc123...
410
-
411
- # Check status
412
- cc-router client status
413
-
414
- # Disconnect (restores Claude Code defaults)
415
- cc-router client disconnect
416
- ```
417
-
418
- Client mode writes `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` into `~/.claude/settings.json`, so Claude Code talks directly to the remote proxy. Nothing runs locally — no accounts, no proxy process, no resources.
419
-
420
- ### CLI reference
421
-
422
- ```text
423
- cc-router client connect <url> Connect Claude Code to a CC-Router server
424
- cc-router client connect --desktop Also configure Claude Desktop interception
425
- cc-router client connect -s <secret> Pass the proxy secret inline (or use --secret)
426
- cc-router client disconnect Revert all client configuration
427
- cc-router client status Show current connection + remote server health
428
- cc-router client start-desktop Start the Claude Desktop mitmproxy interceptor
429
- cc-router client stop-desktop Stop the Claude Desktop interceptor
430
- ```
431
-
432
- ---
433
-
434
- ## Claude Desktop support
435
-
436
- Claude Desktop (chat + Cowork) **can be routed through CC-Router**, but unlike Claude Code it does not respect `ANTHROPIC_BASE_URL`. It talks directly to `api.anthropic.com` via an embedded Anthropic SDK. To redirect its traffic, CC-Router uses [mitmproxy](https://mitmproxy.org/) in *local redirect mode* — a process-scoped interceptor that only captures Claude Desktop's network traffic and forwards it to the proxy.
437
-
438
- This is **opt-in** — the setup wizard will ask if you want it.
439
-
440
- ### Requirements
441
-
442
- - **mitmproxy ≥ 10.1.5** (macOS, Windows) or **≥ 11.1** (Linux — requires kernel ≥ 6.8)
443
- - Admin access to install the mitmproxy CA certificate
444
- - On macOS: manual approval of mitmproxy's Network Extension (one time, via System Settings)
445
-
446
- ### Installing mitmproxy
447
-
448
- ```bash
449
- # macOS
450
- brew install mitmproxy
451
-
452
- # Windows
453
- # Download the installer from https://mitmproxy.org/downloads/
454
- # (or: pip install mitmproxy)
455
-
456
- # Linux
457
- pip install mitmproxy # kernel 6.8+ required for local mode
458
- ```
459
-
460
- ### Enabling Desktop interception
461
-
462
- During `cc-router setup` or `cc-router client connect`, answer **Yes** when asked about Claude Desktop. The wizard will:
463
-
464
- 1. Check that mitmproxy is installed
465
- 2. Generate the mitmproxy CA certificate (if not already present)
466
- 3. Install the CA into the OS trust store (requires sudo/admin)
467
- 4. Write the redirect addon to `~/.cc-router/interceptor/addon.py`
468
- 5. On macOS, prompt you to approve the Network Extension
469
-
470
- Then start the interceptor:
471
-
472
- ```bash
473
- cc-router client start-desktop
474
- ```
475
-
476
- Open Claude Desktop and send a message. The request will be intercepted and redirected to CC-Router. Requests carrying exactly one valid `X-Claude-Code-Session-Id` receive cache-aware sticky affinity; requests without one valid session header use load-aware **unscoped** routing and do not receive sticky affinity. Claude Desktop traffic normally follows the unscoped path.
477
-
478
- ### Stopping / removing Desktop interception
479
-
480
- ```bash
481
- cc-router client stop-desktop # Stop the interceptor (keep configuration)
482
- cc-router client disconnect # Stop + remove all client config
483
- ```
484
-
485
- ### How it works under the hood
486
-
487
- ```
488
- Claude Desktop
489
-
490
- │ tries to connect to api.anthropic.com:443
491
-
492
- mitmproxy (local mode)
493
- │ addon.py rewrites scheme/host to CC-Router
494
-
495
- CC-Router :3456 ──► api.anthropic.com (with OAuth Bearer token)
496
- ```
497
-
498
- mitmproxy's local mode is *process-scoped* — it only intercepts traffic from the Claude process, not your browser, curl, or any other app. The OS-level interception uses:
499
-
500
- | Platform | Mechanism |
501
- |----------|-----------|
502
- | macOS | Network Extension (App Proxy Provider API) |
503
- | Windows | WinDivert (WFP kernel driver) |
504
- | Linux | eBPF (kernel ≥ 6.8) |
505
-
506
- ### Troubleshooting
507
-
508
- - **macOS: "provider rejected new flow"** — re-enable Mitmproxy Redirector in System Settings → General → Login Items & Extensions → Network Extensions, then restart mitmproxy.
509
- - **Windows: UAC prompt every start** — expected; mitmproxy's redirector needs admin at runtime.
510
- - **Linux: "eBPF program failed to load"** — check your kernel version with `uname -r`. You need ≥ 6.8.
511
- - **Chat shows "failed to connect"** — make sure CC-Router is reachable from the mitmproxy process. Run `curl http://localhost:3456/cc-router/health` to verify the proxy is up.
512
-
513
- ---
514
-
515
- ## Toggle Claude Code or Codex while the proxy stays up
516
-
517
- `cc-router start` / `stop` control the proxy process. To send only one CLI back to native auth (or point it at the proxy again) without tearing the router down:
518
-
519
- ```bash
520
- cc-router cli # Claude Code + Codex routing state
521
- cc-router cli claude stop # Claude Code → native Anthropic auth
522
- cc-router cli claude resume # Claude Code → running proxy (alias of start)
523
- cc-router cli codex start # Codex CLI → running proxy
524
- cc-router cli codex stop # Codex CLI → native OpenAI auth
525
- ```
526
-
527
- `cc-router claude …` and `cc-router codex …` are shortcuts for the same commands. `cli` is the grouping — not `provider`, which already means the Anthropic/OpenAI account pool. `cc-router client` remains remote client-mode (connect this machine to another CC-Router).
528
-
529
- These rewrite `~/.claude/settings.json` or the managed block in `~/.codex/config.toml`. The proxy keeps listening. Restart any already-running Claude Code or Codex process so it picks up the new config. From `cc-router status`, `[c]` / `[x]` do the same toggles.
530
-
531
- ---
532
-
533
- ## Reverting to normal Claude Code
534
-
535
- To stop using cc-router and go back to normal Claude Code authentication:
536
-
537
- ```bash
538
- cc-router revert
539
- ```
540
-
541
- This stops the proxy process, removes the auto-start service (if installed), and removes cc-router's settings from `~/.claude/settings.json`. Claude Code will use its own authentication on the next launch.
542
-
543
- For a gentler approach, `cc-router stop` interactively asks what you want to clean up.
544
-
545
- ---
546
-
547
- ## Status dashboard
548
-
549
- ```bash
550
- cc-router status
551
- ```
552
-
553
- ```text
554
- CC-Router · standalone → api.anthropic.com · up 2h 14m · [q] quit
555
-
556
- OPERATIONS base http://localhost:3456 · auth protected · models dynamic
557
- Claude 2/2 healthy OpenAI 1/1 healthy · cross-route ready
558
- endpoints /v1/messages /v1/responses /v1/models /cc-router/accounts
559
- routing claude=claude-sonnet-4-6 aliases[sonnet] openai=gpt-5-codex aliases[codex]
560
- cli Claude on Codex off [c]/[x] · models [m] then [c]/[o] defaults
561
-
562
- MODELS [m/r] refresh [↑/↓] select [c] Claude default [o] OpenAI default
563
- current claude=claude-sonnet-4-6 openai=gpt-5-codex
564
- ▶ anthropic/claude-sonnet-4-6 Claude
565
- openai/gpt-5-codex OpenAI
566
-
567
- ACCOUNTS 2/2 healthy
568
-
569
- ● max-account-1 ok req 142 err 0 expires 6h 48m last 2s ago
570
- ● max-account-2 ok req 139 err 0 expires 6h 51m last 5s ago
571
-
572
- TOTALS requests 281 · errors 0 · refreshes 2
573
-
574
- RECENT ACTIVITY
575
- 14:23:01 → max-account-1 route
576
- 14:22:58 → max-account-2 route
577
- 14:22:45 ↻ max-account-1 refresh
578
- ```
579
-
580
- Press `q` to quit. Run with `--json` for non-interactive output; the JSON includes an `operational` block with capabilities, endpoints, provider readiness, auth status, and model routing. Secrets and account tokens are never included.
581
-
582
- The dashboard is also a control surface. In local mode it controls the local proxy; in client mode it controls the remote CC-Router configured by `cc-router client connect`. Authenticated account views include dynamic model-scoped allowance rows, their reset times, applicable global or requested-model cooldowns, paid-extra state, and whether the usage snapshot is fresh, stale, or unavailable. A stale row is shown as unknown rather than as authoritative available capacity.
583
-
584
- | Key | Action |
585
- |-----|--------|
586
- | `Tab` | Switch focus between logs, accounts, and models |
587
- | `n` | Add a Claude account |
588
- | `e` | Enable/disable selected Claude account |
589
- | `w` / `s` | Change selected Claude account weekly/session cap |
590
- | `d` | Delete selected Claude account |
591
- | `m` / `r` | Load or refresh discovered provider models |
592
- | `c` | Toggle Claude Code routing (or set Claude model default when MODELS is focused) |
593
- | `x` | Toggle Codex CLI routing (proxy stays up) |
594
- | `o` | Set selected `openai/*` model as OpenAI default |
595
-
596
- List and change models without waiting for a package update:
597
-
598
- ```bash
599
- cc-router models list
600
- cc-router models set --claude-model anthropic/claude-sonnet-4-6
601
- cc-router models set --openai-model openai/gpt-5-codex
602
- ```
603
-
604
- When the proxy is running, `models set` updates the live router and persists the new defaults. If the proxy is offline, it writes the configuration for the next start.
605
-
606
- ---
607
-
608
- ## Security
609
-
610
- - Tokens are stored locally in `~/.cc-router/accounts.json`, **never in the repository**
611
- - The file is excluded by `.gitignore`
612
- - Writes are atomic (write to `.tmp`, then rename) — no corruption on crash
613
- - Keychain reads use `execFile` with a fixed argument array — no shell injection
614
- - Anonymous opt-out telemetry via [Aptabase](https://aptabase.com) (see [Telemetry](#telemetry) below)
615
-
616
- See [docs/security.md](docs/security.md) for details.
617
-
618
- ---
619
-
620
- ## Telemetry
621
-
622
- CC-Router sends a handful of anonymous lifecycle events to [Aptabase](https://aptabase.com) (privacy-first, open source, EU-hosted). The goal is simple: know how many people use the project, which versions are live, and roughly how many instances are running — so we can prioritize fixes and features.
623
-
624
- **What we send** — the entire payload lives in [`src/utils/telemetry.ts`](src/utils/telemetry.ts), audit it yourself:
625
-
626
- | Event | When | Custom props |
627
- | -------------------- | ------------------------------------------------ | ---------------------------------------- |
628
- | `app_started` | First proxy start after install | `first_run: true` |
629
- | `setup_completed` | Setup wizard finishes successfully | `account_count` |
630
- | `proxy_started` | Each `cc-router start` | `account_count`, `mode` |
631
- | `proxy_heartbeat` | Every hour while the proxy is running | `uptime_minutes`, `account_count` |
632
- | `telemetry_disabled` | When you run `cc-router telemetry off` | — |
633
-
634
- Plus anonymous system props with every event: `appVersion`, `osName` (macOS/Linux/Windows), `osVersion`, `locale`, `engineVersion` (Node), and an anonymous `installId` (random UUID generated on first run, stored in `~/.cc-router/telemetry.json`).
635
-
636
- **What we never send**: IPs, OAuth tokens, account names, request content, prompts, responses, URLs, hostnames, usernames, file paths — nothing that could identify you or your usage patterns.
637
-
638
- **Disable it** — three ways, any one works:
639
-
640
- ```bash
641
- # 1. Persistent opt-out (recommended)
642
- cc-router telemetry off
643
-
644
- # 2. Respect the de-facto standard (honored by many OSS tools)
645
- export DO_NOT_TRACK=1
646
-
647
- # 3. Project-specific override
648
- export CC_ROUTER_TELEMETRY=0
649
- ```
650
-
651
- Check status anytime: `cc-router telemetry status`.
652
-
653
- ---
104
+ That's it. On first `start` you're asked how to run the router (background, foreground, or
105
+ auto-start on boot) and the choice is remembered; `cc-router start --reconfigure` changes
106
+ it later. Adding more accounts is `cc-router setup --add`, and `cc-router status` opens the
107
+ dashboard.
108
+
109
+ Per-platform token extraction, Codex CLI, Docker and everything else lives in
110
+ [the docs](docs/).
111
+
112
+ ## Documentation
113
+
114
+ | Guide | What's in it |
115
+ |---|---|
116
+ | [Installation & deployment](docs/installation.md) | Per-platform token setup, run modes, Docker |
117
+ | [CLI reference](docs/cli-reference.md) | Every command and flag |
118
+ | [Session routing](docs/session-routing.md) | How an account gets picked, failover, team operation |
119
+ | [Architecture](docs/architecture.md) | Request path and components |
120
+ | [Dashboard](docs/dashboard.md) | Live TUI, keybindings, model management |
121
+ | [Codex CLI & OpenAI](docs/codex.md) | Responses endpoint, model prefixes, OpenAI accounts |
122
+ | [Grok / xAI](docs/grok.md) | Adding Grok accounts, and why they're overview-only |
123
+ | [Claude Desktop](docs/claude-desktop.md) | mitmproxy interception setup |
124
+ | [Client mode](docs/client-mode.md) | Connecting your other devices |
125
+ | [LiteLLM](docs/litellm-setup.md) | Optional logging and rate-limiting layer |
126
+ | [OAuth tokens](docs/oauth-tokens.md) | How subscription tokens and refresh rotation work |
127
+ | [Security](docs/security.md) | Token storage, proxy auth, threat model |
128
+ | [Telemetry](docs/telemetry.md) | Opt-in analytics: what's sent if you enable it |
129
+ | [Troubleshooting](docs/troubleshooting.md) | When something doesn't connect |
654
130
 
655
131
  ## Disclaimer
656
132
 
657
- > CC-Router uses the OAuth tokens of your own Claude Max subscriptions.
133
+ > CC-Router uses the OAuth tokens of **your own** Claude Max and ChatGPT subscriptions.
658
134
  >
659
- > **Read Anthropic's Terms of Service before using this tool.**
660
- > Using multiple Max subscriptions to increase throughput may violate the ToS. Anthropic has been known to ban accounts for unusual OAuth usage patterns.
661
- > Do not share subscription accounts, OAuth credentials, or CC-Router proxy access with other people.
135
+ > **Read Anthropic's and OpenAI's Terms of Service before using this tool.** Using multiple
136
+ > subscriptions to increase throughput may violate them. Anthropic has been known to ban
137
+ > accounts for unusual OAuth usage patterns.
662
138
  >
663
- > The authors are not responsible for any account bans, loss of access, or other consequences resulting from the use of this software. Use at your own risk.
664
-
665
- ---
139
+ > Do not share subscription accounts, OAuth credentials, or CC-Router proxy access with
140
+ > other people.
141
+ >
142
+ > The authors are not responsible for account bans, loss of access, or any other
143
+ > consequence of using this software. Use at your own risk.
666
144
 
667
145
  ## Contributing
668
146
 
669
- See [CONTRIBUTING.md](CONTRIBUTING.md).
670
-
671
- Bug reports → [GitHub Issues](https://github.com/Timo972/cc-router/issues)
672
-
673
- ---
147
+ Bug reports and feature requests go to [GitHub Issues](https://github.com/Timo972/cc-router/issues).
148
+ For development setup, code conventions and the PR process, see [CONTRIBUTING.md](CONTRIBUTING.md).
674
149
 
675
150
  ## License
676
151
 
@@ -678,5 +153,5 @@ Bug reports → [GitHub Issues](https://github.com/Timo972/cc-router/issues)
678
153
 
679
154
  This project began as a fork of [VictorMinemu/CC-Router](https://github.com/VictorMinemu/CC-Router)
680
155
  and is now maintained independently as [`@timo972/cc-router`](https://www.npmjs.com/package/@timo972/cc-router).
681
- It is not affiliated with the upstream project, and issues should be filed here rather than upstream.
156
+ It is not affiliated with the upstream project please file issues here rather than upstream.
682
157
  The original MIT copyright notice is retained in [LICENSE](LICENSE).
@@ -69,8 +69,13 @@ export class OpenAITokenPool {
69
69
  // Each cause extends its own horizon, so neither can misrepresent the
70
70
  // other's duration in either direction.
71
71
  state.globalUntil = Math.max(state.globalUntil, expiry);
72
- if (cause === "rate_limit")
72
+ if (cause === "rate_limit") {
73
73
  state.rateLimitedUntil = Math.max(state.rateLimitedUntil, expiry);
74
+ state.quotaRevision++;
75
+ }
76
+ else {
77
+ state.unavailableUntil = Math.max(state.unavailableUntil, expiry);
78
+ }
74
79
  }
75
80
  setBucketCooldownForAccount(account, limitId, durationMs) {
76
81
  const expiry = this.proposedExpiry(account, durationMs);
@@ -80,6 +85,7 @@ export class OpenAITokenPool {
80
85
  if (!state.bucketUntil.has(limitId))
81
86
  this.makeRoomForBucketCooldown(state);
82
87
  state.bucketUntil.set(limitId, Math.max(state.bucketUntil.get(limitId) ?? 0, expiry));
88
+ state.quotaRevision++;
83
89
  }
84
90
  /**
85
91
  * Keep `bucketUntil` bounded before a new limit id is added. Every distinct
@@ -114,6 +120,31 @@ export class OpenAITokenPool {
114
120
  if (soonestId !== undefined)
115
121
  state.bucketUntil.delete(soonestId);
116
122
  }
123
+ /** Capture before a redemption. Apply only after confirmed redemption and a
124
+ * fresh post-redemption usage fetch. New quota evidence conservatively fences
125
+ * the whole reconciliation; unrelated overload holds retain their own expiry. */
126
+ captureUsageReset(account) {
127
+ const state = this.cooldowns.get(account);
128
+ const revision = state?.quotaRevision;
129
+ return update => {
130
+ if (!state || this.findById(account.id) !== account
131
+ || this.cooldowns.get(account) !== state || state.quotaRevision !== revision)
132
+ return;
133
+ for (const bucket of update.buckets) {
134
+ const windows = [bucket.primary, bucket.secondary].filter(window => window !== undefined);
135
+ // An omitted bucket/window is not evidence of recovery. Exhaustion in
136
+ // retained windows is still independently enforced by hardBlock().
137
+ if (windows.length === 0 || windows.some(window => window.utilization >= 1))
138
+ continue;
139
+ if (bucket.limitId === DEFAULT_CODEX_LIMIT_ID) {
140
+ state.rateLimitedUntil = 0;
141
+ state.globalUntil = state.unavailableUntil;
142
+ }
143
+ state.bucketUntil.delete(bucket.limitId);
144
+ }
145
+ this.sweepExpiredCooldowns();
146
+ };
147
+ }
117
148
  getCooldownView(accountId) {
118
149
  const account = this.findById(accountId);
119
150
  if (!account)
@@ -351,7 +382,7 @@ export class OpenAITokenPool {
351
382
  cooldownsFor(account) {
352
383
  let state = this.cooldowns.get(account);
353
384
  if (!state) {
354
- state = { globalUntil: 0, rateLimitedUntil: 0, bucketUntil: new Map() };
385
+ state = { globalUntil: 0, rateLimitedUntil: 0, unavailableUntil: 0, quotaRevision: 0, bucketUntil: new Map() };
355
386
  this.cooldowns.set(account, state);
356
387
  }
357
388
  return state;
@@ -363,6 +394,8 @@ export class OpenAITokenPool {
363
394
  return false;
364
395
  const now = this.now();
365
396
  let recovered = false;
397
+ if (state.unavailableUntil > 0 && state.unavailableUntil <= now)
398
+ state.unavailableUntil = 0;
366
399
  if (state.rateLimitedUntil > 0 && state.rateLimitedUntil <= now)
367
400
  state.rateLimitedUntil = 0;
368
401
  if (state.globalUntil > 0 && state.globalUntil <= now) {
@@ -0,0 +1,30 @@
1
+ export async function consumeCodexResetCredit(account, redeemRequestId) {
2
+ // Contract: openai/codex 1715e55076737158ba61d43158ede504de6d4ce1,
3
+ // codex-rs/backend-client/src/client/rate_limit_resets.rs and types.rs.
4
+ // Never retry a spend with a fresh ID: a lost response may have consumed it.
5
+ try {
6
+ const response = await fetch("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", {
7
+ method: "POST",
8
+ headers: {
9
+ authorization: `Bearer ${account.accessToken}`,
10
+ "content-type": "application/json",
11
+ },
12
+ body: JSON.stringify({ redeem_request_id: redeemRequestId }),
13
+ signal: AbortSignal.timeout(10_000),
14
+ redirect: "error",
15
+ });
16
+ if (response.ok) {
17
+ const body = await response.json();
18
+ if (body && typeof body === "object" && "code" in body) {
19
+ const code = body.code;
20
+ if (code === "reset" || code === "nothing_to_reset" || code === "no_credit" || code === "already_redeemed") {
21
+ return { code };
22
+ }
23
+ }
24
+ }
25
+ }
26
+ catch {
27
+ // Do not relay upstream bodies, credentials, or network error details.
28
+ }
29
+ throw new Error("Reset outcome unknown; retry with the same redemption ID");
30
+ }
@@ -0,0 +1,64 @@
1
+ export function createUsageResetHandler(options) {
2
+ const inFlight = new WeakSet();
3
+ // One retained snapshot per account allows an uncertain retry to reconcile
4
+ // using the ORIGINAL quota evidence, not quota learned after the first spend.
5
+ const snapshots = new WeakMap();
6
+ return async (req, res) => {
7
+ const id = req.params.id;
8
+ const requestId = req.body?.redeemRequestId;
9
+ if (typeof requestId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(requestId)) {
10
+ res.status(400).json({ error: "redeemRequestId must be a UUID" });
11
+ return;
12
+ }
13
+ const account = options.findAccount(id);
14
+ if (!account) {
15
+ res.status(404).json({ error: "ChatGPT account not found" });
16
+ return;
17
+ }
18
+ if (inFlight.has(account)) {
19
+ res.status(409).json({ error: "Reset already running for this account" });
20
+ return;
21
+ }
22
+ inFlight.add(account);
23
+ try {
24
+ if (!await options.prepare(account)) {
25
+ res.status(503).json({ error: "Account credentials unavailable; reset not submitted" });
26
+ return;
27
+ }
28
+ if (options.findAccount(id) !== account) {
29
+ res.status(404).json({ error: "ChatGPT account changed; reset not submitted" });
30
+ return;
31
+ }
32
+ const previous = snapshots.get(account);
33
+ const replay = previous?.id === requestId;
34
+ const snapshot = replay ? previous : { id: requestId, reconcile: options.captureReset?.(account) };
35
+ snapshots.set(account, snapshot);
36
+ const result = await options.consume(account, requestId);
37
+ if (result.code === "already_redeemed" && !replay) {
38
+ // This UUID predates our ownership. Repeated historical replays must
39
+ // never promote its newly captured quota snapshot into trusted evidence.
40
+ snapshot.reconcile = undefined;
41
+ }
42
+ // Refresh even for no_credit/nothing_to_reset: the displayed snapshot may
43
+ // be stale. Never fabricate windows or decrement credits locally.
44
+ let usageRefreshed = false;
45
+ if (options.findAccount(id) === account) {
46
+ try {
47
+ const usage = await options.refresh(account);
48
+ usageRefreshed = usage.ok;
49
+ if (usage.ok && (result.code === "reset" || (result.code === "already_redeemed" && replay))) {
50
+ snapshot.reconcile?.(usage.update);
51
+ }
52
+ }
53
+ catch { /* retain confirmed redemption */ }
54
+ }
55
+ res.json({ reset: { ...result, usageRefreshed } });
56
+ }
57
+ catch {
58
+ res.status(502).json({ error: "Reset outcome unknown; retry with the same redemption ID" });
59
+ }
60
+ finally {
61
+ inFlight.delete(account);
62
+ }
63
+ };
64
+ }
@@ -1,3 +1,5 @@
1
+ import { consumeCodexResetCredit } from "../providers/openai/usage-reset.js";
2
+ import { createUsageResetHandler } from "./account-usage-reset.js";
1
3
  import express from "express";
2
4
  import { createProxyMiddleware } from "http-proxy-middleware";
3
5
  import { ServerResponse } from "http";
@@ -595,6 +597,13 @@ export async function startServer(opts = {}) {
595
597
  // the SSE streaming on /v1/* is never touched (see comment at /v1 handler).
596
598
  const accountsRouter = express.Router();
597
599
  accountsRouter.use(express.json({ limit: "32kb" }));
600
+ accountsRouter.post("/:id/reset-usage", createUsageResetHandler({
601
+ findAccount: id => openAIAccounts.find(account => account.id === id),
602
+ prepare: account => prepareOpenAIAccountForRequest(account, openAIAccounts, persistOpenAIAccounts),
603
+ consume: consumeCodexResetCredit,
604
+ captureReset: account => openAIPool.captureUsageReset(account),
605
+ refresh: account => openAIUsageRefresher.refreshAfterCurrent(account),
606
+ }));
598
607
  // Shape returned to clients — NEVER includes access/refresh tokens.
599
608
  accountsRouter.get("/", (_req, res) => {
600
609
  const resolveRoutingMetrics = createRoutingMetricsResolver();
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { randomUUID } from "node:crypto";
2
3
  import React, { useState, useEffect, useCallback, useRef } from "react";
3
4
  import { Box, Text, useInput, useApp, useStdout, measureElement } from "ink";
4
5
  import { createAccountsApi } from "./accountsApi.js";
@@ -531,6 +532,9 @@ export function Dashboard({ port, baseUrl, authToken, onIntent }) {
531
532
  const [connectError, setConnectError] = useState(null);
532
533
  const [lastUpdate, setLastUpdate] = useState(0);
533
534
  const [retryCount, setRetryCount] = useState(0);
535
+ // LiveDashboard unmounts on a health-poll failure. Redemption ownership and
536
+ // retry IDs must survive that transition, not just a change in focus.
537
+ const resetSession = useRef({ inFlight: false, pendingIds: new Map() });
534
538
  const resolvedBase = baseUrl
535
539
  ? baseUrl.replace(/\/+$/, "")
536
540
  : `http://localhost:${port}`;
@@ -608,14 +612,14 @@ export function Dashboard({ port, baseUrl, authToken, onIntent }) {
608
612
  if (!data) {
609
613
  return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u280B Connecting to ", resolvedBase, "..."] }) }));
610
614
  }
611
- return (_jsx(LiveDashboard, { data: data, port: port, baseUrl: resolvedBase, lastUpdate: lastUpdate, api: api, modelsApi: modelsApi, onIntent: onIntent, onRefreshAll: refreshLocalViews }));
615
+ return (_jsx(LiveDashboard, { data: data, port: port, baseUrl: resolvedBase, lastUpdate: lastUpdate, api: api, modelsApi: modelsApi, onIntent: onIntent, onRefreshAll: refreshLocalViews, resetSession: resetSession.current }));
612
616
  }
613
617
  // ─── Error screen ─────────────────────────────────────────────────────────────
614
618
  function ErrorScreen({ error, port, retries }) {
615
619
  return (_jsxs(Box, { flexDirection: "column", marginY: 1, marginX: 2, children: [_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "yellow", children: "Is the proxy running? Start it with:" }), _jsx(Text, { color: "cyan", children: " cc-router start" })] }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "gray", children: ["Retrying every ", POLL_INTERVAL_MS / 1000, "s"] }), retries > 0 && _jsxs(Text, { color: "gray", children: [" (attempt ", retries, ")"] }), _jsx(Text, { color: "gray", children: " \u00B7 [q] quit" })] })] }));
616
620
  }
617
621
  // ─── Live dashboard ───────────────────────────────────────────────────────────
618
- function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onIntent, onRefreshAll, }) {
622
+ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onIntent, onRefreshAll, resetSession, }) {
619
623
  const [cliRouting, setCliRouting] = useState(() => ({
620
624
  claude: readClaudeRouting(),
621
625
  codex: readCodexRouting(),
@@ -641,6 +645,7 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
641
645
  // ── Focus / mode ──────────────────────────────────────────────────────────
642
646
  const [focus, setFocus] = useState("logs");
643
647
  const [mode, setMode] = useState("view");
648
+ const [resetTarget, setResetTarget] = useState(null);
644
649
  // Compact ("zen") view: hides TOTALS + RECENT ACTIVITY so the account list
645
650
  // gets the whole vertical budget — the fix for a short terminal starving a
646
651
  // long fleet (e.g. showing 1 of 11 accounts). Toggled with [z], view-only,
@@ -959,6 +964,37 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
959
964
  showBanner(`Models error: ${errMsg(err)}`, "red");
960
965
  }
961
966
  }, [modelsApi, selectedModel, showBanner]);
967
+ const doResetUsage = useCallback(async (id) => {
968
+ if (resetSession.inFlight)
969
+ return;
970
+ resetSession.inFlight = true;
971
+ const requestId = resetSession.pendingIds.get(id) ?? randomUUID();
972
+ resetSession.pendingIds.set(id, requestId);
973
+ showBanner(`Redeeming usage reset for ${id}…`, "yellow", REFRESH_ALL_BANNER_MS);
974
+ try {
975
+ const result = await api.resetUsage(id, requestId);
976
+ resetSession.pendingIds.delete(id);
977
+ const messages = {
978
+ reset: `Usage reset redeemed for ${id}`,
979
+ already_redeemed: `Usage reset already redeemed for ${id}`,
980
+ nothing_to_reset: `Nothing to reset for ${id}`,
981
+ no_credit: `No reset credits available for ${id}`,
982
+ };
983
+ showBanner(messages[result.code] + (result.usageRefreshed ? "" : " — usage refresh failed; reload with R"), result.usageRefreshed && (result.code === "reset" || result.code === "already_redeemed") ? "green" : "yellow");
984
+ // Failure to poll the dashboard must not turn a confirmed spend into an
985
+ // unknown outcome or encourage another redemption.
986
+ try {
987
+ await onRefreshAll?.();
988
+ }
989
+ catch { /* the regular poll will retry */ }
990
+ }
991
+ catch {
992
+ showBanner(`Reset outcome unknown for ${id}; Ctrl+R retries the same redemption (keep dashboard open)`, "red");
993
+ }
994
+ finally {
995
+ resetSession.inFlight = false;
996
+ }
997
+ }, [api, onRefreshAll, resetSession, showBanner]);
962
998
  // ── Keyboard handler ──────────────────────────────────────────────────────
963
999
  useInput((input, key) => {
964
1000
  // ── Text editing mode (w / s) ───────────────────────────────────────
@@ -990,6 +1026,15 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
990
1026
  }
991
1027
  return;
992
1028
  }
1029
+ if (mode === "confirmReset") {
1030
+ if ((input === "y" || input === "Y") && resetTarget)
1031
+ void doResetUsage(resetTarget);
1032
+ else
1033
+ showBanner("Reset cancelled", "gray");
1034
+ setResetTarget(null);
1035
+ setMode("view");
1036
+ return;
1037
+ }
993
1038
  // ── Confirm delete (y/n) ────────────────────────────────────────────
994
1039
  if (mode === "confirmDelete") {
995
1040
  if (input === "y" || input === "Y") {
@@ -1019,6 +1064,27 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
1019
1064
  exit();
1020
1065
  return;
1021
1066
  }
1067
+ // Intercept Ctrl+R before model refresh so it only acts on account focus.
1068
+ // Ink exposes the standard DC2 control character as input r + key.ctrl.
1069
+ if (input === "r" && key.ctrl) {
1070
+ if (focus !== "accounts" || !selectedAccount)
1071
+ return;
1072
+ if (resetSession.inFlight) {
1073
+ showBanner("Reset already running", "yellow");
1074
+ return;
1075
+ }
1076
+ if (selectedAccount.provider !== "openai_subscription") {
1077
+ showBanner("Usage resets are only available for ChatGPT accounts", "yellow");
1078
+ return;
1079
+ }
1080
+ if (!resetSession.pendingIds.has(selectedAccount.id) && (selectedAccount.codexRateLimits?.resetCredits?.available ?? 0) <= 0) {
1081
+ showBanner("No reset credits available", "yellow");
1082
+ return;
1083
+ }
1084
+ setResetTarget(selectedAccount.id);
1085
+ setMode("confirmReset");
1086
+ return;
1087
+ }
1022
1088
  if (input === "R") {
1023
1089
  void doRefreshAll();
1024
1090
  return;
@@ -1164,7 +1230,7 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
1164
1230
  });
1165
1231
  const selectedLog = logs[selectedLogIndex] ?? null;
1166
1232
  const visibleLogs = logs.slice(logWindowTop, logWindowTop + logVisible);
1167
- return (_jsx(Box, { flexDirection: "column", height: frameBound, overflowY: "hidden", children: _jsxs(Box, { flexDirection: "column", flexShrink: 0, ref: contentRef, children: [_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: " CC-Router " }), _jsx(Text, { color: "gray", children: "\u00B7 " }), _jsx(Text, { color: "green", children: data.mode }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsxs(Text, { children: ["up ", formatUptime(data.uptime)] }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { color: "cyan", children: data.totalRequests }), _jsx(Text, { color: "gray", children: " req " }), _jsx(Text, { color: data.totalErrors > 0 ? "red" : "green", children: data.totalErrors }), _jsx(Text, { color: "gray", children: " err" }), _jsx(CacheHealthBadge, { read: data.totalCacheReadTokens, created: data.totalCacheCreationTokens, input: data.totalInputTokens }), _jsx(Text, { color: "gray", children: " \u00B7 [q] quit" })] }), mode === "editWeekly" && selectedAccount && (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: "Set 7d cap for " }), _jsx(Text, { color: "white", bold: true, children: selectedAccount.id }), _jsx(Text, { color: "cyan", children: " (0\u2013100%): " }), _jsx(Text, { color: "white", bold: true, children: editBuffer }), _jsx(Text, { color: "gray", children: "\u2588 [Enter] save [Esc] cancel" })] })), mode === "editSession" && selectedAccount && (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: "Set 5h cap for " }), _jsx(Text, { color: "white", bold: true, children: selectedAccount.id }), _jsx(Text, { color: "cyan", children: " (0\u2013100%): " }), _jsx(Text, { color: "white", bold: true, children: editBuffer }), _jsx(Text, { color: "gray", children: "\u2588 [Enter] save [Esc] cancel" })] })), mode === "confirmDelete" && selectedAccount && (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: "red", bold: true, children: ["Delete \"", selectedAccount.id, "\"? [y] yes [n/Esc] cancel"] }) })), data.version !== DASHBOARD_VERSION && (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "yellow", children: " \u26A0 VERSION MISMATCH " }), _jsxs(Text, { color: "yellow", children: [data.version !== undefined
1233
+ return (_jsx(Box, { flexDirection: "column", height: frameBound, overflowY: "hidden", children: _jsxs(Box, { flexDirection: "column", flexShrink: 0, ref: contentRef, children: [_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: " CC-Router " }), _jsx(Text, { color: "gray", children: "\u00B7 " }), _jsx(Text, { color: "green", children: data.mode }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsxs(Text, { children: ["up ", formatUptime(data.uptime)] }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { color: "cyan", children: data.totalRequests }), _jsx(Text, { color: "gray", children: " req " }), _jsx(Text, { color: data.totalErrors > 0 ? "red" : "green", children: data.totalErrors }), _jsx(Text, { color: "gray", children: " err" }), _jsx(CacheHealthBadge, { read: data.totalCacheReadTokens, created: data.totalCacheCreationTokens, input: data.totalInputTokens }), _jsx(Text, { color: "gray", children: " \u00B7 [q] quit" })] }), mode === "editWeekly" && selectedAccount && (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: "Set 7d cap for " }), _jsx(Text, { color: "white", bold: true, children: selectedAccount.id }), _jsx(Text, { color: "cyan", children: " (0\u2013100%): " }), _jsx(Text, { color: "white", bold: true, children: editBuffer }), _jsx(Text, { color: "gray", children: "\u2588 [Enter] save [Esc] cancel" })] })), mode === "editSession" && selectedAccount && (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: "Set 5h cap for " }), _jsx(Text, { color: "white", bold: true, children: selectedAccount.id }), _jsx(Text, { color: "cyan", children: " (0\u2013100%): " }), _jsx(Text, { color: "white", bold: true, children: editBuffer }), _jsx(Text, { color: "gray", children: "\u2588 [Enter] save [Esc] cancel" })] })), mode === "confirmReset" && resetTarget && (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: "yellow", bold: true, children: ["Redeem 1 reset for \"", resetTarget, "\"? [y] yes [n/Esc] cancel"] }) })), mode === "confirmDelete" && selectedAccount && (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: "red", bold: true, children: ["Delete \"", selectedAccount.id, "\"? [y] yes [n/Esc] cancel"] }) })), data.version !== DASHBOARD_VERSION && (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "yellow", children: " \u26A0 VERSION MISMATCH " }), _jsxs(Text, { color: "yellow", children: [data.version !== undefined
1168
1234
  ? `daemon v${data.version}`
1169
1235
  : "daemon version unreported (older build)", ` · dashboard v${DASHBOARD_VERSION}`] }), LOCAL_TARGET_RE.test(baseUrl) ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: " \u2014 restart: " }), _jsx(Text, { color: "cyan", children: "cc-router stop --keep-config && cc-router start" })] })) : (
1170
1236
  // A remote router can only be restarted where it runs; printing a
@@ -1172,7 +1238,7 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
1172
1238
  _jsxs(Text, { color: "gray", children: [" \u2014 update and restart the daemon on ", baseUrl] }))] })), _jsx(Box, { marginTop: 1 }), data.operational && (_jsxs(_Fragment, { children: [_jsx(OperationsPanel, { operational: data.operational, baseUrl: baseUrl, focus: focus, cliRouting: cliRouting }), _jsx(Box, { marginTop: 1 })] })), (focus === "models" || modelsStatus) && (_jsxs(_Fragment, { children: [_jsx(ModelsPanel, { status: modelsStatus, selectedIndex: selectedModelIndex, focused: focus === "models", visibleRows: modelsVisible, rowsRef: modelRowsRef }), _jsx(Box, { marginTop: 1 })] })), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { bold: true, children: [" ACCOUNTS ", _jsxs(Text, { color: healthyCount === orderedAccounts.length ? "green" : "yellow", children: [healthyCount, "/", orderedAccounts.length, " healthy"] }), weeklyFullCount > 0 && _jsx(Text, { color: "red", children: ` · ${weeklyFullCount} 7d full` })] }), shownAccounts < orderedAccounts.length && (_jsxs(Text, { color: "gray", children: [" · showing ", accountWindowTop + 1, "\u2013", accountWindowTop + shownAccounts] })), compact && _jsx(Text, { color: "cyan", children: " · compact" })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", ref: accountRowsRef, children: _jsx(AccountGroups, { visible: orderedAccounts.slice(accountWindowTop, accountWindowTop + shownAccounts), fleet: orderedAccounts, windowTop: accountWindowTop, selectedIndex: selectedAccountIndex, focused: focus === "accounts" }) })] }), banner && (_jsx(Box, { marginTop: 1, paddingLeft: 2, children: _jsxs(Text, { color: banner.color, children: [" ", banner.text] }) })), !compact && (_jsxs(_Fragment, { children: [_jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: " TOTALS " }), _jsx(Text, { children: "requests " }), _jsx(Text, { color: "cyan", children: data.totalRequests }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "errors " }), _jsx(Text, { color: data.totalErrors > 0 ? "red" : "green", children: data.totalErrors }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "refreshes " }), _jsx(Text, { color: "yellow", children: data.totalRefreshes }), _jsx(CacheHealthBadge, { read: data.totalCacheReadTokens, created: data.totalCacheCreationTokens, input: data.totalInputTokens })] }), _jsx(TokenSummary, { cacheRead: data.totalCacheReadTokens, cacheCreated: data.totalCacheCreationTokens, uncached: data.totalInputTokens, output: data.totalOutputTokens ?? 0 })] }), _jsx(Box, { marginTop: 1 }), _jsx(Text, { bold: true, children: " RECENT ACTIVITY" }), _jsx(Box, { marginTop: 1 })] }))] }), !compact && (_jsx(Box, { flexDirection: "column", ref: logRowsRef, children: visibleLogs.length === 0
1173
1239
  ? _jsx(Text, { color: "gray", children: " No activity yet" })
1174
1240
  : visibleLogs.map((log, i) => (_jsx(LogRow, { log: log, selected: focus === "logs" && logWindowTop + i === selectedLogIndex }, `${log.ts}-${i}`))) })), !compact && focus === "logs" && selectedLog && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginTop: 1 }), _jsx(DetailPanel, { log: selectedLog })] })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: focus === "accounts"
1175
- ? " [Tab] [e] toggle [a]/[o]/[g] provider [n] add [d] delete [w] 7d [s] 5h [R] reload [z] compact [q]"
1241
+ ? " [Tab] [e] toggle [a]/[o]/[g] provider [n] add [d] delete [w] 7d [s] 5h [Ctrl+R] reset [R] reload [z] compact [q]"
1176
1242
  : focus === "models"
1177
1243
  ? " [Tab] [m/r] refresh [c]/[o] default [R] reload all [Esc] logs [z] compact [q]"
1178
1244
  : " [Tab] [m] models [R] reload [z] compact [q] quit" }) })] }) }));
@@ -57,6 +57,22 @@ export function createAccountsApi(baseUrl, authToken) {
57
57
  return {
58
58
  list,
59
59
  refreshAll,
60
+ async resetUsage(id, redeemRequestId) {
61
+ const response = await fetch(`${base}/${encodeURIComponent(id)}/reset-usage`, {
62
+ method: "POST",
63
+ headers: { ...authHeaders, "content-type": "application/json" },
64
+ body: JSON.stringify({ redeemRequestId }),
65
+ signal: AbortSignal.timeout(REFRESH_ALL_TIMEOUT_MS),
66
+ });
67
+ if (!response.ok)
68
+ throw new Error(`HTTP ${response.status}`);
69
+ const body = await response.json();
70
+ const reset = isRecord(body) ? body.reset : undefined;
71
+ if (!isRecord(reset) || typeof reset.code !== "string" || !["reset", "nothing_to_reset", "no_credit", "already_redeemed"].includes(reset.code)
72
+ || typeof reset.usageRefreshed !== "boolean")
73
+ throw new Error("Invalid reset response");
74
+ return { code: reset.code, usageRefreshed: reset.usageRefreshed };
75
+ },
60
76
  patch(id, patch) { return send("PATCH", `/${encodeURIComponent(id)}`, patch); },
61
77
  setProviderEnabled(provider, enabled) { return send("PATCH", `/providers/${encodeURIComponent(provider)}`, { enabled }); },
62
78
  remove(id) { return send("DELETE", `/${encodeURIComponent(id)}`); },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timo972/cc-router",
3
- "version": "0.12.1-rc.0",
3
+ "version": "0.12.1",
4
4
  "description": "Cache-aware session router for Claude Max OAuth tokens — use multiple Claude Max accounts with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {