@karpeleslab/teamclaude 1.0.7 → 1.0.8
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/README.md +85 -4
- package/package.json +1 -1
- package/src/account-manager.js +182 -27
- package/src/account-uuid-rewrite.js +115 -0
- package/src/h2/frames.js +83 -0
- package/src/h2/hpack.js +314 -0
- package/src/h2/relay.js +417 -0
- package/src/index.js +177 -22
- package/src/json-format-stream.js +65 -0
- package/src/mitm.js +387 -0
- package/src/oauth.js +76 -1
- package/src/prober.js +82 -0
- package/src/request-log.js +194 -0
- package/src/server.js +148 -90
- package/src/sx.js +218 -0
- package/src/tui.js +166 -5
- package/src/upstream-fetch.js +85 -0
- package/src/x509.js +166 -0
package/README.md
CHANGED
|
@@ -15,13 +15,17 @@ Sits transparently between Claude Code and the Anthropic API, managing multiple
|
|
|
15
15
|
|
|
16
16
|
- **Automatic account rotation** — switches to the next account when session (5h) or weekly (7d) quota reaches the configured threshold (default 98%)
|
|
17
17
|
- **Auto-retry on 429** — waits the `retry-after` duration and retries the same account; switches to the next on persistent errors
|
|
18
|
-
- **Interactive TUI** — real-time dashboard with color-coded quota bars, reset countdowns, activity log, and keyboard controls
|
|
18
|
+
- **Interactive TUI** — real-time dashboard with color-coded quota bars, reset countdowns, activity log, and keyboard controls; a settings screen (`g`) edits the rotation threshold, quota-probe interval, and sx.org proxy live
|
|
19
19
|
- **OAuth token management** — automatically refreshes tokens nearing expiry and persists them to config; client token refreshes pass through untouched
|
|
20
|
-
- **Hot-reload accounts** — add
|
|
20
|
+
- **Hot-reload accounts** — add or change accounts while the server is running; press **R** in the TUI, or run headless and CLI changes auto-reload via a local control endpoint
|
|
21
|
+
- **Headless mode** — run the proxy without the TUI (`--headless`) for backgrounding/services
|
|
21
22
|
- **Org-aware accounts** — one email can hold multiple accounts across different organizations (e.g. corp + personal); dedup is keyed on account + org, and names disambiguate as `email (Org)`
|
|
22
23
|
- **Rotation priority** — pin a preferred account order with `teamclaude priority`
|
|
23
24
|
- **Enable/disable accounts** — temporarily pause an account without removing it (`teamclaude disable`/`enable`, or `d` in the TUI); re-enabling also clears a stuck error state
|
|
24
25
|
- **Quota persistence** — observed quota survives restarts (saved to a sibling state file), so rotation state isn't lost on restart; stale windows are discarded automatically
|
|
26
|
+
- **Optional quota probe** — off by default; when enabled, periodically refreshes idle accounts' quota from the usage endpoint (no message spend), and surfaces the Sonnet weekly bucket
|
|
27
|
+
- **Optional MITM proxy mode** — `teamclaude run --mitm` routes claude via an HTTPS forward proxy with a local CA so even hardcoded `api.anthropic.com` endpoints (e.g. the Claude Design MCP) get the real token injected
|
|
28
|
+
- **Optional sx.org proxy mode** — off by default; set an [sx.org](https://sx.org) API key in the TUI settings screen (`g`) and TeamClaude auto-provisions a residential proxy to change the egress IP and work around IP-based `429`s. Three modes (`m` to cycle): **always** (route all upstream traffic), **on 429 only** (stay direct, fail over to the proxy after a 429), or **off** (keep the key but don't use it). TLS stays end-to-end with Anthropic (the proxy only relays ciphertext)
|
|
25
29
|
- **Request logging** — optional full request/response logging for debugging
|
|
26
30
|
- **Zero dependencies** — uses only Node.js built-in modules
|
|
27
31
|
|
|
@@ -103,7 +107,15 @@ When running from a TTY, shows an interactive TUI with:
|
|
|
103
107
|
- Real-time activity log with request tracking
|
|
104
108
|
- Keyboard shortcuts (see below)
|
|
105
109
|
|
|
106
|
-
Falls back to plain log output when not a TTY (e.g. running as a service).
|
|
110
|
+
Falls back to plain log output when not a TTY (e.g. running as a service). Pass `--headless` (or `--no-tui`) to force the plain-log mode even from a terminal — useful for backgrounding the proxy.
|
|
111
|
+
|
|
112
|
+
When running headless, you can re-sync accounts from the config without a restart by POSTing to the local control endpoint (the equivalent of pressing **R** in the TUI):
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
curl -X POST http://localhost:3456/teamclaude/reload
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
You usually don't need to call it directly: `teamclaude login`, `import`, `enable`, `disable`, and `priority` automatically notify a running server to reload. (New accounts and credential/priority/enable-disable changes are picked up live; account *removals* still require a restart.)
|
|
107
119
|
|
|
108
120
|
#### TUI Keyboard Shortcuts
|
|
109
121
|
|
|
@@ -154,6 +166,7 @@ teamclaude remove <name> # Remove an account (by name or email)
|
|
|
154
166
|
teamclaude disable <name> # Temporarily exclude an account from rotation
|
|
155
167
|
teamclaude enable <name> # Re-enable it (also clears a stuck error state)
|
|
156
168
|
teamclaude priority <name> 1 # Set rotation priority (lower = preferred)
|
|
169
|
+
teamclaude probe 300 # Enable background quota refresh (off by default)
|
|
157
170
|
teamclaude alias # Print/install a `claude` alias that routes via the proxy
|
|
158
171
|
teamclaude api <path> # Call an API endpoint with account credentials
|
|
159
172
|
teamclaude help # Show all commands
|
|
@@ -195,6 +208,7 @@ TEAMCLAUDE_CONFIG=./my-config.json teamclaude server
|
|
|
195
208
|
},
|
|
196
209
|
"upstream": "https://api.anthropic.com",
|
|
197
210
|
"switchThreshold": 0.98,
|
|
211
|
+
"sx": { "apiKey": "your-sx-org-api-key", "mode": "always" },
|
|
198
212
|
"accounts": [
|
|
199
213
|
{
|
|
200
214
|
"name": "user@example.com (Acme)",
|
|
@@ -216,12 +230,79 @@ TEAMCLAUDE_CONFIG=./my-config.json teamclaude server
|
|
|
216
230
|
| `proxy.port` | Local port the proxy listens on |
|
|
217
231
|
| `proxy.apiKey` | API key clients use to authenticate with the proxy |
|
|
218
232
|
| `upstream` | Upstream API base URL |
|
|
219
|
-
| `switchThreshold` | Quota utilization (0–1) at which to switch accounts |
|
|
233
|
+
| `switchThreshold` | Quota utilization (0–1) at which to switch accounts (TUI: `g` → `t`) |
|
|
234
|
+
| `quotaProbeSeconds` | Background quota-probe interval in seconds (`0` = off, the default; CLI `probe` or TUI `g` → `p`) |
|
|
235
|
+
| `sx.apiKey` | [sx.org](https://sx.org) API key. When set, TeamClaude auto-provisions a residential proxy (egress-IP 429 workaround). Absent/empty = off |
|
|
236
|
+
| `sx.mode` | `always` (route all upstream traffic), `429` (direct, fail over to the proxy after a 429), or `off` (keep the key but don't use it). Defaults to `always` when a key is set |
|
|
220
237
|
| `accounts[].accountUuid` | Anthropic account (person) id; set automatically from the OAuth profile |
|
|
221
238
|
| `accounts[].orgUuid` / `orgName` | Organization the account is scoped to — lets one email hold multiple org accounts |
|
|
222
239
|
| `accounts[].priority` | Rotation preference, lower = preferred (default 0) |
|
|
223
240
|
| `accounts[].disabled` | If `true`, the account is excluded from rotation until re-enabled |
|
|
224
241
|
|
|
242
|
+
### Quota probe (optional, off by default)
|
|
243
|
+
|
|
244
|
+
By default TeamClaude is **passive** — it learns each account's quota only from the responses that flow through it, so an account that hasn't been used yet shows unknown quota until it's first rotated to.
|
|
245
|
+
|
|
246
|
+
If you'd rather keep idle accounts' quota fresh, enable the background probe:
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
teamclaude probe 300 # refresh every 300s
|
|
250
|
+
teamclaude probe off # back to passive (default)
|
|
251
|
+
teamclaude probe # show current setting
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
You can also set the interval live from the TUI settings screen (`g` → `p`), alongside the rotation threshold (`t`).
|
|
255
|
+
|
|
256
|
+
It reads each OAuth account's utilization from Anthropic's usage endpoint (`/api/oauth/usage`), which reports quota **without consuming any message quota**. Minimum interval is 30s. Changing it takes effect on a running server immediately (no restart). When enabled, it also surfaces the **Sonnet 7-day** bucket as an extra bar in the TUI / `status` (when your plan exposes it).
|
|
257
|
+
|
|
258
|
+
### MITM proxy mode (optional, off by default)
|
|
259
|
+
|
|
260
|
+
The normal reverse-proxy only intercepts what `ANTHROPIC_BASE_URL` covers. Some Claude Code features (e.g. the **Claude Design MCP**) use a **hardcoded** `https://api.anthropic.com` URL that ignores that variable, so they bypass the proxy. MITM proxy mode captures those too.
|
|
261
|
+
|
|
262
|
+
Run claude with the `--mitm` flag:
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
teamclaude run --mitm -- <claude args...>
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
That launches claude pointed at teamclaude as an **HTTPS forward proxy** (`HTTPS_PROXY`) and trusts a locally-generated CA (`NODE_EXTRA_CA_CERTS`). For an intercepted host, teamclaude **dials the real upstream first, mirrors its negotiated ALPN** (HTTP/2 or HTTP/1.1), then terminates TLS toward claude with the same protocol and relays the traffic **as transparently as possible** — rewriting only what it must:
|
|
269
|
+
|
|
270
|
+
- the **`authorization`** header → the active account's real token (dropping any client `x-api-key`);
|
|
271
|
+
- the **`account_uuid`** inside `metadata.user_id` → the active account's UUID (so the body agrees with the injected token);
|
|
272
|
+
- and it reads `anthropic-ratelimit-*` from responses for quota.
|
|
273
|
+
|
|
274
|
+
Everything else is copied byte-for-byte (HTTP/2 is handled with a built-in HPACK codec so the only header changed is the auth one). Any host other than the upstream is blind-tunnelled. The server accepts *both* base-URL and proxy clients at once, so instances launched with and without `--mitm` can share one server.
|
|
275
|
+
|
|
276
|
+
Trust model:
|
|
277
|
+
- The CA is generated locally, stored in the config dir, and trusted **only** by the claude process you launch via `teamclaude run` (through `NODE_EXTRA_CA_CERTS`) — it is **never** added to your system trust store. The leaf private key is `0600`; the CA private key is never written to disk.
|
|
278
|
+
- teamclaude still verifies the **real** Anthropic certificate on the upstream leg.
|
|
279
|
+
|
|
280
|
+
Verify the proxy + CA without any credentials — the proxy always answers a built-in test host:
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
# (with the server running and certs generated, e.g. after one `teamclaude run`)
|
|
284
|
+
curl --proxy http://localhost:3456 --cacert ~/.config/teamclaude-ca.pem https://www.example.org/
|
|
285
|
+
# → {"teamclaude":"mitm-proxy-ok","host":"www.example.org",...}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### sx.org proxy mode (optional, off by default)
|
|
289
|
+
|
|
290
|
+
Some transient `429`s key on the proxy's **outbound IP**, not the account — so rotating accounts doesn't help. To work around them, TeamClaude can route upstream requests through a residential proxy from [sx.org](https://sx.org), giving a different egress IP.
|
|
291
|
+
|
|
292
|
+
Open the TUI and press **`g`** for the settings screen, then **`k`** to paste your sx.org API key (stored in `config.sx.apiKey`). TeamClaude reuses an existing active proxy port on your sx.org account, or auto-creates a residential US one, and dials the upstream through it via HTTP `CONNECT` on **both** the reverse-proxy and `--mitm` paths.
|
|
293
|
+
|
|
294
|
+
Press **`m`** to cycle the **mode**:
|
|
295
|
+
|
|
296
|
+
| Mode | Behavior |
|
|
297
|
+
|------|----------|
|
|
298
|
+
| **always** | Tunnel **every** upstream request through sx.org. |
|
|
299
|
+
| **on 429 only** | Connect directly; on a `429` (which is IP-based), immediately retry that request through sx.org's fresh egress IP — no wait. On the `--mitm` path, a recent `429` routes new tunnels through sx.org for a short window. |
|
|
300
|
+
| **off** | Never use sx.org, but **keep the API key** so you can re-enable it instantly. |
|
|
301
|
+
|
|
302
|
+
TLS is established **end-to-end with `api.anthropic.com` over the tunnel**, so the sx.org proxy only ever relays ciphertext and the real Anthropic certificate is still verified. Mode and key changes apply live (no restart). Press **`x`** to forget the key entirely.
|
|
303
|
+
|
|
304
|
+
> **Cost:** in **always** mode *all* Claude traffic flows through the residential proxy, which sx.org meters by bandwidth — expect real per-GB cost. **on 429 only** uses the proxy just when you're actually being throttled, so it's the cheaper way to ride out rate limits.
|
|
305
|
+
|
|
225
306
|
## How It Works
|
|
226
307
|
|
|
227
308
|
1. Claude Code connects to the local proxy instead of `api.anthropic.com`
|
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -5,7 +5,8 @@ import { sameIdentity } from './identity.js';
|
|
|
5
5
|
// windows, learned passively from upstream responses. Transient/derived state
|
|
6
6
|
// (probing, requalify, rateLimitedUntil) is intentionally excluded.
|
|
7
7
|
const PERSISTED_QUOTA_FIELDS = [
|
|
8
|
-
'unified5h', 'unified7d', '
|
|
8
|
+
'unified5h', 'unified7d', 'unified7dSonnet',
|
|
9
|
+
'unified5hReset', 'unified7dReset', 'unified7dSonnetReset', 'unifiedStatus',
|
|
9
10
|
'tokensLimit', 'tokensRemaining', 'requestsLimit', 'requestsRemaining', 'resetsAt',
|
|
10
11
|
];
|
|
11
12
|
|
|
@@ -17,17 +18,22 @@ function emptyQuota() {
|
|
|
17
18
|
requestsLimit: null,
|
|
18
19
|
requestsRemaining: null,
|
|
19
20
|
// Unified rate limits (Claude Max accounts)
|
|
20
|
-
unified5h: null,
|
|
21
|
-
unified7d: null,
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
unified5h: null, // utilization 0-1
|
|
22
|
+
unified7d: null, // utilization 0-1
|
|
23
|
+
unified7dSonnet: null, // utilization 0-1 (Sonnet-specific weekly bucket)
|
|
24
|
+
unified5hReset: null, // ms timestamp
|
|
25
|
+
unified7dReset: null, // ms timestamp
|
|
26
|
+
unified7dSonnetReset: null, // ms timestamp
|
|
27
|
+
unifiedStatus: null, // allowed | allowed_warning | rejected
|
|
25
28
|
resetsAt: null,
|
|
26
29
|
};
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
export class AccountManager {
|
|
30
|
-
constructor(accounts, switchThreshold = 0.98) {
|
|
33
|
+
constructor(accounts, switchThreshold = 0.98, { refreshFn = refreshAccessToken } = {}) {
|
|
34
|
+
// Injectable for tests (mirrors Prober's probeFn); defaults to the real
|
|
35
|
+
// OAuth token refresh.
|
|
36
|
+
this._refreshFn = refreshFn;
|
|
31
37
|
this.accounts = accounts.map((acct, index) => ({
|
|
32
38
|
index,
|
|
33
39
|
name: acct.name,
|
|
@@ -55,35 +61,114 @@ export class AccountManager {
|
|
|
55
61
|
}));
|
|
56
62
|
this.currentIndex = 0;
|
|
57
63
|
this.switchThreshold = switchThreshold;
|
|
64
|
+
// When every account reads as over-quota we would otherwise refuse locally
|
|
65
|
+
// forever (a stale cached utilization is never re-validated because no
|
|
66
|
+
// request is ever sent). Instead, allow one real upstream probe at most this
|
|
67
|
+
// often to refresh the cached quota. See _selectProbe.
|
|
68
|
+
this.probeIntervalMs = 60_000;
|
|
69
|
+
this._nextProbeAt = 0;
|
|
58
70
|
}
|
|
59
71
|
|
|
60
72
|
/**
|
|
61
73
|
* Get the best available account, rotating if the current one is near quota.
|
|
62
74
|
* Returns null if all accounts are exhausted.
|
|
63
75
|
*/
|
|
64
|
-
getActiveAccount() {
|
|
76
|
+
getActiveAccount(exclude = null) {
|
|
65
77
|
// Clear expired quotas across all accounts and switch proactively if a
|
|
66
78
|
// session reset made a sooner-expiring account the better choice. This runs
|
|
67
79
|
// on every request so the behaviour holds without the TUI render loop.
|
|
68
80
|
this.refreshExpiredQuotas();
|
|
69
81
|
const current = this.accounts[this.currentIndex];
|
|
82
|
+
// `exclude` is a per-request set of indices already tried this request (e.g.
|
|
83
|
+
// an account that just threw a transport error). It is never a persistent
|
|
84
|
+
// status change — the account stays healthy for the next request.
|
|
70
85
|
// We just learned a probed account's weekly quota — re-evaluate which
|
|
71
86
|
// account is best now that its limit is known.
|
|
72
87
|
if (current && current.requalify) {
|
|
73
88
|
current.requalify = false;
|
|
74
|
-
const next = this._selectNext();
|
|
89
|
+
const next = this._selectNext(exclude);
|
|
75
90
|
if (next) return next;
|
|
76
91
|
}
|
|
77
|
-
if (this._isAvailable(current)) {
|
|
92
|
+
if (this._isAvailable(current) && !exclude?.has(current.index)) {
|
|
78
93
|
// A strictly higher-priority (lower value) available account preempts a
|
|
79
94
|
// healthy current one. Within the same priority tier we stay put, so the
|
|
80
95
|
// common case (all accounts at the default priority 0) is unchanged and
|
|
81
96
|
// never thrashes — preemption only triggers when priorities differ.
|
|
82
97
|
const betterExists = this.accounts.some(a =>
|
|
83
|
-
this._isAvailable(a) && (a.priority || 0) < (current.priority || 0));
|
|
84
|
-
return betterExists ? this._selectNext() : current;
|
|
98
|
+
this._isAvailable(a) && !exclude?.has(a.index) && (a.priority || 0) < (current.priority || 0));
|
|
99
|
+
return betterExists ? this._selectNext(exclude) : current;
|
|
85
100
|
}
|
|
86
|
-
|
|
101
|
+
const next = this._selectNext(exclude);
|
|
102
|
+
if (next) return next;
|
|
103
|
+
// No account is under the switch threshold. Before refusing locally, allow a
|
|
104
|
+
// throttled probe so a stale/poisoned cached quota can't pin us in a
|
|
105
|
+
// permanent "all exhausted" state — the probe's real response refreshes the
|
|
106
|
+
// quota (or upstream's own 429 converts soft exhaustion into a hard
|
|
107
|
+
// rate-limit hold). null here means the caller emits the synthetic 429.
|
|
108
|
+
return this._selectProbe(exclude);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
_isProbeable(account) {
|
|
112
|
+
if (!account) return false;
|
|
113
|
+
// Never probe an account the operator has taken out of rotation, one whose
|
|
114
|
+
// token is broken, or one upstream has explicitly rate-limited — those are
|
|
115
|
+
// hard states, not stale soft-quota guesses.
|
|
116
|
+
if (account.disabled) return false;
|
|
117
|
+
if (account.status === 'error' || account.status === 'exhausted') return false;
|
|
118
|
+
if (account.status === 'throttled' && account.rateLimitedUntil
|
|
119
|
+
&& Date.now() < account.rateLimitedUntil) return false;
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Highest utilization across all known quota dimensions (0-1), used to pick
|
|
124
|
+
* the least-exhausted probe target. Mirrors the ratios in _isNearQuota. */
|
|
125
|
+
_maxUtilization(account) {
|
|
126
|
+
const q = account.quota;
|
|
127
|
+
let max = 0;
|
|
128
|
+
if (q.unified5h != null) max = Math.max(max, q.unified5h);
|
|
129
|
+
if (q.unified7d != null) max = Math.max(max, q.unified7d);
|
|
130
|
+
if (q.tokensLimit != null && q.tokensRemaining != null) {
|
|
131
|
+
max = Math.max(max, 1 - q.tokensRemaining / q.tokensLimit);
|
|
132
|
+
}
|
|
133
|
+
if (q.requestsLimit != null && q.requestsRemaining != null) {
|
|
134
|
+
max = Math.max(max, 1 - q.requestsRemaining / q.requestsLimit);
|
|
135
|
+
}
|
|
136
|
+
return max;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Pick an account to send a single revalidation probe upstream when every
|
|
141
|
+
* account reads as over the switch threshold. Throttled to one probe per
|
|
142
|
+
* probeIntervalMs so a genuinely-exhausted fleet isn't hammered — between
|
|
143
|
+
* probes this returns null and the caller falls back to the synthetic 429.
|
|
144
|
+
* The chosen account is the least-utilized probeable one (most likely to have
|
|
145
|
+
* stale headroom), so the refreshed quota corrects the cache fastest.
|
|
146
|
+
*/
|
|
147
|
+
_selectProbe(exclude = null) {
|
|
148
|
+
const now = Date.now();
|
|
149
|
+
if (now < this._nextProbeAt) return null;
|
|
150
|
+
|
|
151
|
+
let best = null;
|
|
152
|
+
let bestPriority = Infinity;
|
|
153
|
+
let bestUsage = Infinity;
|
|
154
|
+
for (const account of this.accounts) {
|
|
155
|
+
if (exclude?.has(account.index)) continue;
|
|
156
|
+
if (!this._isProbeable(account)) continue;
|
|
157
|
+
const priority = account.priority || 0;
|
|
158
|
+
const usage = this._maxUtilization(account);
|
|
159
|
+
if (priority < bestPriority ||
|
|
160
|
+
(priority === bestPriority && usage < bestUsage)) {
|
|
161
|
+
bestPriority = priority;
|
|
162
|
+
bestUsage = usage;
|
|
163
|
+
best = account;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (!best) return null;
|
|
167
|
+
|
|
168
|
+
this._nextProbeAt = now + this.probeIntervalMs;
|
|
169
|
+
this.currentIndex = best.index;
|
|
170
|
+
console.log(`[TeamClaude] All accounts over threshold — probing "${best.name}" to refresh quota`);
|
|
171
|
+
return best;
|
|
87
172
|
}
|
|
88
173
|
|
|
89
174
|
_isAvailable(account) {
|
|
@@ -134,6 +219,11 @@ export class AccountManager {
|
|
|
134
219
|
q.unifiedStatus = null;
|
|
135
220
|
changed = true;
|
|
136
221
|
}
|
|
222
|
+
if (q.unified7dSonnet != null && q.unified7dSonnetReset && now >= q.unified7dSonnetReset) {
|
|
223
|
+
q.unified7dSonnet = null;
|
|
224
|
+
q.unified7dSonnetReset = null;
|
|
225
|
+
changed = true;
|
|
226
|
+
}
|
|
137
227
|
|
|
138
228
|
// Clear expired standard quotas
|
|
139
229
|
if (q.resetsAt && now >= new Date(q.resetsAt).getTime()) {
|
|
@@ -224,22 +314,25 @@ export class AccountManager {
|
|
|
224
314
|
return false;
|
|
225
315
|
}
|
|
226
316
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
317
|
+
/**
|
|
318
|
+
* Pick the best available account by selection order, WITHOUT mutating state:
|
|
319
|
+
* 1. lowest `priority` value (operator-controlled; default 0, lower = preferred)
|
|
320
|
+
* 2. then the account with no known weekly limit — using it lets us
|
|
321
|
+
* discover its quota
|
|
322
|
+
* 3. then the account whose weekly limit expires soonest: that quota is
|
|
323
|
+
* closest to refreshing, so spending it first preserves accounts whose
|
|
324
|
+
* weekly window resets further out.
|
|
325
|
+
* With all priorities at the default 0, this reduces to the weekly-reset
|
|
326
|
+
* heuristic. Returns the account or null if none are available.
|
|
327
|
+
*/
|
|
328
|
+
_pickBestAvailable(exclude = null) {
|
|
237
329
|
let best = null;
|
|
238
330
|
let bestPriority = Infinity;
|
|
239
331
|
let bestReset = Infinity;
|
|
240
332
|
|
|
241
333
|
for (let i = 0; i < this.accounts.length; i++) {
|
|
242
334
|
const account = this.accounts[i];
|
|
335
|
+
if (exclude?.has(account.index)) continue;
|
|
243
336
|
// _isAvailable filters out accounts at/above the switch threshold, so the
|
|
244
337
|
// soonest-expiring pick only ever lands on an account whose 5-hour quota
|
|
245
338
|
// is still below 98%.
|
|
@@ -255,7 +348,31 @@ export class AccountManager {
|
|
|
255
348
|
best = account;
|
|
256
349
|
}
|
|
257
350
|
}
|
|
351
|
+
return best;
|
|
352
|
+
}
|
|
258
353
|
|
|
354
|
+
/**
|
|
355
|
+
* Select the active account up front (e.g. on daemon launch, once persisted
|
|
356
|
+
* quota has been restored) so we start on the highest-priority / soonest-
|
|
357
|
+
* resetting account instead of blindly on index 0. Mirrors rotation order.
|
|
358
|
+
* Returns the chosen account, or the existing current one if none are
|
|
359
|
+
* available (the server still starts; requests 429 until a window resets).
|
|
360
|
+
*/
|
|
361
|
+
selectActiveAccount() {
|
|
362
|
+
this.refreshExpiredQuotas(); // drop any restored windows that already expired
|
|
363
|
+
const best = this._pickBestAvailable();
|
|
364
|
+
if (!best) return this.accounts[this.currentIndex] || null;
|
|
365
|
+
this.currentIndex = best.index;
|
|
366
|
+
best.probing = best.quota.unified7dReset == null;
|
|
367
|
+
const wk = best.quota.unified7d != null
|
|
368
|
+
? `${(best.quota.unified7d * 100).toFixed(1)}% weekly used`
|
|
369
|
+
: 'weekly quota unknown';
|
|
370
|
+
console.log(`[TeamClaude] Starting on account "${best.name}" (priority ${best.priority || 0}, ${wk})`);
|
|
371
|
+
return best;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
_selectNext(exclude = null) {
|
|
375
|
+
const best = this._pickBestAvailable(exclude);
|
|
259
376
|
if (best) {
|
|
260
377
|
const switched = best.index !== this.currentIndex;
|
|
261
378
|
this.currentIndex = best.index;
|
|
@@ -273,6 +390,7 @@ export class AccountManager {
|
|
|
273
390
|
let soonestTime = Infinity;
|
|
274
391
|
|
|
275
392
|
for (const account of this.accounts) {
|
|
393
|
+
if (exclude?.has(account.index)) continue;
|
|
276
394
|
const resetTime = account.rateLimitedUntil
|
|
277
395
|
|| account.quota.unified5hReset
|
|
278
396
|
|| account.quota.unified7dReset
|
|
@@ -380,6 +498,37 @@ export class AccountManager {
|
|
|
380
498
|
}
|
|
381
499
|
}
|
|
382
500
|
|
|
501
|
+
/**
|
|
502
|
+
* Apply quota learned from the OAuth usage endpoint (the background probe).
|
|
503
|
+
* Updates utilization/reset for the 5h, 7d, and Sonnet-7d buckets WITHOUT
|
|
504
|
+
* touching usage counters — a probe is not real client traffic.
|
|
505
|
+
*/
|
|
506
|
+
applyUsageData(accountIndex, usage) {
|
|
507
|
+
const account = this.accounts[accountIndex];
|
|
508
|
+
if (!account || !usage) return;
|
|
509
|
+
const q = account.quota;
|
|
510
|
+
|
|
511
|
+
if (usage.fiveHour) {
|
|
512
|
+
if (usage.fiveHour.utilization != null) q.unified5h = usage.fiveHour.utilization;
|
|
513
|
+
if (usage.fiveHour.resetAt != null) q.unified5hReset = usage.fiveHour.resetAt;
|
|
514
|
+
}
|
|
515
|
+
if (usage.sevenDay) {
|
|
516
|
+
if (usage.sevenDay.utilization != null) q.unified7d = usage.sevenDay.utilization;
|
|
517
|
+
if (usage.sevenDay.resetAt != null) q.unified7dReset = usage.sevenDay.resetAt;
|
|
518
|
+
}
|
|
519
|
+
if (usage.sevenDaySonnet) {
|
|
520
|
+
if (usage.sevenDaySonnet.utilization != null) q.unified7dSonnet = usage.sevenDaySonnet.utilization;
|
|
521
|
+
if (usage.sevenDaySonnet.resetAt != null) q.unified7dSonnetReset = usage.sevenDaySonnet.resetAt;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// If we just learned this account's weekly window while probing, re-evaluate
|
|
525
|
+
// selection (same path as learning it from a live response).
|
|
526
|
+
if (account.probing && q.unified7dReset != null) {
|
|
527
|
+
account.probing = false;
|
|
528
|
+
account.requalify = true;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
383
532
|
/**
|
|
384
533
|
* Mark an account as rate-limited for a given duration.
|
|
385
534
|
*/
|
|
@@ -408,7 +557,7 @@ export class AccountManager {
|
|
|
408
557
|
account._refreshPromise = (async () => {
|
|
409
558
|
console.log(`[TeamClaude] Refreshing token for account "${account.name}"...`);
|
|
410
559
|
try {
|
|
411
|
-
const newTokens = await
|
|
560
|
+
const newTokens = await this._refreshFn(account.refreshToken);
|
|
412
561
|
account.credential = newTokens.accessToken;
|
|
413
562
|
account.refreshToken = newTokens.refreshToken;
|
|
414
563
|
account.expiresAt = newTokens.expiresAt;
|
|
@@ -416,10 +565,16 @@ export class AccountManager {
|
|
|
416
565
|
this._onTokenRefresh?.(accountIndex, newTokens);
|
|
417
566
|
} catch (err) {
|
|
418
567
|
console.error(`[TeamClaude] Token refresh failed for "${account.name}": ${err.message}`);
|
|
419
|
-
//
|
|
420
|
-
// a
|
|
421
|
-
|
|
568
|
+
// Reserve 'error' (which drops the account from rotation until re-login)
|
|
569
|
+
// for a GENUINE auth rejection: the refresh token itself is no longer
|
|
570
|
+
// valid — revoked, or invalidated by an account/plan migration. A
|
|
571
|
+
// transient failure (network, 5xx, timeout) must NOT sideline a healthy
|
|
572
|
+
// account: keep its current token and retry on the next request. This is
|
|
573
|
+
// what kept accounts wrongly "errored" after a momentary refresh blip.
|
|
574
|
+
const isAuthRejection = err.status === 400 || err.status === 401 || err.status === 403;
|
|
575
|
+
if (isAuthRejection) {
|
|
422
576
|
account.status = 'error';
|
|
577
|
+
console.error(`[TeamClaude] Account "${account.name}" needs re-login (refresh token rejected) — run: teamclaude login`);
|
|
423
578
|
}
|
|
424
579
|
} finally {
|
|
425
580
|
account._refreshPromise = null;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Rewrite the request body's account_uuid to match the account whose token we
|
|
2
|
+
// inject. Claude Code puts the logged-in account's UUID inside `metadata.user_id`
|
|
3
|
+
// (a stringified JSON) of /v1/messages; under rotation that would disagree with
|
|
4
|
+
// the injected token.
|
|
5
|
+
//
|
|
6
|
+
// This is a STREAMING, byte-exact JSON state machine — no regex, no whole-body
|
|
7
|
+
// buffering — so it handles arbitrarily large bodies fed in chunks. It tracks
|
|
8
|
+
// JSON structure (container stack, current key, in-string/escape) to find the
|
|
9
|
+
// `metadata.user_id` string value, and only inside that value does it look for
|
|
10
|
+
// the `account_uuid` field and overwrite its 36-char value with the new UUID
|
|
11
|
+
// (same length → no content-length/flow-control changes). A stray `account_uuid`
|
|
12
|
+
// elsewhere in the body (user content, tool results) is never touched.
|
|
13
|
+
|
|
14
|
+
// Byte sequence of `account_uuid":"` as it appears INSIDE the (escaped) user_id
|
|
15
|
+
// string: account_uuid \ " : \ "
|
|
16
|
+
const PREFIX = Buffer.from('account_uuid\\":\\"', 'latin1');
|
|
17
|
+
|
|
18
|
+
export class AccountUuidPatcher {
|
|
19
|
+
constructor(newUuid) {
|
|
20
|
+
this.newUuid = (typeof newUuid === 'string' && newUuid.length === 36) ? Buffer.from(newUuid, 'latin1') : null;
|
|
21
|
+
this.frames = []; // container stack: { container:'obj'|'arr', name, key, awaitingKey }
|
|
22
|
+
this.inStr = false;
|
|
23
|
+
this.esc = false;
|
|
24
|
+
this.readingKey = false;
|
|
25
|
+
this.keyBuf = [];
|
|
26
|
+
this.target = false; // inside the metadata.user_id string value
|
|
27
|
+
this.matchPos = 0; // PREFIX match progress (within target)
|
|
28
|
+
this.uuidRemaining = 0; // value bytes left to overwrite
|
|
29
|
+
this.done = false; // patched the one account_uuid already
|
|
30
|
+
this.changed = false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Feed a chunk; returns a same-length chunk (patched in place). */
|
|
34
|
+
push(chunk) {
|
|
35
|
+
if (!this.newUuid || this.done) return chunk;
|
|
36
|
+
const out = Buffer.from(chunk);
|
|
37
|
+
for (let i = 0; i < out.length; i++) {
|
|
38
|
+
out[i] = this.#byte(out[i]);
|
|
39
|
+
if (this.done) break; // rest passes through unchanged
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#top() { return this.frames[this.frames.length - 1]; }
|
|
45
|
+
|
|
46
|
+
#byte(b) {
|
|
47
|
+
if (this.target) return this.#targetByte(b);
|
|
48
|
+
|
|
49
|
+
if (this.inStr) {
|
|
50
|
+
if (this.esc) { this.esc = false; if (this.readingKey) this.keyBuf.push(b); return b; }
|
|
51
|
+
if (b === 0x5c) { this.esc = true; return b; } // backslash
|
|
52
|
+
if (b === 0x22) { // end of string
|
|
53
|
+
this.inStr = false;
|
|
54
|
+
if (this.readingKey) { this.#top().key = Buffer.from(this.keyBuf).toString('latin1'); this.keyBuf = []; this.readingKey = false; }
|
|
55
|
+
return b;
|
|
56
|
+
}
|
|
57
|
+
if (this.readingKey) this.keyBuf.push(b);
|
|
58
|
+
return b;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const top = this.#top();
|
|
62
|
+
switch (b) {
|
|
63
|
+
case 0x7b: this.frames.push({ container: 'obj', name: top ? top.key : null, key: null, awaitingKey: true }); break; // {
|
|
64
|
+
case 0x5b: this.frames.push({ container: 'arr', name: top ? top.key : null, key: null, awaitingKey: false }); break; // [
|
|
65
|
+
case 0x7d: case 0x5d: this.frames.pop(); break; // } ]
|
|
66
|
+
case 0x3a: if (top) top.awaitingKey = false; break; // : (key → value)
|
|
67
|
+
case 0x2c: if (top && top.container === 'obj') top.awaitingKey = true; break; // ,
|
|
68
|
+
case 0x22: // string start
|
|
69
|
+
if (top && top.container === 'obj' && top.awaitingKey) {
|
|
70
|
+
this.readingKey = true; this.keyBuf = []; this.inStr = true; this.esc = false;
|
|
71
|
+
} else {
|
|
72
|
+
this.inStr = true; this.esc = false; this.readingKey = false;
|
|
73
|
+
if (top && top.container === 'obj' && top.name === 'metadata' && top.key === 'user_id' && this.frames.length === 2) {
|
|
74
|
+
this.target = true; this.matchPos = 0; this.uuidRemaining = 0;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
break;
|
|
78
|
+
default: break; // scalars / whitespace
|
|
79
|
+
}
|
|
80
|
+
return b;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Inside the metadata.user_id string value: stream-match the account_uuid key
|
|
84
|
+
// and overwrite its 36-byte value. Detect the (unescaped) closing quote to exit.
|
|
85
|
+
#targetByte(b) {
|
|
86
|
+
if (this.uuidRemaining > 0) {
|
|
87
|
+
const outByte = this.newUuid[this.newUuid.length - this.uuidRemaining];
|
|
88
|
+
this.uuidRemaining--;
|
|
89
|
+
if (outByte !== b) this.changed = true;
|
|
90
|
+
if (this.uuidRemaining === 0) this.done = true; // only one account_uuid per body
|
|
91
|
+
return outByte;
|
|
92
|
+
}
|
|
93
|
+
if (this.esc) { this.esc = false; this.#match(b); return b; }
|
|
94
|
+
if (b === 0x5c) { this.esc = true; this.#match(b); return b; }
|
|
95
|
+
if (b === 0x22) { this.target = false; this.matchPos = 0; return b; } // end of user_id value
|
|
96
|
+
this.#match(b);
|
|
97
|
+
return b;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#match(b) {
|
|
101
|
+
if (b === PREFIX[this.matchPos]) {
|
|
102
|
+
this.matchPos++;
|
|
103
|
+
if (this.matchPos === PREFIX.length) { this.uuidRemaining = 36; this.matchPos = 0; }
|
|
104
|
+
} else {
|
|
105
|
+
this.matchPos = (b === PREFIX[0]) ? 1 : 0; // PREFIX has no internal repeat of its first byte
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** One-shot convenience (whole-buffer); returns the same instance if unchanged. */
|
|
111
|
+
export function patchAccountUuid(buf, newUuid) {
|
|
112
|
+
const p = new AccountUuidPatcher(newUuid);
|
|
113
|
+
const out = p.push(buf);
|
|
114
|
+
return p.changed ? out : buf;
|
|
115
|
+
}
|
package/src/h2/frames.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Minimal HTTP/2 framing (RFC 7540 §4) — just what the MITM relay needs:
|
|
2
|
+
// split a byte stream into frames, re-serialize frames, and (de)construct
|
|
3
|
+
// HEADERS/CONTINUATION header blocks so we can rewrite one header and re-emit.
|
|
4
|
+
// All other frame types are forwarded verbatim by the relay.
|
|
5
|
+
|
|
6
|
+
export const FRAME = {
|
|
7
|
+
DATA: 0x0, HEADERS: 0x1, PRIORITY: 0x2, RST_STREAM: 0x3, SETTINGS: 0x4,
|
|
8
|
+
PUSH_PROMISE: 0x5, PING: 0x6, GOAWAY: 0x7, WINDOW_UPDATE: 0x8, CONTINUATION: 0x9,
|
|
9
|
+
};
|
|
10
|
+
export const FLAG = { END_STREAM: 0x1, END_HEADERS: 0x4, PADDED: 0x8, PRIORITY: 0x20 };
|
|
11
|
+
|
|
12
|
+
// Client connection preface: "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" (RFC 7540 §3.5).
|
|
13
|
+
export const PREFACE = Buffer.from('505249202a20485454502f322e300d0a0d0a534d0d0a0d0a', 'hex');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Split as many complete frames as possible out of `buf`.
|
|
17
|
+
* Returns { frames, rest } where rest is the unconsumed tail (incomplete frame).
|
|
18
|
+
* Each frame: { type, flags, streamId, payload, raw } (payload/raw are subarrays).
|
|
19
|
+
*/
|
|
20
|
+
export function readFrames(buf) {
|
|
21
|
+
const frames = [];
|
|
22
|
+
let off = 0;
|
|
23
|
+
while (buf.length - off >= 9) {
|
|
24
|
+
const length = buf.readUIntBE(off, 3);
|
|
25
|
+
if (buf.length - off < 9 + length) break;
|
|
26
|
+
frames.push({
|
|
27
|
+
type: buf[off + 3],
|
|
28
|
+
flags: buf[off + 4],
|
|
29
|
+
streamId: buf.readUInt32BE(off + 5) & 0x7fffffff,
|
|
30
|
+
payload: buf.subarray(off + 9, off + 9 + length),
|
|
31
|
+
raw: buf.subarray(off, off + 9 + length),
|
|
32
|
+
});
|
|
33
|
+
off += 9 + length;
|
|
34
|
+
}
|
|
35
|
+
return { frames, rest: buf.subarray(off) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Serialize one frame. */
|
|
39
|
+
export function buildFrame({ type, flags = 0, streamId, payload = Buffer.alloc(0) }) {
|
|
40
|
+
const h = Buffer.alloc(9);
|
|
41
|
+
h.writeUIntBE(payload.length, 0, 3);
|
|
42
|
+
h[3] = type;
|
|
43
|
+
h[4] = flags;
|
|
44
|
+
h.writeUInt32BE(streamId & 0x7fffffff, 5);
|
|
45
|
+
return Buffer.concat([h, payload]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Pull the header-block fragment out of a HEADERS payload, stripping any
|
|
50
|
+
* PADDED / PRIORITY prefixes. Returns { block, priority } where priority is the
|
|
51
|
+
* 5-byte priority field (or null). Padding is discarded.
|
|
52
|
+
*/
|
|
53
|
+
export function stripHeadersPayload(payload, flags) {
|
|
54
|
+
let off = 0;
|
|
55
|
+
let padLen = 0;
|
|
56
|
+
if (flags & FLAG.PADDED) { padLen = payload[0]; off = 1; }
|
|
57
|
+
let priority = null;
|
|
58
|
+
if (flags & FLAG.PRIORITY) { priority = Buffer.from(payload.subarray(off, off + 5)); off += 5; }
|
|
59
|
+
const block = Buffer.from(payload.subarray(off, payload.length - padLen));
|
|
60
|
+
return { block, priority };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build a HEADERS frame (+ CONTINUATION frames if the block exceeds
|
|
65
|
+
* maxFrameSize) for a re-encoded header block. Padding is not re-added.
|
|
66
|
+
*/
|
|
67
|
+
export function buildHeaderBlock(streamId, block, { endStream = false, priority = null, maxFrameSize = 16384 } = {}) {
|
|
68
|
+
const prio = priority || Buffer.alloc(0);
|
|
69
|
+
const firstCap = Math.max(0, maxFrameSize - prio.length);
|
|
70
|
+
const firstChunk = block.subarray(0, firstCap);
|
|
71
|
+
let rest = block.subarray(firstChunk.length);
|
|
72
|
+
|
|
73
|
+
let hFlags = (endStream ? FLAG.END_STREAM : 0) | (priority ? FLAG.PRIORITY : 0);
|
|
74
|
+
if (rest.length === 0) hFlags |= FLAG.END_HEADERS;
|
|
75
|
+
|
|
76
|
+
const out = [buildFrame({ type: FRAME.HEADERS, flags: hFlags, streamId, payload: Buffer.concat([prio, firstChunk]) })];
|
|
77
|
+
while (rest.length) {
|
|
78
|
+
const chunk = rest.subarray(0, maxFrameSize);
|
|
79
|
+
rest = rest.subarray(chunk.length);
|
|
80
|
+
out.push(buildFrame({ type: FRAME.CONTINUATION, flags: rest.length === 0 ? FLAG.END_HEADERS : 0, streamId, payload: chunk }));
|
|
81
|
+
}
|
|
82
|
+
return Buffer.concat(out);
|
|
83
|
+
}
|