@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 +13 -0
- package/README.md +99 -624
- package/dist/providers/openai/token-pool.js +35 -2
- package/dist/providers/openai/usage-reset.js +30 -0
- package/dist/proxy/account-usage-reset.js +64 -0
- package/dist/proxy/server.js +9 -0
- package/dist/ui/Dashboard.js +70 -4
- package/dist/ui/accountsApi.js +16 -0
- package/package.json +1 -1
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
|
-
**
|
|
4
|
-
|
|
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
|
[](https://www.npmjs.com/package/@timo972/cc-router)
|
|
7
11
|
[](LICENSE)
|
|
12
|
+
[](https://nodejs.org)
|
|
8
13
|
|
|
9
|
-
|
|
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
|

|
|
17
17
|
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
+
## Features
|
|
82
47
|
|
|
83
|
-
|
|
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
|
-
|
|
62
|
+
**Routed** — requests are proxied to these:
|
|
88
63
|
|
|
89
|
-
|
|
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
|
-
|
|
70
|
+
**Monitored** — tracked and shown in the dashboard, not proxied:
|
|
92
71
|
|
|
93
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
100
|
+
# 4. Use Claude Code as usual — the proxy is transparent
|
|
115
101
|
claude
|
|
116
102
|
```
|
|
117
103
|
|
|
118
|
-
That's it.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
>
|
|
661
|
-
>
|
|
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
|
-
>
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/proxy/server.js
CHANGED
|
@@ -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();
|
package/dist/ui/Dashboard.js
CHANGED
|
@@ -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" }) })] }) }));
|
package/dist/ui/accountsApi.js
CHANGED
|
@@ -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