@timo972/cc-router 0.7.0 → 0.9.0-rc.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 +139 -0
- package/README.md +12 -87
- package/dist/cli/cmd-accounts.js +114 -11
- package/dist/cli/index.js +0 -0
- package/dist/protocol/openai-responses-collect.js +71 -0
- package/dist/providers/anthropic/usage-refresher.js +195 -0
- package/dist/providers/anthropic/usage.js +217 -0
- package/dist/proxy/account-add.js +30 -0
- package/dist/proxy/account-deletion.js +16 -0
- package/dist/proxy/anthropic-routing.js +31 -2
- package/dist/proxy/lease-lifecycle.js +182 -22
- package/dist/proxy/logger.js +3 -0
- package/dist/proxy/messages-cross-route.js +4 -1
- package/dist/proxy/request-model.js +17 -0
- package/dist/proxy/responses-server.js +43 -1
- package/dist/proxy/server.js +198 -24
- package/dist/proxy/session-router.js +12 -8
- package/dist/proxy/stats.js +11 -0
- package/dist/proxy/token-pool.js +379 -108
- package/dist/ui/Dashboard.js +90 -4
- package/dist/ui/accountsApi.js +136 -20
- package/package.json +12 -11
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,140 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
Nothing yet.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## [0.9.0] — 2026-08-04
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **Non-streaming `/v1/responses` requests are served correctly.** A caller that
|
|
20
|
+
posts `stream: false` — the public Responses API default — now receives a
|
|
21
|
+
single JSON Responses object. The Codex backend is SSE-only, so the router
|
|
22
|
+
reconciles the forced event stream into one body instead of returning raw SSE
|
|
23
|
+
bytes the client cannot parse. Streaming callers (the Codex CLI) are
|
|
24
|
+
unaffected.
|
|
25
|
+
- A distinct `warn` activity type with its own `logWarn` console channel,
|
|
26
|
+
rendered as its own row style on the status dashboard so advisories are
|
|
27
|
+
visually separate from routing errors.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- `/v1/responses` rejects an explicit `store: true` with a `400`
|
|
32
|
+
`invalid_request_error` instead of silently rewriting it to `false`. The Codex
|
|
33
|
+
subscription backend is stateless and cannot offer server-side response
|
|
34
|
+
retrieval by id. An omitted `store` is still normalized to `false` silently.
|
|
35
|
+
- An explicit `max_output_tokens` is still dropped — the backend does not
|
|
36
|
+
support it — but each drop now surfaces as a warning in both the console log
|
|
37
|
+
and the dashboard activity feed, so the ignored cap is observable.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- Malformed upstream data from the Codex backend (a bad JSON body or a malformed
|
|
42
|
+
SSE stream) maps to a `502 upstream_error` instead of throwing out of the
|
|
43
|
+
async Express handler, which left the client connection hanging indefinitely.
|
|
44
|
+
- Non-2xx Codex passthrough preserves the upstream content-type instead of
|
|
45
|
+
hardcoding `text/plain`, which broke SDK clients that parse errors by
|
|
46
|
+
content-type.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## [0.8.3] — 2026-08-04
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- Accounts added while the proxy is running (`accounts add`, `add-openai`,
|
|
55
|
+
`login-openai`) are now loaded into the live pool immediately — routable and
|
|
56
|
+
visible in `accounts list` without a restart. Previously only removals were
|
|
57
|
+
applied at runtime; adds required restarting the proxy. When no proxy is
|
|
58
|
+
running the add still falls back to a plain disk write.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## [0.8.2] — 2026-08-03
|
|
63
|
+
|
|
64
|
+
### Fixed
|
|
65
|
+
|
|
66
|
+
- The interactive status dashboard no longer crashes when model-scoped usage
|
|
67
|
+
reports an unknown reset timestamp as zero.
|
|
68
|
+
|
|
69
|
+
### Internal
|
|
70
|
+
|
|
71
|
+
- GitHub Actions bumped to v7.
|
|
72
|
+
- The Codex config-path test uses the platform-native location instead of a
|
|
73
|
+
hardcoded POSIX path.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## [0.8.1] — 2026-08-03
|
|
78
|
+
|
|
79
|
+
### Fixed
|
|
80
|
+
|
|
81
|
+
- Anthropic model-scoped usage rows using the current nested `scope.model`
|
|
82
|
+
shape are parsed correctly, so exhausting Fable capacity no longer creates
|
|
83
|
+
an account-global cooldown that also blocks Opus routing.
|
|
84
|
+
|
|
85
|
+
### Changed
|
|
86
|
+
|
|
87
|
+
- Hosting guidance about sharing accounts across a team was removed from the
|
|
88
|
+
docs.
|
|
89
|
+
|
|
90
|
+
### Internal
|
|
91
|
+
|
|
92
|
+
- CI installs with pnpm and runs the suite across the supported Node versions.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## [0.8.0] — 2026-08-01
|
|
97
|
+
|
|
98
|
+
### Added
|
|
99
|
+
|
|
100
|
+
- **Model-aware Anthropic allowance routing.** Requested Messages models now
|
|
101
|
+
participate in account eligibility and headroom ranking through dynamic
|
|
102
|
+
model-scoped weekly limits. Account-based session affinity is retained while
|
|
103
|
+
the bound account can serve the requested model.
|
|
104
|
+
- Authenticated dashboard, health, and accounts views now show safe global and
|
|
105
|
+
model-scoped capacity, usage freshness, paid-extra state, and global or
|
|
106
|
+
requested-model cooldown summaries.
|
|
107
|
+
|
|
108
|
+
### Changed
|
|
109
|
+
|
|
110
|
+
- Anthropic cooldowns, upstream quota exhaustion, disabled or unhealthy state,
|
|
111
|
+
and invalid authentication are hard routing exclusions. The only fallback is
|
|
112
|
+
an explicit bypass of configured per-account percentage caps when every
|
|
113
|
+
otherwise eligible account is capped.
|
|
114
|
+
- Usage snapshots refresh in memory from Anthropic's internal OAuth usage
|
|
115
|
+
endpoint with bounded concurrency, timeout, and backoff, while response
|
|
116
|
+
headers remain the graceful-degradation source when that endpoint is
|
|
117
|
+
unavailable.
|
|
118
|
+
- The README leads with the fork's positioning, and the account-sharing use
|
|
119
|
+
case was dropped.
|
|
120
|
+
|
|
121
|
+
### Fixed
|
|
122
|
+
|
|
123
|
+
- When all accounts are hard-blocked, the router now returns a local
|
|
124
|
+
Anthropic-shaped 429 when any blocker is rate-limit or quota related, adding
|
|
125
|
+
the earliest trustworthy `Retry-After` only when known. A 503 is used only
|
|
126
|
+
for entirely non-rate-limit unavailability. These local errors make no
|
|
127
|
+
Anthropic Messages request, and fallback no longer sends requests to cooling
|
|
128
|
+
or upstream-rate-limited accounts.
|
|
129
|
+
- `accounts remove` now removes the account from the running proxy instead of
|
|
130
|
+
only rewriting `accounts.json`, so a removed account stops being routed
|
|
131
|
+
without a restart.
|
|
132
|
+
- A per-account cap of 100% is no longer treated as over-cap at full
|
|
133
|
+
utilization, so sessions on accounts running on paid extra usage stay sticky
|
|
134
|
+
on their bound account.
|
|
135
|
+
|
|
136
|
+
### Internal
|
|
137
|
+
|
|
138
|
+
- Package management switched from npm to pnpm (`pnpm-lock.yaml`,
|
|
139
|
+
`pnpm-workspace.yaml`).
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
9
143
|
## [0.7.0] — 2026-07-26
|
|
10
144
|
|
|
11
145
|
First release of `@timo972/cc-router`, an independently maintained fork of
|
|
@@ -93,4 +227,9 @@ cache-aware session routing and a round of security hardening.
|
|
|
93
227
|
- `http-proxy-middleware` 3.0.5 → 3.0.7 for GHSA-gcq2-9pq2-cxqm (high). The
|
|
94
228
|
affected APIs are not used here.
|
|
95
229
|
|
|
230
|
+
[0.9.0]: https://github.com/Timo972/cc-router/releases/tag/v0.9.0
|
|
231
|
+
[0.8.3]: https://github.com/Timo972/cc-router/releases/tag/v0.8.3
|
|
232
|
+
[0.8.2]: https://github.com/Timo972/cc-router/releases/tag/v0.8.2
|
|
233
|
+
[0.8.1]: https://github.com/Timo972/cc-router/releases/tag/v0.8.1
|
|
234
|
+
[0.8.0]: https://github.com/Timo972/cc-router/releases/tag/v0.8.0
|
|
96
235
|
[0.7.0]: https://github.com/Timo972/cc-router/releases/tag/v0.7.0
|
package/README.md
CHANGED
|
@@ -22,8 +22,8 @@ Distribute Claude Code requests across Claude subscriptions, and expose an OpenA
|
|
|
22
22
|
- **Transparent Claude proxy** — Claude Code works normally; streaming, thinking, tool use, prompt caching all pass through
|
|
23
23
|
- **Codex CLI support** — configure Codex to use CC-Router as a Responses-compatible provider
|
|
24
24
|
- **Automatic token refresh** — OAuth tokens are refreshed before they expire, saved atomically to disk
|
|
25
|
-
- **
|
|
26
|
-
- **Client mode** — connect to
|
|
25
|
+
- **Model-aware rate limits** — avoids accounts whose requested-model or global allowance is exhausted, and respects scoped cooldowns
|
|
26
|
+
- **Client mode** — connect another device you own to your private CC-Router (`cc-router client connect <url>`)
|
|
27
27
|
- **Claude Desktop support** — route Cowork / Agent-mode traffic through CC-Router via mitmproxy interception (macOS, Windows, Linux)
|
|
28
28
|
- **Guided setup wizard** — interactive `cc-router setup` extracts tokens from Keychain or credentials file, configures everything
|
|
29
29
|
- **Live dashboard** — real-time terminal UI showing account health, request counts, token usage, recent activity
|
|
@@ -71,16 +71,16 @@ All standard Claude Code features work transparently on the Claude route: stream
|
|
|
71
71
|
|
|
72
72
|
### Cache-aware Claude account routing
|
|
73
73
|
|
|
74
|
-
CC-Router keeps requests from one Claude Code session on the same
|
|
74
|
+
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.
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
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.
|
|
77
|
+
|
|
78
|
+
If an upstream account returns 401, 429, or 529, CC-Router passes that response through unchanged and invalidates the session's affinity. The client's next retry can then select another usable account; the router never retries after response bytes have started. 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.
|
|
77
79
|
|
|
78
80
|
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.
|
|
79
81
|
|
|
80
82
|
**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).
|
|
81
83
|
|
|
82
|
-
📖 **Running this for a team?** [docs/session-routing.md](docs/session-routing.md) covers how accounts are picked, what per-account throttles do, how to monitor affinity, and how to read the failure modes.
|
|
83
|
-
|
|
84
84
|
---
|
|
85
85
|
|
|
86
86
|
## Use cases
|
|
@@ -98,82 +98,6 @@ With two accounts you double your effective rate limit. With three, you triple i
|
|
|
98
98
|
|
|
99
99
|
---
|
|
100
100
|
|
|
101
|
-
### Hosting cc-router on a shared machine
|
|
102
|
-
|
|
103
|
-
Run cc-router on a machine everyone on the team can reach — a home server, a VPS, or a spare machine on the office network.
|
|
104
|
-
|
|
105
|
-
#### On the server
|
|
106
|
-
|
|
107
|
-
```bash
|
|
108
|
-
npm install -g @timo972/cc-router
|
|
109
|
-
cc-router setup # configure the accounts
|
|
110
|
-
cc-router start # first run asks: background/boot/server mode — choose "server mode"
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
When you enable server mode during `cc-router start`, the proxy automatically binds to all interfaces (`0.0.0.0`) and prints instructions for connecting clients.
|
|
114
|
-
|
|
115
|
-
#### On each developer's machine
|
|
116
|
-
|
|
117
|
-
No installation needed. Just set two environment variables in `~/.claude/settings.json`:
|
|
118
|
-
|
|
119
|
-
```json
|
|
120
|
-
{
|
|
121
|
-
"env": {
|
|
122
|
-
"ANTHROPIC_BASE_URL": "http://192.168.1.50:3456",
|
|
123
|
-
"ANTHROPIC_AUTH_TOKEN": "proxy-managed"
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
Replace `192.168.1.50` with the server's IP or hostname. Then run `claude` normally.
|
|
129
|
-
|
|
130
|
-
Or use the CLI to write the settings automatically:
|
|
131
|
-
|
|
132
|
-
```bash
|
|
133
|
-
cc-router configure --port 3456
|
|
134
|
-
# Then manually update ANTHROPIC_BASE_URL to the remote IP
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
---
|
|
138
|
-
|
|
139
|
-
### Hosting on a VPS (internet-accessible)
|
|
140
|
-
|
|
141
|
-
If your team is distributed or works remotely, run cc-router on a VPS and expose it over HTTPS via a reverse proxy.
|
|
142
|
-
|
|
143
|
-
#### Recommended nginx config
|
|
144
|
-
|
|
145
|
-
```nginx
|
|
146
|
-
server {
|
|
147
|
-
listen 443 ssl;
|
|
148
|
-
server_name cc-router.yourcompany.com;
|
|
149
|
-
|
|
150
|
-
# ... SSL cert config (e.g. Let's Encrypt) ...
|
|
151
|
-
|
|
152
|
-
location / {
|
|
153
|
-
proxy_pass http://127.0.0.1:3456;
|
|
154
|
-
proxy_buffering off; # required for SSE streaming
|
|
155
|
-
proxy_read_timeout 1800s; # outer-proxy body idle timeout
|
|
156
|
-
proxy_set_header X-Forwarded-For $remote_addr;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
These settings protect different phases. CC-Router's `proxyRequestTimeoutMs` in `~/.cc-router/config.json` applies only while waiting for Anthropic response headers. An outer proxy's `proxy_read_timeout` applies while reading the response body, so configure that outer timeout separately with enough headroom for long thinking pauses. Neither timeout repairs a missing upstream terminal event, and CC-Router never synthesizes `message_stop`.
|
|
162
|
-
|
|
163
|
-
Each developer then points to:
|
|
164
|
-
```json
|
|
165
|
-
{
|
|
166
|
-
"env": {
|
|
167
|
-
"ANTHROPIC_BASE_URL": "https://cc-router.yourcompany.com",
|
|
168
|
-
"ANTHROPIC_AUTH_TOKEN": "proxy-managed"
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
**Security note:** if the proxy is internet-accessible, add authentication at the nginx level (basic auth, mTLS, or IP allowlist) so only your team can use it. cc-router does not implement user authentication itself.
|
|
174
|
-
|
|
175
|
-
---
|
|
176
|
-
|
|
177
101
|
## Quickstart
|
|
178
102
|
|
|
179
103
|
```bash
|
|
@@ -443,9 +367,9 @@ This prompts for the OpenAI access token, refresh token, expiry timestamp, and s
|
|
|
443
367
|
|
|
444
368
|
---
|
|
445
369
|
|
|
446
|
-
## Client mode — connecting
|
|
370
|
+
## Client mode — connecting your own devices
|
|
447
371
|
|
|
448
|
-
|
|
372
|
+
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.
|
|
449
373
|
|
|
450
374
|
The setup wizard asks about this at the very first step:
|
|
451
375
|
|
|
@@ -453,13 +377,13 @@ The setup wizard asks about this at the very first step:
|
|
|
453
377
|
cc-router setup
|
|
454
378
|
# → What do you want to do?
|
|
455
379
|
# • Host CC-Router on this machine
|
|
456
|
-
# • Connect to
|
|
380
|
+
# • Connect to your existing CC-Router server ← pick this
|
|
457
381
|
```
|
|
458
382
|
|
|
459
383
|
Or use the dedicated command directly:
|
|
460
384
|
|
|
461
385
|
```bash
|
|
462
|
-
#
|
|
386
|
+
# Connect another device you own over your private network
|
|
463
387
|
cc-router client connect http://192.168.1.50:3456 --secret cc-rtr-abc123...
|
|
464
388
|
|
|
465
389
|
# Check status
|
|
@@ -615,7 +539,7 @@ cc-router status
|
|
|
615
539
|
|
|
616
540
|
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.
|
|
617
541
|
|
|
618
|
-
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`.
|
|
542
|
+
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.
|
|
619
543
|
|
|
620
544
|
| Key | Action |
|
|
621
545
|
|-----|--------|
|
|
@@ -693,6 +617,7 @@ Check status anytime: `cc-router telemetry status`.
|
|
|
693
617
|
>
|
|
694
618
|
> **Read Anthropic's Terms of Service before using this tool.**
|
|
695
619
|
> Using multiple Max subscriptions to increase throughput may violate the ToS. Anthropic has been known to ban accounts for unusual OAuth usage patterns.
|
|
620
|
+
> Do not share subscription accounts, OAuth credentials, or CC-Router proxy access with other people.
|
|
696
621
|
>
|
|
697
622
|
> 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.
|
|
698
623
|
|
package/dist/cli/cmd-accounts.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
-
import { loadAccounts, loadOpenAIAccounts, accountsFileExists, upsertAccountRecord, removeAccountRecordById } from "../config/manager.js";
|
|
2
|
+
import { loadAccounts, loadOpenAIAccounts, accountsFileExists, upsertAccountRecord, removeAccountRecordById, readConfig, serialize } from "../config/manager.js";
|
|
3
3
|
import { saveAccounts } from "../proxy/token-refresher.js";
|
|
4
4
|
import { formatExpiry, redactToken } from "../utils/token-extractor.js";
|
|
5
5
|
import { PROXY_PORT } from "../config/paths.js";
|
|
@@ -94,9 +94,12 @@ export function registerAccounts(program) {
|
|
|
94
94
|
...existing.filter(a => a.id !== account.id),
|
|
95
95
|
account,
|
|
96
96
|
];
|
|
97
|
-
|
|
97
|
+
const { mode } = await addAccountRuntimeAware(serialize([account])[0], {
|
|
98
|
+
tryAddLive: tryAddAccountToRunningProxy,
|
|
99
|
+
addStored: () => saveAccounts(merged),
|
|
100
|
+
});
|
|
98
101
|
console.log(chalk.green(`\n✓ Account "${account.id}" added (${merged.length} total).\n`));
|
|
99
|
-
|
|
102
|
+
printAddOutcome(mode);
|
|
100
103
|
});
|
|
101
104
|
// ── accounts add-openai ──────────────────────────────────────────────────
|
|
102
105
|
accounts
|
|
@@ -135,9 +138,9 @@ export function registerAccounts(program) {
|
|
|
135
138
|
expiresAt,
|
|
136
139
|
scopes,
|
|
137
140
|
});
|
|
138
|
-
|
|
141
|
+
const { mode } = await addAccountRuntimeAware(record);
|
|
139
142
|
console.log(chalk.green(`\n✓ OpenAI account "${record.id}" saved.\n`));
|
|
140
|
-
|
|
143
|
+
printAddOutcome(mode);
|
|
141
144
|
console.log(chalk.yellow(" Treat this as experimental until the OAuth login wizard lands.\n"));
|
|
142
145
|
});
|
|
143
146
|
// ── accounts login-openai ────────────────────────────────────────────────
|
|
@@ -163,9 +166,9 @@ export function registerAccounts(program) {
|
|
|
163
166
|
console.log(chalk.gray("Waiting for authorization..."));
|
|
164
167
|
},
|
|
165
168
|
});
|
|
166
|
-
|
|
169
|
+
const { mode } = await addAccountRuntimeAware(record);
|
|
167
170
|
console.log(chalk.green(`\n✓ OpenAI account "${record.id}" saved via device login.\n`));
|
|
168
|
-
|
|
171
|
+
printAddOutcome(mode);
|
|
169
172
|
});
|
|
170
173
|
// ── accounts remove ───────────────────────────────────────────────────────
|
|
171
174
|
accounts
|
|
@@ -196,13 +199,17 @@ export function registerAccounts(program) {
|
|
|
196
199
|
console.log(chalk.gray("Cancelled."));
|
|
197
200
|
return;
|
|
198
201
|
}
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
+
const isOpenAI = openAIAccounts.some(account => account.id === id);
|
|
203
|
+
try {
|
|
204
|
+
await removeAccountRuntimeAware(id);
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
208
|
+
console.log(chalk.red(`✗ Could not remove "${id}": ${message}`));
|
|
202
209
|
process.exit(1);
|
|
203
210
|
}
|
|
204
211
|
const remaining = loadAccounts().length + loadOpenAIAccounts().length;
|
|
205
|
-
const providerLabel =
|
|
212
|
+
const providerLabel = isOpenAI ? "OpenAI account" : "Account";
|
|
206
213
|
console.log(chalk.green(`✓ Removed ${providerLabel} "${id}". ${remaining} account(s) remaining.`));
|
|
207
214
|
if (remaining === 0) {
|
|
208
215
|
console.log(chalk.yellow(" No accounts left. Run: cc-router setup"));
|
|
@@ -210,6 +217,12 @@ export function registerAccounts(program) {
|
|
|
210
217
|
});
|
|
211
218
|
}
|
|
212
219
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
220
|
+
/** Tell the user whether the new account is already live or needs a restart. */
|
|
221
|
+
function printAddOutcome(mode) {
|
|
222
|
+
console.log(mode === "live"
|
|
223
|
+
? chalk.gray(" Loaded into the running proxy — available now, no restart needed.\n")
|
|
224
|
+
: chalk.gray(" Restart the proxy to load the new account: cc-router start\n"));
|
|
225
|
+
}
|
|
213
226
|
export function buildStoredAccountsJson(anthropicAccounts, openAIAccounts) {
|
|
214
227
|
return [
|
|
215
228
|
...anthropicAccounts.map(a => ({
|
|
@@ -227,6 +240,96 @@ export function buildStoredAccountsJson(anthropicAccounts, openAIAccounts) {
|
|
|
227
240
|
})),
|
|
228
241
|
];
|
|
229
242
|
}
|
|
243
|
+
/** Return false only when no running proxy can be reached; HTTP errors remain authoritative. */
|
|
244
|
+
export async function tryRemoveAccountFromRunningProxy(id, options = {}) {
|
|
245
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
246
|
+
const baseUrl = (options.baseUrl ?? `http://localhost:${PROXY_PORT}`).replace(/\/+$/, "");
|
|
247
|
+
const authToken = options.authToken ?? readConfig().proxySecret;
|
|
248
|
+
let response;
|
|
249
|
+
try {
|
|
250
|
+
response = await fetchImpl(`${baseUrl}/cc-router/accounts/${encodeURIComponent(id)}`, {
|
|
251
|
+
method: "DELETE",
|
|
252
|
+
headers: authToken ? { authorization: `Bearer ${authToken}` } : {},
|
|
253
|
+
signal: AbortSignal.timeout(3_000),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
if (!response.ok) {
|
|
260
|
+
let detail = "";
|
|
261
|
+
try {
|
|
262
|
+
const payload = await response.json();
|
|
263
|
+
if (typeof payload.error === "string")
|
|
264
|
+
detail = `: ${payload.error}`;
|
|
265
|
+
}
|
|
266
|
+
catch { /* best effort */ }
|
|
267
|
+
throw new Error(`HTTP ${response.status}${detail}`);
|
|
268
|
+
}
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
export async function removeAccountRuntimeAware(id, dependencies = {
|
|
272
|
+
tryRemoveLive: tryRemoveAccountFromRunningProxy,
|
|
273
|
+
removeStored: removeAccountRecordById,
|
|
274
|
+
}) {
|
|
275
|
+
if (await dependencies.tryRemoveLive(id))
|
|
276
|
+
return { mode: "live" };
|
|
277
|
+
const removed = dependencies.removeStored(id);
|
|
278
|
+
if (!removed)
|
|
279
|
+
throw new Error(`Account "${id}" disappeared before it could be removed`);
|
|
280
|
+
return { mode: "stored", removed };
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Add an account to a running proxy so it becomes routable without a restart.
|
|
284
|
+
* Returns false only when no running proxy can be reached (the caller then
|
|
285
|
+
* persists to disk itself); HTTP error responses — e.g. 409 for a duplicate —
|
|
286
|
+
* are authoritative and thrown so the caller does not silently write to disk.
|
|
287
|
+
*/
|
|
288
|
+
export async function tryAddAccountToRunningProxy(record, options = {}) {
|
|
289
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
290
|
+
const baseUrl = (options.baseUrl ?? `http://localhost:${PROXY_PORT}`).replace(/\/+$/, "");
|
|
291
|
+
const authToken = options.authToken ?? readConfig().proxySecret;
|
|
292
|
+
let response;
|
|
293
|
+
try {
|
|
294
|
+
response = await fetchImpl(`${baseUrl}/cc-router/accounts`, {
|
|
295
|
+
method: "POST",
|
|
296
|
+
headers: {
|
|
297
|
+
"content-type": "application/json",
|
|
298
|
+
...(authToken ? { authorization: `Bearer ${authToken}` } : {}),
|
|
299
|
+
},
|
|
300
|
+
body: JSON.stringify(record),
|
|
301
|
+
signal: AbortSignal.timeout(3_000),
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
if (!response.ok) {
|
|
308
|
+
let detail = "";
|
|
309
|
+
try {
|
|
310
|
+
const payload = await response.json();
|
|
311
|
+
if (typeof payload.error === "string")
|
|
312
|
+
detail = `: ${payload.error}`;
|
|
313
|
+
}
|
|
314
|
+
catch { /* best effort */ }
|
|
315
|
+
throw new Error(`HTTP ${response.status}${detail}`);
|
|
316
|
+
}
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Persist a newly added account so it is usable immediately. When a proxy is
|
|
321
|
+
* running the record is handed to it (live pool + disk in one step); otherwise
|
|
322
|
+
* it is written to disk via `addStored` and picked up on the next start.
|
|
323
|
+
*/
|
|
324
|
+
export async function addAccountRuntimeAware(record, dependencies = {
|
|
325
|
+
tryAddLive: tryAddAccountToRunningProxy,
|
|
326
|
+
addStored: upsertAccountRecord,
|
|
327
|
+
}) {
|
|
328
|
+
if (await dependencies.tryAddLive(record))
|
|
329
|
+
return { mode: "live" };
|
|
330
|
+
dependencies.addStored(record);
|
|
331
|
+
return { mode: "stored" };
|
|
332
|
+
}
|
|
230
333
|
async function fetchLiveStats() {
|
|
231
334
|
try {
|
|
232
335
|
const res = await fetch(`http://localhost:${PROXY_PORT}/cc-router/health`, {
|
package/dist/cli/index.js
CHANGED
|
File without changes
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { parseSseLines } from "./sse.js";
|
|
2
|
+
function upstreamError(message) {
|
|
3
|
+
return { kind: "json", status: 502, body: { error: { type: "upstream_error", message } } };
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Collapse the Codex backend's forced SSE stream into a single Responses
|
|
7
|
+
* object for callers that did not ask to stream. The backend's terminal
|
|
8
|
+
* `response.completed` payload is returned verbatim, preserving tool calls,
|
|
9
|
+
* reasoning, and usage.
|
|
10
|
+
*/
|
|
11
|
+
export async function collectCodexResponseStream(upstream) {
|
|
12
|
+
if (!upstream.ok) {
|
|
13
|
+
const contentType = upstream.headers.get("content-type") ?? undefined;
|
|
14
|
+
return { kind: "text", status: upstream.status, contentType, body: await upstream.text() };
|
|
15
|
+
}
|
|
16
|
+
const contentType = upstream.headers.get("content-type") ?? "";
|
|
17
|
+
if (contentType.includes("application/json")) {
|
|
18
|
+
try {
|
|
19
|
+
return { kind: "json", status: upstream.status, body: await upstream.json() };
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return upstreamError("Malformed upstream JSON body");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const reader = upstream.body?.getReader();
|
|
26
|
+
if (!reader)
|
|
27
|
+
return upstreamError("Empty upstream body");
|
|
28
|
+
const decoder = new TextDecoder();
|
|
29
|
+
let remainder = "";
|
|
30
|
+
let completed;
|
|
31
|
+
let failure;
|
|
32
|
+
const applyEvent = (event) => {
|
|
33
|
+
if (typeof event !== "object" || event === null)
|
|
34
|
+
return;
|
|
35
|
+
const e = event;
|
|
36
|
+
if (e.type === "response.completed") {
|
|
37
|
+
completed = e.response;
|
|
38
|
+
}
|
|
39
|
+
else if (e.type === "response.failed") {
|
|
40
|
+
const err = e.response?.error;
|
|
41
|
+
failure = err?.message ?? "Response failed";
|
|
42
|
+
}
|
|
43
|
+
else if (e.type === "error") {
|
|
44
|
+
failure = e.error?.message ?? "Upstream error event";
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
try {
|
|
48
|
+
while (true) {
|
|
49
|
+
const { value, done } = await reader.read();
|
|
50
|
+
if (done)
|
|
51
|
+
break;
|
|
52
|
+
const parsed = parseSseLines(remainder + decoder.decode(value, { stream: true }));
|
|
53
|
+
remainder = parsed.remainder;
|
|
54
|
+
for (const event of parsed.events)
|
|
55
|
+
applyEvent(event);
|
|
56
|
+
}
|
|
57
|
+
const tail = remainder + decoder.decode();
|
|
58
|
+
if (tail.length > 0) {
|
|
59
|
+
for (const event of parseSseLines(tail + "\n").events)
|
|
60
|
+
applyEvent(event);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return upstreamError("Malformed upstream stream");
|
|
65
|
+
}
|
|
66
|
+
if (failure !== undefined)
|
|
67
|
+
return upstreamError(failure);
|
|
68
|
+
if (completed === undefined)
|
|
69
|
+
return upstreamError("Stream ended before response.completed");
|
|
70
|
+
return { kind: "json", status: upstream.status, body: completed };
|
|
71
|
+
}
|