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/README.md +320 -0
- package/package.json +38 -0
- package/src/account-manager.js +1271 -0
- package/src/cloud.js +618 -0
- package/src/config.js +148 -0
- package/src/index.js +1726 -0
- package/src/oauth.js +347 -0
- package/src/server.js +1267 -0
- package/src/tui.js +843 -0
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
|
+
}
|