teamclaude-cloud 1.4.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/src/server.js ADDED
@@ -0,0 +1,1267 @@
1
+ import http from 'node:http';
2
+ import { writeFile, mkdir } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { isTokenExpiringSoon } from './oauth.js';
5
+
6
+
7
+ const HOP_BY_HOP_HEADERS = new Set([
8
+ 'host', 'connection', 'keep-alive', 'transfer-encoding',
9
+ 'te', 'trailer', 'upgrade', 'proxy-authorization', 'proxy-authenticate',
10
+ ]);
11
+
12
+ export function createProxyServer(accountManager, config, hooks = {}) {
13
+ const upstream = config.upstream || 'https://api.anthropic.com';
14
+ const proxyApiKey = config.proxy?.apiKey;
15
+ const logDir = config.logDir || null;
16
+ // How long a request may wait for a per-account concurrency slot to free when
17
+ // every available account is at its cap, before giving up with a 429. 0 = never
18
+ // queue (fail fast). Default 15s.
19
+ const queueTimeoutMs = Number.isFinite(config.overflowQueueTimeoutMs)
20
+ ? Math.max(0, config.overflowQueueTimeoutMs)
21
+ : 15000;
22
+ // Cap the buffered request body. The proxy must buffer the whole body to replay
23
+ // it across accounts on a 429/5xx, so an unbounded body is an unbounded buffer.
24
+ const maxBodyBytes = Number.isFinite(config.maxRequestBytes) && config.maxRequestBytes > 0
25
+ ? config.maxRequestBytes
26
+ : 32 * 1024 * 1024;
27
+ // Connection affinity: keep one client connection's sequential requests on the
28
+ // same account for prompt-cache locality (HTTP/1.1 keep-alive reuses the socket
29
+ // for a session's sequential turns). Soft — overflow still spreads. Set
30
+ // `sessionAffinity: false` to route purely by use-or-lose every request instead.
31
+ const sessionAffinity = config.sessionAffinity !== false;
32
+ let requestCounter = 0;
33
+ let inFlightProxied = 0; // proxied (non-status/oauth) requests currently being handled
34
+
35
+ if (logDir) {
36
+ mkdir(logDir, { recursive: true }).catch(() => {});
37
+ }
38
+
39
+ // ── Active warm-up ─────────────────────────────────────────────────────────
40
+ // Quota is only learned from real upstream rate-limit headers (Anthropic has no
41
+ // "get my quota" endpoint), so a freshly (re)started proxy shows the whole fleet
42
+ // as "—" until client traffic happens to flow through every account. Active
43
+ // warm-up fixes that: it stages a request template from the first genuine
44
+ // /v1/messages and COMMITS it only after upstream accepts that request (2xx) —
45
+ // so a model/header combo upstream would reject can't seed a template that makes
46
+ // every probe fail. The committed template (exact model + anthropic-version +
47
+ // anthropic-beta + Claude-Code system) is replayed as a minimal probe
48
+ // (max_tokens: 1) against each still-unmeasured account to populate its quota.
49
+ // It fans out once the instant the template commits (right after the first
50
+ // post-restart request) AND periodically (config.warmupIntervalMs, default 5m;
51
+ // 0 = startup-only). Each probe is best-effort and side-effect-light: it never
52
+ // refreshes tokens or mutates account status, reserves a real cap slot so it
53
+ // can't push an account over maxConcurrent, and only learns from a 2xx (or an
54
+ // account-level quota 429). `config.activeWarmup: false` disables it all.
55
+ const activeWarmup = config.activeWarmup !== false;
56
+ const warmupIntervalMs = Number.isFinite(config.warmupIntervalMs)
57
+ ? Math.max(0, config.warmupIntervalMs)
58
+ : 5 * 60 * 1000;
59
+ const WARMUP_PROBE_TIMEOUT_MS = 15_000;
60
+ let probeTemplate = null; // committed { model, version, beta, system } — only after a 2xx
61
+ let warmupInFlight = false; // guard against overlapping fan-outs
62
+ let warmupClosed = false; // set on server close: stop scheduling, abort in-flight probes
63
+ const warmupAbort = new AbortController();
64
+
65
+ // Stage a candidate template from a genuine /v1/messages request WITHOUT
66
+ // committing — we only trust the shape once upstream has accepted it (see
67
+ // commitProbeTemplate). Path-exact so /v1/messages/count_tokens isn't taken for
68
+ // inference. Returns the candidate (or null). Called AFTER the response (the
69
+ // caller decides whether a commit/upgrade is even possible), so the body
70
+ // parse is only ever paid for the one or two requests that actually commit.
71
+ function stageProbeTemplate(req, body) {
72
+ if (!activeWarmup) return null;
73
+ if (req.method !== 'POST' || req.url.split('?')[0] !== '/v1/messages') return null;
74
+ let json;
75
+ try { json = JSON.parse(body.toString()); } catch { return null; }
76
+ if (!json || typeof json.model !== 'string') return null;
77
+ return {
78
+ model: json.model,
79
+ version: req.headers['anthropic-version'] || '2023-06-01',
80
+ beta: req.headers['anthropic-beta'] || null,
81
+ system: json.system ?? null,
82
+ };
83
+ }
84
+
85
+ // Commit a staged template once its request succeeded (2xx), then fan out so
86
+ // the rest of the fleet is measured within seconds of the first post-restart
87
+ // request. The MODEL matters beyond acceptance: model-scoped weekly windows
88
+ // (7d_oi — the "Fable" weekly limit) only appear on responses to requests for
89
+ // that model tier, so probes replaying e.g. a haiku-shaped template can never
90
+ // refresh the Fbl numbers. Therefore exactly one one-way UPGRADE is allowed:
91
+ // a shape whose own response carried a 7d_* window (elicitsModelWeekly)
92
+ // replaces a committed shape that didn't. No model names are hardcoded — the
93
+ // template converges to whatever tier actually reports the extra window.
94
+ function commitProbeTemplate(candidate, status, elicitsModelWeekly = false) {
95
+ if (!activeWarmup || warmupClosed) return;
96
+ if (!(status >= 200 && status < 300)) return; // only trust an accepted shape
97
+ // A template RESTORED from the last run's snapshot is provisional: it let
98
+ // probes work before any traffic, but upstream accepted it in a previous
99
+ // process — the model may have been retired since. The first freshly
100
+ // accepted shape therefore always replaces it (fresh evidence wins; the
101
+ // Fable-window upgrade then re-applies organically among fresh commits).
102
+ if (probeTemplate && !probeTemplate._restored
103
+ && (probeTemplate._elicitsModelWeekly || !elicitsModelWeekly)) return;
104
+ probeTemplate = { ...candidate, _elicitsModelWeekly: elicitsModelWeekly };
105
+ setImmediate(() => { warmupUnmeasured(); });
106
+ // Note: the already-measured accounts still missing their Fable window are
107
+ // healed by the periodic top-up pass (topUpModelWeekly) and by an on-demand
108
+ // R — NOT here. Kicking a top-up off this commit would race a concurrent R's
109
+ // refreshQuotaAll (both set `_warming`), skewing its M/N count for no real
110
+ // gain, since the periodic pass fills the same windows within one interval.
111
+ }
112
+
113
+ function buildProbeBody(t) {
114
+ const b = { model: t.model, max_tokens: 1, messages: [{ role: 'user', content: 'ping' }] };
115
+ if (t.system != null) b.system = t.system; // mirror the real request (OAuth requires the system prompt)
116
+ return JSON.stringify(b);
117
+ }
118
+
119
+ // A probe fetch is bounded by BOTH a timeout and server-close, so a scheduled or
120
+ // in-flight probe can't keep sending a credentialed request after teardown.
121
+ // Returns { signal, cleanup }: the caller MUST call cleanup() when the probe
122
+ // settles (success OR failure) so a fast probe doesn't leave its 15s timer and
123
+ // its warmupAbort listener dangling until the timeout fires.
124
+ function probeSignal() {
125
+ const ac = new AbortController();
126
+ if (warmupAbort.signal.aborted) { ac.abort(); return { signal: ac.signal, cleanup() {} }; }
127
+ const onClose = () => ac.abort();
128
+ warmupAbort.signal.addEventListener('abort', onClose, { once: true });
129
+ const t = setTimeout(() => ac.abort(), WARMUP_PROBE_TIMEOUT_MS);
130
+ t.unref?.();
131
+ let cleaned = false;
132
+ const cleanup = () => {
133
+ if (cleaned) return;
134
+ cleaned = true;
135
+ clearTimeout(t);
136
+ warmupAbort.signal.removeEventListener('abort', onClose);
137
+ };
138
+ return { signal: ac.signal, cleanup };
139
+ }
140
+
141
+ // Probe one account: send a minimal /v1/messages with its own auth and fold the
142
+ // rate-limit headers into its quota. Best-effort and side-effect-light:
143
+ // - Never refreshes tokens — a background refresh failure could mark the account
144
+ // 'error' and pull it from rotation before any real request proved auth. An
145
+ // OAuth account with an expiring token is left to the client path (which has
146
+ // the proper 401 → forced-refresh → error handling).
147
+ // - Does NOT reserve a client concurrency slot. The hard rule is "client traffic
148
+ // must not break". A probe that shared the per-account cap would inevitably
149
+ // subtract one client slot, which — with the overflow queue disabled — lets
150
+ // the proxy itself 429 a client when every slot is momentarily taken (no
151
+ // account to fail over to). So the cap is left entirely to clients; a probe is
152
+ // at most ONE extra concurrent request, and only ever on an idle, non-sticky,
153
+ // unmeasured account (warmupCandidates requires inflight===0; real traffic
154
+ // concentrates on the *measured* sticky account, not here). maxConcurrent is a
155
+ // conservative soft cap kept under Anthropic's real per-account limit (see
156
+ // CLAUDE.md, "the cap is not a hard binding"), so that transient +1 stays
157
+ // safe — and in the unlikely event it did cause a client rate-429, the
158
+ // existing 429 failover transparently recovers it, whereas a probe-induced
159
+ // capacity 429 could not.
160
+ // - Learns ONLY from a response upstream accepted (2xx) or an account-level
161
+ // quota 429 ('rejected') — a 4xx / non-exhaustion 429 / 5xx never mutates state.
162
+ async function warmupAccount(account, { force = false } = {}) {
163
+ if (!probeTemplate || warmupClosed || account._warming) return;
164
+ // Don't refresh from a background probe; skip an OAuth account that needs one.
165
+ if (account.type === 'oauth' && isTokenExpiringSoon(account.expiresAt)) return;
166
+ // Re-confirm it's still an available, unmeasured, idle candidate — unless
167
+ // this is a FORCED re-measure (TUI Reload), which deliberately probes
168
+ // already-measured (and even throttled/near-quota) accounts to pull fresh
169
+ // upstream numbers. The idle/enabled screening for that path lives in
170
+ // refreshQuotaAll.
171
+ if (!force && !accountManager.warmupCandidates().includes(account)) return;
172
+ account._warming = true;
173
+ const probe = probeSignal();
174
+ try {
175
+ const headers = { 'content-type': 'application/json', 'anthropic-version': probeTemplate.version };
176
+ if (probeTemplate.beta) headers['anthropic-beta'] = probeTemplate.beta;
177
+ if (account.type === 'oauth') headers['authorization'] = `Bearer ${account.credential}`;
178
+ else headers['x-api-key'] = account.credential;
179
+
180
+ const res = await fetch(`${upstream}/v1/messages`, {
181
+ method: 'POST', headers, body: buildProbeBody(probeTemplate), signal: probe.signal,
182
+ });
183
+ const rl = {};
184
+ for (const [k, v] of res.headers.entries()) {
185
+ if (k.startsWith('anthropic-ratelimit-')) rl[k] = v;
186
+ }
187
+ await res.body?.cancel();
188
+ // Learn ONLY from a response upstream accepted (2xx) or an *account-level*
189
+ // quota 429 — one whose `unified-status` is `rejected` (the account is
190
+ // genuinely over its limit). A non-exhaustion 429 (request-rate / global /
191
+ // transient) carries rate-limit headers too but is NOT account state;
192
+ // folding it in would wrongly mark the account measured/unavailable and
193
+ // break best-effort. updateQuota by OBJECT is reindex-safe; still skip a
194
+ // detached (removed-mid-fetch) account.
195
+ const accountExhausted429 = res.status === 429
196
+ && rl['anthropic-ratelimit-unified-status'] === 'rejected';
197
+ if ((res.ok || accountExhausted429) && Object.keys(rl).length
198
+ && accountManager.accounts[account.index] === account) {
199
+ accountManager.updateQuota(account, rl);
200
+ // Convergence accounting: a probe that leaves the account fully
201
+ // measured resets the fruitless-probe counter; one that leaves it
202
+ // half-measured (a header family missing) counts toward the cap.
203
+ if (accountManager._fullyMeasured(account)) {
204
+ account._partialProbes = 0;
205
+ } else {
206
+ account._partialProbes = (account._partialProbes || 0) + 1;
207
+ account._lastFruitlessProbeAt = Date.now(); // paces the slow retry backstop
208
+ }
209
+ // Model-weekly (Fable) top-up accounting: if this probe's response
210
+ // carried the window, clear the top-up budget; if it did NOT (this
211
+ // account/tier just doesn't report it) count toward the cap so the
212
+ // top-up pass below doesn't probe it forever.
213
+ if (Object.keys(account.quota.modelWeekly).length > 0) account._mwProbes = 0;
214
+ else account._mwProbes = (account._mwProbes || 0) + 1;
215
+ console.log(`[TeamClaude] Warm-up measured account "${account.name}"`);
216
+ return true; // quota actually folded — the forced-refresh path counts these
217
+ } else if (accountManager.accounts[account.index] === account
218
+ && (res.ok || (res.status >= 400 && res.status < 500 && res.status !== 429))) {
219
+ // The probe COMPLETED with a DETERMINISTIC fruitless outcome — a 2xx
220
+ // with no rate-limit headers (contract violation that will repeat), or
221
+ // a 4xx (bad shape / revoked auth — same next time). Count it toward
222
+ // the convergence cap so such an upstream/account is not probed every
223
+ // interval forever. Transient trouble — 5xx, a non-exhaustion 429, or
224
+ // a network failure (the catch below) — is deliberately NOT counted: a
225
+ // fully unmeasured account has no reset timestamp, so no sweep would
226
+ // ever clear its counter, and counting a passing blip would abandon it
227
+ // permanently even after upstream recovers.
228
+ account._partialProbes = (account._partialProbes || 0) + 1;
229
+ account._lastFruitlessProbeAt = Date.now(); // paces the slow retry backstop
230
+ }
231
+ } catch (err) {
232
+ // Best-effort: leave the account unmeasured (exactly as before warm-up).
233
+ console.error(`[TeamClaude] Warm-up probe failed for "${account.name}": ${err.message}`);
234
+ } finally {
235
+ probe.cleanup(); // clear the timeout + warmupAbort listener now (not 15s later)
236
+ account._warming = false;
237
+ }
238
+ return false; // skipped, fruitless, or failed — nothing was measured
239
+ }
240
+
241
+ // Forced fleet re-measure (TUI Reload / R): probe EVERY idle account —
242
+ // measured or not, ENABLED OR DISABLED — so the dashboard reflects fresh
243
+ // upstream numbers on demand. Usage spent from other devices/sessions never
244
+ // flows through this proxy, so the displayed values can silently drift until
245
+ // the next organic measurement. Disabled accounts are out of *rotation*, not
246
+ // out of *monitoring*: R is an explicit "show me everything" action, and a
247
+ // probe is read-only (it reserves no rotation slot and routes no client
248
+ // traffic), so refreshing a disabled account's dashboard row is safe and is
249
+ // what the user expects. Throttled/near-quota accounts are included on purpose
250
+ // (their exhausted-429 responses still carry authoritative quota headers);
251
+ // only accounts with a request in flight are skipped (that response refreshes
252
+ // them anyway). The convergence budgets are renewed first — an explicit user
253
+ // action is a fresh reason to probe. Returns { targets, measured }, or -1 when
254
+ // no probe template exists yet (nothing has flowed through the proxy, so there
255
+ // is no known-accepted request shape to replay).
256
+ async function refreshQuotaAll() {
257
+ if (!activeWarmup || warmupClosed || !probeTemplate) return -1;
258
+ const targets = accountManager.accounts.filter(a =>
259
+ a.status !== 'error' && a.inflight === 0 && !a._warming);
260
+ // Revive lapsed tokens FIRST. Background probes never refresh tokens (a
261
+ // background failure could mark an account 'error' before any real request
262
+ // proved auth), so an account that has sat idle past its token lifetime
263
+ // gets silently skipped by warmupAccount's expiring-token guard — the
264
+ // no.1 reason a fleet-wide refresh would quietly update almost nothing.
265
+ // An explicit user action (R) is the right moment to pay that refresh:
266
+ // failures are the same actionable truth the client path would surface.
267
+ await Promise.all(targets.map(a =>
268
+ accountManager.ensureTokenFresh(a).catch(() => { /* surfaces via status/error below */ })));
269
+ const alive = targets.filter(a => a.status !== 'error');
270
+ // Renew both probe budgets — R is an explicit "measure everything now".
271
+ for (const a of alive) { a._partialProbes = 0; a._mwProbes = 0; }
272
+ const outcomes = await Promise.all(alive.map(a => warmupAccount(a, { force: true })));
273
+ // Honest accounting: `targets` is what the user asked to refresh, `measured`
274
+ // is what actually got fresh data — the TUI reports M/N, never a blanket
275
+ // "refreshed N" while probes silently skipped or failed.
276
+ return { targets: targets.length, measured: outcomes.filter(Boolean).length };
277
+ }
278
+
279
+ // Model-weekly (Fable) top-up: an account fully measured for 5h/7d but missing
280
+ // its 7d_oi window (measured by lower-tier traffic/probe) is NOT an ordinary
281
+ // warm-up candidate, so nothing re-probes it — its `Fbl` bar stays blank
282
+ // indefinitely. Once the committed template is known to elicit the window,
283
+ // re-probe such accounts (bounded by _mwProbes) so the Fable numbers self-heal
284
+ // within a warm-up interval instead of waiting for the user to press R while
285
+ // that exact account is idle. Force-probes so the fully-measured guard doesn't
286
+ // exclude them; still skips in-flight/disabled/error accounts.
287
+ async function topUpModelWeekly() {
288
+ if (!activeWarmup || warmupClosed || !probeTemplate || !probeTemplate._elicitsModelWeekly) return;
289
+ const targets = accountManager.accounts.filter(a =>
290
+ a.enabled !== false && a.status !== 'error' && a.inflight === 0 && !a._warming
291
+ && accountManager.needsModelWeekly(a));
292
+ if (!targets.length) return;
293
+ await Promise.all(targets.map(a => warmupAccount(a, { force: true })));
294
+ }
295
+
296
+ // Probe every currently-unmeasured idle account in parallel. Guarded so two
297
+ // triggers (first-commit + the interval) can't run overlapping fan-outs.
298
+ async function warmupUnmeasured() {
299
+ if (!activeWarmup || warmupClosed || !probeTemplate || warmupInFlight) return;
300
+ warmupInFlight = true;
301
+ try {
302
+ await Promise.all(accountManager.warmupCandidates().map(a => warmupAccount(a)));
303
+ } finally {
304
+ warmupInFlight = false;
305
+ }
306
+ }
307
+
308
+ // Periodic warm-up: re-measures any account that is still unmeasured — including
309
+ // one whose quota window just reset (its utilization is cleared, so the
310
+ // dashboard reads "—" again) — without waiting for client traffic to reach it.
311
+ let warmupTimer = null;
312
+ if (activeWarmup && warmupIntervalMs > 0) {
313
+ warmupTimer = setInterval(() => {
314
+ // Sweep expired quota windows first: a rolled-over window keeps its
315
+ // account "measured" (with stale values) until some request-path sweep
316
+ // runs, and warm-up only probes UNMEASURED accounts — so without this an
317
+ // idle proxy would never re-measure after a reset. Sweep → unmeasured →
318
+ // the fan-out below re-probes → fresh data → ordering/display update.
319
+ accountManager.sweepExpired();
320
+ warmupUnmeasured();
321
+ topUpModelWeekly(); // heal fully-measured accounts still missing their Fable window
322
+ }, warmupIntervalMs);
323
+ warmupTimer.unref(); // never keep the process alive just for warm-up
324
+ }
325
+
326
+ const server = http.createServer(async (req, res) => {
327
+ try {
328
+ // Auth check — skip for localhost connections
329
+ const clientKey = req.headers['x-api-key'];
330
+ const remoteAddr = req.socket.remoteAddress;
331
+ const isLocal = remoteAddr === '127.0.0.1' || remoteAddr === '::1' || remoteAddr === '::ffff:127.0.0.1';
332
+ if (proxyApiKey && clientKey !== proxyApiKey && !isLocal) {
333
+ res.writeHead(401, { 'Content-Type': 'application/json' });
334
+ res.end(JSON.stringify({
335
+ type: 'error',
336
+ error: { type: 'authentication_error', message: 'Invalid proxy API key' },
337
+ }));
338
+ return;
339
+ }
340
+
341
+ // Status endpoint
342
+ if (req.method === 'GET' && req.url === '/teamclaude/status') {
343
+ res.writeHead(200, { 'Content-Type': 'application/json' });
344
+ res.end(JSON.stringify(accountManager.getStatus(), null, 2));
345
+ return;
346
+ }
347
+
348
+ // Everything below buffers a request body (the OAuth relay AND the proxied
349
+ // path) → global admission control to bound proxy memory: inFlightProxied
350
+ // may not exceed the fleet's useful capacity (sum of per-account caps +
351
+ // overflow queue depth), and we reject BEFORE buffering. Without this, body
352
+ // buffering happens before any queue admission, so N concurrent uploads each
353
+ // buffer up to maxBodyBytes regardless of queue depth — memory would grow
354
+ // with connection count (localhost auth is skipped, so any local process
355
+ // could flood, including via /v1/oauth/token). Bound: totalCapacity × maxBodyBytes.
356
+ if (inFlightProxied >= accountManager.totalCapacity()) {
357
+ req.resume(); // drain & discard the body so the socket isn't leaked
358
+ res.writeHead(429, { 'Content-Type': 'application/json', 'retry-after': '5' });
359
+ res.end(JSON.stringify({
360
+ type: 'error',
361
+ error: { type: 'rate_limit_error', message: 'Proxy at capacity; retry shortly.' },
362
+ }));
363
+ return;
364
+ }
365
+ inFlightProxied++;
366
+ try {
367
+ // Let client token refresh requests pass through to upstream untouched.
368
+ // The proxy manages its own tokens via ensureTokenFresh(); intercepting
369
+ // or rewriting client refreshes would cause token rotation conflicts.
370
+ if (req.method === 'POST' && req.url === '/v1/oauth/token') {
371
+ await relayRaw(req, res, upstream, maxBodyBytes);
372
+ return; // outer finally decrements inFlightProxied
373
+ }
374
+
375
+ // Track request
376
+ const reqId = ++requestCounter;
377
+ hooks.onRequestStart?.(reqId, { method: req.method, path: req.url });
378
+
379
+ // tried429/tried5xx/authRetried hold account OBJECTS (not indexes), and
380
+ // `held` is the acquired account OBJECT — both stable across a concurrent
381
+ // removeAccount() re-index, so a release/exclude can't target the wrong account.
382
+ const ctx = { account: null, status: null, authRetried: new Set(), tried429: new Set(), tried5xx: new Set(), overloadRetries: 0, held: null, queueTimeoutMs, abortSignal: null, affinityKey: sessionAffinity ? req.socket : null, sawModelWeekly: false };
383
+ try {
384
+ // Buffer request body (needed for retry on 429), bounded by maxBodyBytes.
385
+ const bodyChunks = [];
386
+ let bodyLen = 0;
387
+ let bodyTooLarge = false;
388
+ for await (const chunk of req) {
389
+ bodyLen += chunk.length;
390
+ if (bodyLen > maxBodyBytes) { bodyTooLarge = true; break; }
391
+ bodyChunks.push(chunk);
392
+ }
393
+ if (bodyTooLarge) {
394
+ req.destroy();
395
+ if (!res.headersSent) {
396
+ res.writeHead(413, { 'Content-Type': 'application/json' });
397
+ res.end(JSON.stringify({
398
+ type: 'error',
399
+ error: { type: 'invalid_request_error', message: `Request body exceeds ${maxBodyBytes} bytes.` },
400
+ }));
401
+ }
402
+ return;
403
+ }
404
+ const body = Buffer.concat(bodyChunks);
405
+
406
+ // Tie an abort signal to client disconnect so a request that's only
407
+ // WAITING in the overflow queue is cancelled if the client goes away —
408
+ // otherwise it would acquire a slot later and be dispatched upstream,
409
+ // burning quota for a response nobody is listening for.
410
+ const ac = new AbortController();
411
+ const onClose = () => ac.abort();
412
+ res.on('close', onClose);
413
+ ctx.abortSignal = ac.signal;
414
+ try {
415
+ await forwardRequest(req, res, body, accountManager, upstream, 0, hooks, reqId, ctx, logDir);
416
+ // Stage + commit the warm-up template AFTER the response: only an
417
+ // upstream-accepted shape (2xx via ctx.status) is trusted, and the
418
+ // response also tells us whether this request's model tier reports
419
+ // the model-scoped weekly windows (ctx.sawModelWeekly → the Fable
420
+ // limit) — the one property worth a one-way template upgrade.
421
+ if (!probeTemplate || probeTemplate._restored
422
+ || (!probeTemplate._elicitsModelWeekly && ctx.sawModelWeekly)) {
423
+ const candidate = stageProbeTemplate(req, body);
424
+ if (candidate) commitProbeTemplate(candidate, ctx.status, ctx.sawModelWeekly === true);
425
+ }
426
+ } finally {
427
+ res.removeListener('close', onClose);
428
+ }
429
+ } catch (err) {
430
+ ctx.status = ctx.status || 502;
431
+ console.error('[TeamClaude] Unhandled error:', err);
432
+ if (!res.headersSent) {
433
+ res.writeHead(502, { 'Content-Type': 'application/json' });
434
+ res.end(JSON.stringify({
435
+ type: 'error',
436
+ error: { type: 'proxy_error', message: 'Internal proxy error' },
437
+ }));
438
+ }
439
+ } finally {
440
+ // Release the concurrency slot held by this request (if any). A failover
441
+ // releases the previous account before re-acquiring, so at this point only
442
+ // the last-held slot remains; releaseAccount guards against double-release.
443
+ if (ctx.held != null) {
444
+ accountManager.releaseAccount(ctx.held);
445
+ ctx.held = null;
446
+ }
447
+ hooks.onRequestEnd?.(reqId, {
448
+ method: req.method, path: req.url,
449
+ account: ctx.account, status: ctx.status,
450
+ });
451
+ }
452
+ } finally {
453
+ inFlightProxied--;
454
+ }
455
+ } catch (err) {
456
+ console.error('[TeamClaude] Unhandled error:', err);
457
+ }
458
+ });
459
+
460
+ // Shut warm-up down the instant a close is REQUESTED, not when the `'close'`
461
+ // event finally fires — that waits for open keep-alive connections to drain,
462
+ // and during that window the interval could still dispatch a credentialed
463
+ // probe. Wrap server.close() to run the (idempotent) shutdown synchronously;
464
+ // keep the `'close'` handler as a fallback for closes that bypass the method.
465
+ // It stops scheduling new fan-outs (warmupClosed), aborts any in-flight /
466
+ // scheduled probe (warmupAbort), and clears the periodic timer.
467
+ const shutdownWarmup = () => {
468
+ if (warmupClosed) return;
469
+ warmupClosed = true;
470
+ warmupAbort.abort();
471
+ if (warmupTimer) clearInterval(warmupTimer);
472
+ };
473
+ const closeServer = server.close.bind(server);
474
+ server.close = (cb) => { shutdownWarmup(); return closeServer(cb); };
475
+ server.on('close', shutdownWarmup);
476
+
477
+ // Exposed for the TUI Reload path (and tests): forced fleet-wide quota
478
+ // re-measure. Kept off the HTTP surface — it spends real upstream requests,
479
+ // so only a deliberate local action should trigger it.
480
+ server.refreshQuotaAll = refreshQuotaAll;
481
+
482
+ // Probe-template persistence (wired into the quota snapshot by index.js).
483
+ // The template is the only known-accepted request shape — without persisting
484
+ // it, a freshly restarted idle proxy can't probe at all: quota restores from
485
+ // the snapshot (accounts read "measured"), no traffic flows, so forced
486
+ // re-measure (TUI R) returns -1 until the first genuine request. Restoring
487
+ // the last run's template closes that gap; it is marked `_restored` so the
488
+ // first freshly accepted shape replaces it (see commitProbeTemplate).
489
+ server.exportProbeTemplate = () => (probeTemplate ? { ...probeTemplate } : null);
490
+ server.importProbeTemplate = (t) => {
491
+ // Never clobber live evidence: a committed-in-this-process template wins.
492
+ if (!activeWarmup || warmupClosed || probeTemplate) return false;
493
+ if (!t || typeof t !== 'object' || typeof t.model !== 'string' || !t.model) return false;
494
+ probeTemplate = {
495
+ model: t.model,
496
+ version: typeof t.version === 'string' && t.version ? t.version : '2023-06-01',
497
+ beta: typeof t.beta === 'string' && t.beta ? t.beta : null,
498
+ system: t.system ?? null,
499
+ _elicitsModelWeekly: t._elicitsModelWeekly === true,
500
+ _restored: true,
501
+ };
502
+ return true;
503
+ };
504
+
505
+ return server;
506
+ }
507
+
508
+ /**
509
+ * Relay a request to upstream with no header rewriting — pure passthrough.
510
+ * Buffers the body bounded by maxBodyBytes (else 413) so the untouched
511
+ * `/v1/oauth/token` path can't be used to exhaust proxy memory.
512
+ */
513
+ async function relayRaw(req, res, upstream, maxBodyBytes = Infinity) {
514
+ const bodyChunks = [];
515
+ let bodyLen = 0;
516
+ let tooLarge = false;
517
+ for await (const chunk of req) {
518
+ bodyLen += chunk.length;
519
+ if (bodyLen > maxBodyBytes) { tooLarge = true; break; }
520
+ bodyChunks.push(chunk);
521
+ }
522
+ if (tooLarge) {
523
+ req.destroy();
524
+ if (!res.headersSent) {
525
+ res.writeHead(413, { 'Content-Type': 'application/json' });
526
+ res.end(JSON.stringify({ type: 'error', error: { type: 'invalid_request_error', message: `Request body exceeds ${maxBodyBytes} bytes.` } }));
527
+ }
528
+ return;
529
+ }
530
+ const body = Buffer.concat(bodyChunks);
531
+
532
+ // Abort the relay if the client disconnects, so a hung upstream OAuth endpoint
533
+ // can't pin this connection (and its admission-control inFlightProxied slot)
534
+ // forever. Tied to res 'close'; the listener is removed once we're done.
535
+ const ac = new AbortController();
536
+ const onClose = () => ac.abort();
537
+ res.on('close', onClose);
538
+ try {
539
+ const upstreamRes = await fetch(`${upstream}${req.url}`, {
540
+ method: req.method,
541
+ headers: {
542
+ 'content-type': req.headers['content-type'] || 'application/json',
543
+ 'accept': req.headers['accept'] || 'application/json',
544
+ 'user-agent': req.headers['user-agent'] || 'node',
545
+ },
546
+ body: body.length > 0 ? body : undefined,
547
+ signal: ac.signal,
548
+ });
549
+
550
+ const responseBody = await upstreamRes.text();
551
+ const responseHeaders = {};
552
+ for (const [key, value] of upstreamRes.headers.entries()) {
553
+ if (key === 'transfer-encoding' || key === 'connection') continue;
554
+ responseHeaders[key] = value;
555
+ }
556
+ res.writeHead(upstreamRes.status, responseHeaders);
557
+ res.end(responseBody);
558
+ } catch (err) {
559
+ // Client disconnected → we aborted the relay; nothing to respond to.
560
+ if (ac.signal.aborted || err?.name === 'AbortError' || err?.code === 'ABORT_ERR' || res.destroyed) {
561
+ if (!res.writableEnded) res.destroy();
562
+ return;
563
+ }
564
+ console.error('[TeamClaude] Raw relay error:', err.message);
565
+ if (!res.headersSent) {
566
+ res.writeHead(502, { 'Content-Type': 'application/json' });
567
+ res.end(JSON.stringify({ type: 'error', error: { type: 'proxy_error', message: 'Upstream unreachable' } }));
568
+ }
569
+ } finally {
570
+ res.removeListener('close', onClose);
571
+ }
572
+ }
573
+
574
+
575
+ function logTimestamp() {
576
+ const d = new Date();
577
+ const pad = (n, w = 2) => String(n).padStart(w, '0');
578
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
579
+ }
580
+
581
+ async function writeRequestLog(logDir, reqId, sections) {
582
+ if (!logDir) return;
583
+ const ts = logTimestamp();
584
+ const filename = `${ts}_${String(reqId).padStart(5, '0')}.log`;
585
+ try {
586
+ await writeFile(join(logDir, filename), sections.join('\n\n'), 'utf-8');
587
+ } catch (err) {
588
+ console.error(`[TeamClaude] Failed to write log: ${err.message}`);
589
+ }
590
+ }
591
+
592
+ function formatHeaders(headers) {
593
+ if (headers.entries) {
594
+ return [...headers.entries()].map(([k, v]) => ` ${k}: ${v}`).join('\n');
595
+ }
596
+ return Object.entries(headers).map(([k, v]) => ` ${k}: ${v}`).join('\n');
597
+ }
598
+
599
+ // Upstream statuses that are transient and safe to retry instead of surfacing to
600
+ // the client. 529 = "Overloaded" (Anthropic at capacity); 500/502/503/504 =
601
+ // gateway / availability blips. Passing these straight through fails the client's
602
+ // turn — e.g. Claude Code prints "API Error: 529 Overloaded" and stops — so
603
+ // forwardRequest fails them over to another account and, when the whole fleet is
604
+ // overloaded, retries with a bounded exponential backoff before giving up.
605
+ const RETRYABLE_STATUS = new Set([500, 502, 503, 504, 529]);
606
+ // Sleep that also resolves immediately if `signal` aborts — so a client that
607
+ // disconnects during an overload backoff doesn't keep its account slot reserved
608
+ // for the whole (up to multi-second) wait. Cleans up its timer/listener either way.
609
+ function sleepOrAbort(ms, signal) {
610
+ return new Promise((resolve) => {
611
+ if (signal?.aborted) return resolve();
612
+ const cleanup = () => { clearTimeout(t); signal?.removeEventListener('abort', onAbort); };
613
+ const onAbort = () => { cleanup(); resolve(); };
614
+ const t = setTimeout(() => { cleanup(); resolve(); }, ms);
615
+ signal?.addEventListener('abort', onAbort, { once: true });
616
+ });
617
+ }
618
+
619
+ // Await `promise`, but stop waiting the instant `signal` aborts (client gone).
620
+ // The underlying op (e.g. a coalesced token refresh shared by other requests)
621
+ // is NOT cancelled — we only stop *this* request from blocking on it, so its
622
+ // account slot can be released promptly. Rejections still propagate.
623
+ function raceAbort(promise, signal) {
624
+ if (!signal) return promise;
625
+ if (signal.aborted) return Promise.resolve();
626
+ return new Promise((resolve, reject) => {
627
+ const cleanup = () => signal.removeEventListener('abort', onAbort);
628
+ const onAbort = () => { cleanup(); resolve(); };
629
+ signal.addEventListener('abort', onAbort, { once: true });
630
+ promise.then(
631
+ (v) => { cleanup(); resolve(v); },
632
+ (e) => { cleanup(); reject(e); },
633
+ );
634
+ });
635
+ }
636
+
637
+ // parseInt with a default that HONORS an explicit 0 — unlike `parseInt(...) || def`,
638
+ // which discards a valid 0 (0 is falsy). e.g. TEAMCLAUDE_OVERLOAD_RETRIES=0 must
639
+ // actually disable proxy-held backoff retries during an incident, not fall back to
640
+ // the default. Mirrors the Number.isFinite guard used for reevalIntervalMs in index.js.
641
+ const envInt = (name, def) => {
642
+ const v = parseInt(process.env[name], 10);
643
+ return Number.isFinite(v) ? v : def;
644
+ };
645
+
646
+ async function forwardRequest(req, res, body, accountManager, upstream, retryCount, hooks, reqId, ctx, logDir) {
647
+ const maxRetries = accountManager.accounts.length;
648
+
649
+ // Select account. On a failover retry (a prior account 429'd / 5xx'd for this
650
+ // request) ctx.tried* is non-empty → pick a different account, skipping the
651
+ // ones already tried.
652
+ const excludeForSelect = (ctx.tried429.size || ctx.tried5xx.size)
653
+ ? new Set([...ctx.tried429, ...ctx.tried5xx])
654
+ : null;
655
+
656
+ // Reserve a per-account concurrency slot. On a 401 same-account refresh-retry
657
+ // the slot is already held (ctx.held set, exclude unchanged) → reuse it.
658
+ // Otherwise acquire a fresh slot, waiting briefly if every available account is
659
+ // at its cap (overflow queue) before giving up with a 429. Releasing this slot
660
+ // before any account-switching retry is the caller's job, via releaseHeld().
661
+ let account;
662
+ if (ctx.held != null) {
663
+ account = ctx.held;
664
+ } else {
665
+ account = await accountManager.acquireAccount(excludeForSelect, ctx.queueTimeoutMs, ctx.abortSignal, ctx.affinityKey);
666
+ if (account) ctx.held = account;
667
+ }
668
+ const releaseHeld = () => {
669
+ if (ctx.held != null) {
670
+ accountManager.releaseAccount(ctx.held);
671
+ ctx.held = null;
672
+ }
673
+ };
674
+
675
+ // The client disconnected while this request was queued (acquireAccount was
676
+ // cancelled by the abort signal) — nothing to respond to.
677
+ if (!account && (ctx.abortSignal?.aborted || res.destroyed)) return;
678
+
679
+ if (!account) {
680
+ ctx.account = '(none available)';
681
+ // If every account is in auth-error state, this is an authentication
682
+ // problem (revoked/expired tokens needing re-login), not a rate limit —
683
+ // return 401 so the client surfaces it instead of pointlessly backing off.
684
+ const accts = accountManager.accounts;
685
+ if (accts.length > 0 && accts.every(a => a.status === 'error')) {
686
+ ctx.status = 401;
687
+ res.writeHead(401, { 'Content-Type': 'application/json' });
688
+ res.end(JSON.stringify({
689
+ type: 'error',
690
+ error: {
691
+ type: 'authentication_error',
692
+ message: `All ${accts.length} accounts failed authentication. Re-login required.`,
693
+ },
694
+ }));
695
+ return;
696
+ }
697
+ ctx.status = 429;
698
+ const status = accountManager.getStatus();
699
+ const retryAfter = computeRetryAfter(status.accounts, accountManager.switchThreshold);
700
+ res.writeHead(429, {
701
+ 'Content-Type': 'application/json',
702
+ 'retry-after': String(retryAfter),
703
+ });
704
+ res.end(JSON.stringify({
705
+ type: 'error',
706
+ error: {
707
+ type: 'rate_limit_error',
708
+ message: `All ${accts.length} accounts exhausted. Retry in ${retryAfter}s.`,
709
+ },
710
+ }));
711
+ return;
712
+ }
713
+
714
+ // Track which account handles this request
715
+ ctx.account = account.name;
716
+ hooks.onRequestRouted?.(reqId, { account: account.name });
717
+
718
+ // Refresh OAuth token if needed. Stop waiting if the client disconnects (the
719
+ // refresh is coalesced/shared, so we don't cancel it — we just don't pin this
720
+ // request's account slot on a possibly-hung token endpoint).
721
+ await raceAbort(accountManager.ensureTokenFresh(account), ctx.abortSignal);
722
+ if (res.destroyed || ctx.abortSignal?.aborted) return; // client gone — outer finally frees the slot
723
+
724
+ // The account may have been REMOVED (TUI/CLI delete) during the awaited refresh
725
+ // above (or the 401 forced-refresh that recurses back here). A detached account
726
+ // must not be used to dispatch upstream — its slot release is a no-op and we'd
727
+ // be sending traffic on a credential the operator just retired. Re-select a live
728
+ // account instead. (accounts[i] === account holds only while it's still live.)
729
+ if (accountManager.accounts[account.index] !== account) {
730
+ releaseHeld();
731
+ if (res.destroyed) return; // client gone — outer finally cleans up
732
+ if (retryCount < maxRetries) {
733
+ return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
734
+ }
735
+ // Out of retry budget after repeated removals — respond rather than hang.
736
+ ctx.status = 503;
737
+ if (!res.headersSent) {
738
+ res.writeHead(503, { 'Content-Type': 'application/json', 'retry-after': '5' });
739
+ res.end(JSON.stringify({
740
+ type: 'error',
741
+ error: { type: 'overloaded_error', message: 'Account removed mid-request; retry shortly.' },
742
+ }));
743
+ }
744
+ return;
745
+ }
746
+
747
+ if (account.status === 'error' && retryCount < maxRetries) {
748
+ releaseHeld(); // failing over to a different account
749
+ return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
750
+ }
751
+
752
+ // Build upstream request headers
753
+ const isOAuth = account.type === 'oauth';
754
+ const headers = {};
755
+ for (const [key, value] of Object.entries(req.headers)) {
756
+ const lk = key.toLowerCase();
757
+ if (HOP_BY_HOP_HEADERS.has(lk)) continue;
758
+ if (lk === 'x-api-key') continue;
759
+ // Strip accept-encoding: Node fetch auto-decompresses, which would
760
+ // mismatch the Content-Encoding header we forward to the client
761
+ if (lk === 'accept-encoding') continue;
762
+ headers[key] = value;
763
+ }
764
+
765
+ if (isOAuth) {
766
+ headers['authorization'] = `Bearer ${account.credential}`;
767
+ } else {
768
+ headers['x-api-key'] = account.credential;
769
+ }
770
+
771
+ const upstreamUrl = `${upstream}${req.url}`;
772
+ const method = req.method;
773
+
774
+ // Build log sections
775
+ const logSections = [];
776
+ if (logDir) {
777
+ const safeHeaders = { ...headers };
778
+ // Mask credentials in logs
779
+ if (safeHeaders['x-api-key']) {
780
+ safeHeaders['x-api-key'] = safeHeaders['x-api-key'].slice(0, 15) + '...';
781
+ }
782
+ if (safeHeaders['authorization']) {
783
+ safeHeaders['authorization'] = safeHeaders['authorization'].slice(0, 20) + '...';
784
+ }
785
+ logSections.push(
786
+ `=== REQUEST (account: ${account.name}, retry: ${retryCount}) ===\n${method} ${upstreamUrl}\n${formatHeaders(safeHeaders)}`,
787
+ );
788
+ if (body.length > 0) {
789
+ try {
790
+ logSections.push(`=== REQUEST BODY ===\n${JSON.stringify(JSON.parse(body.toString()), null, 2)}`);
791
+ } catch {
792
+ logSections.push(`=== REQUEST BODY (${body.length} bytes) ===\n${body.toString().slice(0, 4096)}`);
793
+ }
794
+ }
795
+ }
796
+
797
+ try {
798
+ const upstreamRes = await fetch(upstreamUrl, {
799
+ method,
800
+ headers,
801
+ body: ['GET', 'HEAD'].includes(method) ? undefined : body,
802
+ redirect: 'manual',
803
+ // Abort the upstream call when the client disconnects (ctx.abortSignal is
804
+ // tied to res 'close'). Without this, a client that drops mid-SSE while the
805
+ // upstream stalls would leave streamResponse blocked in reader.read(), so
806
+ // the per-account slot and inFlightProxied never release — repeated stalls
807
+ // would leak the proxy to capacity. Aborting rejects the read and unwinds
808
+ // the finally that frees the slot.
809
+ signal: ctx.abortSignal,
810
+ });
811
+
812
+ // Extract rate limit headers
813
+ const rateLimitHeaders = {};
814
+ for (const [key, value] of upstreamRes.headers.entries()) {
815
+ if (key.startsWith('anthropic-ratelimit-')) {
816
+ rateLimitHeaders[key] = value;
817
+ }
818
+ }
819
+ // Did this request's model tier report a model-scoped weekly window
820
+ // (anthropic-ratelimit-unified-7d_<label>-*)? Only such requests can teach
821
+ // probes to refresh the Fable weekly numbers — used by the template-upgrade
822
+ // decision in the request handler. Request-scoped: any attempt's headers
823
+ // prove the property, since the request shape is identical across failovers.
824
+ if (Object.keys(rateLimitHeaders).some(k => k.startsWith('anthropic-ratelimit-unified-7d_'))) {
825
+ ctx.sawModelWeekly = true;
826
+ }
827
+ accountManager.updateQuota(account, rateLimitHeaders);
828
+
829
+ // 401 = auth failure (stale or revoked token). For OAuth, attempt one
830
+ // forced token refresh and retry the same account (the token may be stale
831
+ // but still refreshable). If that doesn't fix it — refresh fails, the token
832
+ // is revoked, or it's an API-key account — mark the account 'error' so it's
833
+ // excluded from BOTH selection and warm-up, then switch to another account.
834
+ // Without this, warm-up would keep routing client traffic to a revoked
835
+ // account (it stays unmeasured/active), yielding repeated 401s.
836
+ if (upstreamRes.status === 401) {
837
+ await upstreamRes.body?.cancel();
838
+
839
+ if (account.type === 'oauth' && account.refreshToken
840
+ && !ctx.authRetried.has(account)
841
+ && retryCount < maxRetries && !res.destroyed) {
842
+ ctx.authRetried.add(account);
843
+ console.log(`[TeamClaude] 401 on "${account.name}" — forcing token refresh and retrying`);
844
+ await raceAbort(accountManager.ensureTokenFresh(account, true), ctx.abortSignal);
845
+ if (res.destroyed || ctx.abortSignal?.aborted) return; // client gone during refresh
846
+ // ensureTokenFresh only marks 'error' for an expired token; a successful
847
+ // (or non-fatal) refresh leaves status intact → retry the same account.
848
+ if (account.status !== 'error') {
849
+ if (logDir) {
850
+ logSections.push(`=== RESPONSE 401 — forced token refresh, retrying ===`);
851
+ writeRequestLog(logDir, reqId, logSections);
852
+ }
853
+ return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
854
+ }
855
+ }
856
+
857
+ // Refresh didn't help (failed / already retried / revoked-but-unexpired)
858
+ // or it's an API-key account — fail this account out and switch.
859
+ if (account.status !== 'error') {
860
+ account.status = 'error';
861
+ console.log(`[TeamClaude] 401 on "${account.name}" — auth failed, marking account error`);
862
+ }
863
+ if (logDir) {
864
+ logSections.push(`=== RESPONSE 401 — auth failure, account marked error ===\n${formatHeaders(upstreamRes.headers)}`);
865
+ writeRequestLog(logDir, reqId, logSections);
866
+ }
867
+ if (res.destroyed) return;
868
+ if (retryCount < maxRetries) {
869
+ releaseHeld(); // this account is now 'error'; fail over to another
870
+ return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
871
+ }
872
+ // Every account failed auth — surface the 401 to the client.
873
+ ctx.status = 401;
874
+ if (!res.headersSent) {
875
+ res.writeHead(401, { 'Content-Type': 'application/json' });
876
+ res.end(JSON.stringify({
877
+ type: 'error',
878
+ error: { type: 'authentication_error', message: 'All accounts failed authentication.' },
879
+ }));
880
+ }
881
+ return;
882
+ }
883
+
884
+ // Handle 429s. A 429 can mean two very different things:
885
+ // (a) this account is out of quota (account-level exhaustion), or
886
+ // (b) a transient / global / IP / request-level limit that would 429 on
887
+ // any account.
888
+ // Only (a) should throttle the account and switch; replaying (b) across the
889
+ // fleet would mark every account throttled and break unrelated requests.
890
+ // isExhausted() (checked after updateQuota folds in the 429 headers)
891
+ // distinguishes them.
892
+ if (upstreamRes.status === 429) {
893
+ let retryAfter = parseInt(upstreamRes.headers.get('retry-after'), 10);
894
+ if (Number.isNaN(retryAfter)) retryAfter = 60;
895
+ retryAfter = Math.min(Math.max(retryAfter, 1), 300); // clamp [1s, 5m]
896
+ // Discard the 429 response body
897
+ await upstreamRes.body?.cancel();
898
+
899
+ if (accountManager.isExhausted(account)) {
900
+ // (a) Account-level exhaustion: throttle this account (so
901
+ // getActiveAccount skips it until it resets) and immediately
902
+ // re-dispatch to another available account — never sleep holding the
903
+ // client. When every account is throttled, getActiveAccount returns
904
+ // null and the client gets a 429 to back off on its own.
905
+ console.log(`[TeamClaude] 429 (quota exhausted) on "${account.name}" — throttling ${retryAfter}s, switching accounts`);
906
+ accountManager.markRateLimited(account, retryAfter);
907
+ if (logDir) {
908
+ logSections.push(`=== RESPONSE 429 — account quota exhausted, throttled ${retryAfter}s, switching ===\n${formatHeaders(upstreamRes.headers)}`);
909
+ writeRequestLog(logDir, reqId, logSections);
910
+ }
911
+ if (res.destroyed) return;
912
+
913
+ // Safety backstop: each retry throttles a distinct account, so
914
+ // getActiveAccount returns null before this can fire. Cap anyway.
915
+ if (retryCount >= maxRetries) {
916
+ ctx.status = 429;
917
+ const ra = computeRetryAfter(accountManager.getStatus().accounts, accountManager.switchThreshold);
918
+ if (!res.headersSent) {
919
+ res.writeHead(429, { 'Content-Type': 'application/json', 'retry-after': String(ra) });
920
+ res.end(JSON.stringify({
921
+ type: 'error',
922
+ error: { type: 'rate_limit_error', message: `All accounts throttled. Retry in ${ra}s.` },
923
+ }));
924
+ }
925
+ return;
926
+ }
927
+ releaseHeld(); // throttled this account; switch to another
928
+ return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
929
+ }
930
+
931
+ // (b) Non-exhaustion 429: usually an account-level request-rate /
932
+ // concurrency limit (the account still has token quota, but is being hit
933
+ // too fast) — or a transient / global limit. Try ANOTHER account for THIS
934
+ // request (per-request exclusion via ctx.tried429) so concurrent overflow
935
+ // spreads to an idle account instead of failing. Crucially we do NOT
936
+ // throttle the account: throttling on a request-global 429 would poison
937
+ // the fleet for unrelated requests. Only when every available account has
938
+ // been tried for this request (→ effectively global) is the 429 passed
939
+ // through to the client; no account state is mutated either way.
940
+ ctx.tried429.add(account);
941
+ if (!res.destroyed && retryCount < maxRetries
942
+ && (accountManager.anyUsable(ctx.tried429) || accountManager.anyCapped(ctx.tried429))) {
943
+ console.log(`[TeamClaude] 429 (rate/transient) on "${account.name}" — switching account for this request`);
944
+ if (logDir) {
945
+ logSections.push(`=== RESPONSE 429 — rate/transient, switching account (not throttled) ===\n${formatHeaders(upstreamRes.headers)}`);
946
+ writeRequestLog(logDir, reqId, logSections);
947
+ }
948
+ releaseHeld(); // free this account's slot before trying another
949
+ return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
950
+ }
951
+
952
+ console.log(`[TeamClaude] 429 (global) on "${account.name}" — every account tried, passing through`);
953
+ ctx.status = 429;
954
+ if (logDir) {
955
+ logSections.push(`=== RESPONSE 429 — global, passed through after trying all accounts ===\n${formatHeaders(upstreamRes.headers)}`);
956
+ writeRequestLog(logDir, reqId, logSections);
957
+ }
958
+ if (res.destroyed) return;
959
+ if (!res.headersSent) {
960
+ res.writeHead(429, { 'Content-Type': 'application/json', 'retry-after': String(retryAfter) });
961
+ res.end(JSON.stringify({
962
+ type: 'error',
963
+ error: { type: 'rate_limit_error', message: `Upstream rate limited (retry in ${retryAfter}s).` },
964
+ }));
965
+ }
966
+ return;
967
+ }
968
+
969
+ // Handle retryable upstream 5xx (notably 529 "Overloaded" — Anthropic is over
970
+ // capacity). Unlike a 429, a 529 is NOT account-specific: every account hits
971
+ // the same overloaded upstream. Surfacing it fails the client's turn, so:
972
+ // (1) fail this request over to another account (cheap; for 500/502/503/504 a
973
+ // different account/region is occasionally healthier), then
974
+ // (2) once every account has 5xx'd for this request, wait a bounded
975
+ // exponential backoff and retry the whole fleet — the client transparently
976
+ // gets the eventual success instead of an error.
977
+ // Only after the backoff budget is spent is the 5xx surfaced (so the client is
978
+ // never left hanging indefinitely). No account state is mutated — a 529 is
979
+ // upstream overload, not a bad account.
980
+ if (RETRYABLE_STATUS.has(upstreamRes.status)) {
981
+ const code = upstreamRes.status;
982
+ await upstreamRes.body?.cancel();
983
+
984
+ const maxOverload = Math.max(0, envInt('TEAMCLAUDE_OVERLOAD_RETRIES', 6));
985
+ const backoffBase = Math.max(50, envInt('TEAMCLAUDE_OVERLOAD_BACKOFF_BASE_MS', 1000));
986
+ const backoffCap = Math.max(backoffBase, envInt('TEAMCLAUDE_OVERLOAD_BACKOFF_CAP_MS', 10000));
987
+
988
+ // (1) Per-request failover to an account not yet 5xx'd (or 429'd) this request.
989
+ ctx.tried5xx.add(account);
990
+ const exclude5xx = new Set([...ctx.tried429, ...ctx.tried5xx]);
991
+ if (!res.destroyed && retryCount < maxRetries
992
+ && (accountManager.anyUsable(exclude5xx) || accountManager.anyCapped(exclude5xx))) {
993
+ console.log(`[TeamClaude] ${code} on "${account.name}" — switching account for this request`);
994
+ if (logDir) {
995
+ logSections.push(`=== RESPONSE ${code} — transient upstream 5xx, switching account ===\n${formatHeaders(upstreamRes.headers)}`);
996
+ writeRequestLog(logDir, reqId, logSections);
997
+ }
998
+ releaseHeld(); // free this account's slot before trying another
999
+ return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
1000
+ }
1001
+
1002
+ // (2) Every account 5xx'd for this request → upstream overload. Back off and
1003
+ // retry the whole fleet so the client transparently rides out the blip.
1004
+ if (!res.destroyed && ctx.overloadRetries < maxOverload) {
1005
+ const waitMs = Math.min(backoffBase * 2 ** ctx.overloadRetries, backoffCap);
1006
+ ctx.overloadRetries += 1;
1007
+ console.log(`[TeamClaude] ${code} on every account — upstream overloaded, backing off ${waitMs}ms (retry ${ctx.overloadRetries}/${maxOverload})`);
1008
+ if (logDir) {
1009
+ logSections.push(`=== RESPONSE ${code} — all accounts overloaded, backoff ${waitMs}ms (retry ${ctx.overloadRetries}/${maxOverload}) ===`);
1010
+ writeRequestLog(logDir, reqId, logSections);
1011
+ }
1012
+ await sleepOrAbort(waitMs, ctx.abortSignal);
1013
+ // Client gone during the backoff → bail; the outer finally releases the
1014
+ // slot promptly instead of holding it for the rest of the wait.
1015
+ if (res.destroyed || ctx.abortSignal?.aborted) return;
1016
+ ctx.tried5xx.clear(); // fresh round: let every account be tried again
1017
+ releaseHeld(); // re-acquire from the full set on the next round
1018
+ return forwardRequest(req, res, body, accountManager, upstream, 0, hooks, reqId, ctx, logDir);
1019
+ }
1020
+
1021
+ // (3) Backoff budget spent — surface the 5xx rather than hold the client forever.
1022
+ console.log(`[TeamClaude] ${code} on "${account.name}" — overload persisted after ${ctx.overloadRetries} backoffs, passing through`);
1023
+ ctx.status = code;
1024
+ if (logDir) {
1025
+ logSections.push(`=== RESPONSE ${code} — overload persisted after ${ctx.overloadRetries} backoffs, passed through ===\n${formatHeaders(upstreamRes.headers)}`);
1026
+ writeRequestLog(logDir, reqId, logSections);
1027
+ }
1028
+ if (res.destroyed) return;
1029
+ if (!res.headersSent) {
1030
+ res.writeHead(code, { 'Content-Type': 'application/json' });
1031
+ res.end(JSON.stringify({
1032
+ type: 'error',
1033
+ error: { type: 'overloaded_error', message: `Upstream overloaded (HTTP ${code}). Retried ${ctx.overloadRetries}x.` },
1034
+ }));
1035
+ }
1036
+ return;
1037
+ }
1038
+
1039
+ // Log response headers
1040
+ if (logDir) {
1041
+ logSections.push(`=== RESPONSE ${upstreamRes.status} ===\n${formatHeaders(upstreamRes.headers)}`);
1042
+ }
1043
+
1044
+ ctx.status = upstreamRes.status;
1045
+
1046
+ // Build response headers (skip hop-by-hop and encoding headers)
1047
+ const responseHeaders = {};
1048
+ for (const [key, value] of upstreamRes.headers.entries()) {
1049
+ if (key === 'transfer-encoding' || key === 'connection') continue;
1050
+ // Strip content-encoding/content-length since fetch may auto-decompress
1051
+ if (key === 'content-encoding' || key === 'content-length') continue;
1052
+ responseHeaders[key] = value;
1053
+ }
1054
+
1055
+ res.writeHead(upstreamRes.status, responseHeaders);
1056
+
1057
+ if (!upstreamRes.body) {
1058
+ if (logDir) {
1059
+ logSections.push(`=== RESPONSE BODY ===\n(empty)`);
1060
+ writeRequestLog(logDir, reqId, logSections);
1061
+ }
1062
+ res.end();
1063
+ return;
1064
+ }
1065
+
1066
+ const isStreaming = (upstreamRes.headers.get('content-type') || '').includes('text/event-stream');
1067
+
1068
+ if (isStreaming) {
1069
+ const streamLog = logDir ? [] : null;
1070
+ await streamResponse(upstreamRes.body, res, account, accountManager, streamLog);
1071
+ if (logDir) {
1072
+ logSections.push(`=== RESPONSE BODY (streamed) ===\n${streamLog.join('')}`);
1073
+ writeRequestLog(logDir, reqId, logSections);
1074
+ }
1075
+ } else {
1076
+ const buf = Buffer.from(await upstreamRes.arrayBuffer());
1077
+ extractUsageFromBody(buf, account, accountManager);
1078
+ if (logDir) {
1079
+ try {
1080
+ logSections.push(`=== RESPONSE BODY ===\n${JSON.stringify(JSON.parse(buf.toString()), null, 2)}`);
1081
+ } catch {
1082
+ logSections.push(`=== RESPONSE BODY (${buf.length} bytes) ===\n${buf.toString().slice(0, 8192)}`);
1083
+ }
1084
+ writeRequestLog(logDir, reqId, logSections);
1085
+ }
1086
+ res.end(buf);
1087
+ }
1088
+ } catch (err) {
1089
+ // Client disconnected → we aborted the upstream fetch (ctx.abortSignal). This
1090
+ // is not the account's fault: don't mark it 'error' or fail over (the client
1091
+ // is gone). Just unwind — the outer finally releases the slot / inFlightProxied.
1092
+ if (ctx.abortSignal?.aborted || err?.name === 'AbortError' || err?.code === 'ABORT_ERR' || res.destroyed) {
1093
+ if (!res.writableEnded) res.destroy();
1094
+ return;
1095
+ }
1096
+
1097
+ console.error(`[TeamClaude] Upstream error (account "${account.name}"):`, err.message);
1098
+
1099
+ if (logDir) {
1100
+ logSections.push(`=== ERROR ===\n${err.stack || err.message}`);
1101
+ writeRequestLog(logDir, reqId, logSections);
1102
+ }
1103
+
1104
+ const isTransient = err instanceof Error &&
1105
+ (err.message.includes('fetch failed') ||
1106
+ err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED' ||
1107
+ err.code === 'ETIMEDOUT' || err.code === 'UND_ERR_CONNECT_TIMEOUT');
1108
+
1109
+ // Transient network errors: just close the connection and let the client retry
1110
+ if (isTransient) {
1111
+ res.destroy();
1112
+ return;
1113
+ }
1114
+
1115
+ if (retryCount < maxRetries && !res.headersSent) {
1116
+ account.status = 'error';
1117
+ releaseHeld(); // this account errored; fail over to another
1118
+ return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
1119
+ }
1120
+ ctx.status = 502;
1121
+
1122
+ if (!res.headersSent) {
1123
+ res.writeHead(502, { 'Content-Type': 'application/json' });
1124
+ res.end(JSON.stringify({
1125
+ type: 'error',
1126
+ error: { type: 'proxy_error', message: `Upstream error: ${err.message}` },
1127
+ }));
1128
+ }
1129
+ }
1130
+ }
1131
+
1132
+ /**
1133
+ * Stream an SSE response to the client, parsing usage data along the way.
1134
+ */
1135
+ async function streamResponse(webStream, res, account, accountManager, streamLog) {
1136
+ const reader = webStream.getReader();
1137
+ const decoder = new TextDecoder();
1138
+ let sseBuffer = '';
1139
+
1140
+ try {
1141
+ while (true) {
1142
+ const { done, value } = await reader.read();
1143
+ if (done) break;
1144
+
1145
+ // Client disconnected — stop reading from upstream
1146
+ if (res.destroyed) break;
1147
+
1148
+ // Forward chunk immediately
1149
+ const ok = res.write(value);
1150
+
1151
+ const text = decoder.decode(value, { stream: true });
1152
+
1153
+ // Capture for logging
1154
+ if (streamLog) streamLog.push(text);
1155
+
1156
+ // Parse SSE events for usage tracking
1157
+ sseBuffer += text;
1158
+ const events = sseBuffer.split('\n\n');
1159
+ sseBuffer = events.pop(); // keep incomplete event
1160
+
1161
+ for (const event of events) {
1162
+ parseSSEUsage(event, account, accountManager);
1163
+ }
1164
+
1165
+ // Handle backpressure — also bail out if client disconnects,
1166
+ // because 'drain' will never fire on a destroyed socket
1167
+ if (!ok) {
1168
+ await new Promise(resolve => {
1169
+ res.once('drain', resolve);
1170
+ res.once('close', resolve);
1171
+ });
1172
+ if (res.destroyed) break;
1173
+ }
1174
+ }
1175
+
1176
+ // Parse any remaining buffer
1177
+ if (sseBuffer.trim()) {
1178
+ parseSSEUsage(sseBuffer, account, accountManager);
1179
+ }
1180
+ } finally {
1181
+ // Cancel upstream reader to stop consuming data nobody needs
1182
+ reader.cancel().catch(() => {});
1183
+ if (!res.writableEnded) res.end();
1184
+ }
1185
+ }
1186
+
1187
+ function parseSSEUsage(event, account, accountManager) {
1188
+ const dataLine = event.split('\n').find(l => l.startsWith('data: '));
1189
+ if (!dataLine) return;
1190
+
1191
+ try {
1192
+ const data = JSON.parse(dataLine.slice(6));
1193
+ if (data.type === 'message_start' && data.message?.usage) {
1194
+ accountManager.updateUsage(account, data.message.usage.input_tokens, 0);
1195
+ } else if (data.type === 'message_delta' && data.usage) {
1196
+ accountManager.updateUsage(account, 0, data.usage.output_tokens);
1197
+ }
1198
+ } catch {
1199
+ // not valid JSON, skip
1200
+ }
1201
+ }
1202
+
1203
+ function extractUsageFromBody(buffer, account, accountManager) {
1204
+ try {
1205
+ const json = JSON.parse(buffer.toString());
1206
+ if (json.usage) {
1207
+ accountManager.updateUsage(account, json.usage.input_tokens, json.usage.output_tokens);
1208
+ }
1209
+ } catch {
1210
+ // not JSON or no usage
1211
+ }
1212
+ }
1213
+
1214
+ // Seconds a client should wait before ANY account can serve again, derived from
1215
+ // *why* each account is currently unusable — so an all-exhausted fleet tells the
1216
+ // client the real (often hours-long) wait instead of a flat 60s it would just
1217
+ // re-flood against every minute:
1218
+ // An account is usable again only once BOTH its throttle AND every quota window
1219
+ // it is currently past `threshold` on have cleared — so we take the LATEST (max)
1220
+ // of them per account:
1221
+ // - an explicit throttle (a live exhaustion 429 → markRateLimited) is clamped
1222
+ // to <=5m, but the binding 5h/7d window that 429 came with may reset hours
1223
+ // later; the account stays `_isNearQuota` until then, so returning the
1224
+ // throttle alone made the client re-flood every 5 min while still exhausted;
1225
+ // - the quota reset is the unified 5h/7d reset the dashboard shows, NOT
1226
+ // `resetsAt` (a standard/API-key-only field the old code looked at, so a
1227
+ // utilization-exhausted Max fleet always fell through to the 60s default).
1228
+ // A window UNDER threshold is not binding, so its (always-future) reset is
1229
+ // ignored — and an account with NO binding constraint at all (quota-healthy,
1230
+ // merely concurrency-capped or overflow-queued) contributes a short 60s
1231
+ // candidate instead: its slot frees in seconds, so one healthy account caps the
1232
+ // whole fleet's wait at the short fallback even when every other account is
1233
+ // hours from reset. Disabled/auth-error accounts never return on a timer, so
1234
+ // they're skipped. Falls back to 60s when nothing contributes anything.
1235
+ function computeRetryAfter(accounts, threshold = 0.98) {
1236
+ const now = Date.now();
1237
+ let soonest = Infinity;
1238
+ const consider = ms => { if (ms > 0 && ms < soonest) soonest = ms; };
1239
+ for (const acct of accounts) {
1240
+ if (acct.enabled === false || acct.status === 'error') continue;
1241
+ // freeAt = max(throttle, every over-threshold quota reset). The account is
1242
+ // blocked until the LAST of these clears; taking the min across accounts
1243
+ // then gives the soonest the fleet has anything to serve.
1244
+ let freeAt = 0;
1245
+ if (acct.rateLimitedUntil) freeAt = Math.max(freeAt, new Date(acct.rateLimitedUntil).getTime());
1246
+ const q = acct.quota || {};
1247
+ if (q.unified5h != null && q.unified5h >= threshold && q.unified5hReset)
1248
+ freeAt = Math.max(freeAt, q.unified5hReset);
1249
+ if (q.unified7d != null && q.unified7d >= threshold && q.unified7dReset)
1250
+ freeAt = Math.max(freeAt, q.unified7dReset);
1251
+ // Standard windows reset independently — use each window's OWN reset
1252
+ // (falling back to the collapsed resetsAt for snapshots predating the
1253
+ // split fields), so when both are binding the LATER one wins instead of
1254
+ // resetsAt's preference for the sooner token reset.
1255
+ const tokensReset = q.tokensReset || q.resetsAt;
1256
+ if (q.tokensLimit != null && q.tokensRemaining != null && tokensReset
1257
+ && 1 - q.tokensRemaining / q.tokensLimit >= threshold)
1258
+ freeAt = Math.max(freeAt, new Date(tokensReset).getTime());
1259
+ const requestsReset = q.requestsReset || q.resetsAt;
1260
+ if (q.requestsLimit != null && q.requestsRemaining != null && requestsReset
1261
+ && 1 - q.requestsRemaining / q.requestsLimit >= threshold)
1262
+ freeAt = Math.max(freeAt, new Date(requestsReset).getTime());
1263
+ if (freeAt > 0) consider(freeAt - now);
1264
+ else consider(60_000); // quota-healthy (merely capped/queued): a slot frees in seconds — cap the fleet wait at the short fallback
1265
+ }
1266
+ return soonest === Infinity ? 60 : Math.max(1, Math.ceil(soonest / 1000));
1267
+ }