@karpeleslab/teamclaude 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,194 @@
1
+ // Per-request logging for the MITM relay (parity with the reverse-proxy path's
2
+ // --log-to). One tap per CONNECT/connection (h2 stream ids restart per
3
+ // connection, so taps must not be shared).
4
+ //
5
+ // Logs STREAM to disk as the request/response flow: the file is opened and the
6
+ // request head written the moment headers arrive, and every body chunk is
7
+ // appended as it is relayed. JSON bodies are pretty-printed on the fly via a
8
+ // streaming state machine (src/json-format-stream.js) — never buffered whole,
9
+ // so even ~1M-token bodies cost only the current chunk, and a request that
10
+ // blocks mid-stream leaves its partial (readable) body on disk so you can see
11
+ // exactly how far it got. Auth/x-api-key are masked. No size caps.
12
+
13
+ import { createWriteStream } from 'node:fs';
14
+ import { mkdir } from 'node:fs/promises';
15
+ import { join } from 'node:path';
16
+ import { JsonStreamFormatter } from './json-format-stream.js';
17
+
18
+ let seq = 0; // module-global so filenames are unique across connections
19
+
20
+ function maskValue(name, val) {
21
+ const n = name.toLowerCase();
22
+ if (n === 'authorization') return val.slice(0, 20) + '...';
23
+ if (n === 'x-api-key') return val.slice(0, 15) + '...';
24
+ return val;
25
+ }
26
+
27
+ function fmtFields(fields, { pseudo = true } = {}) {
28
+ return fields
29
+ .filter((f) => pseudo || !f.name.toString().startsWith(':'))
30
+ .map((f) => { const n = f.name.toString(); return ` ${n}: ${maskValue(n, f.value.toString())}`; })
31
+ .join('\n');
32
+ }
33
+
34
+ function get(fields, name) {
35
+ const f = fields.find((x) => x.name.toString() === name);
36
+ return f ? f.value.toString() : '';
37
+ }
38
+
39
+ function maskHeadText(text) {
40
+ return text.split('\r\n').map((line) => {
41
+ const lower = line.toLowerCase();
42
+ if (lower.startsWith('authorization:')) return 'authorization: ' + line.slice(14).trim().slice(0, 20) + '...';
43
+ if (lower.startsWith('x-api-key:')) return 'x-api-key: ...';
44
+ return line;
45
+ }).join('\r\n');
46
+ }
47
+
48
+ // content-type from an h2 field list / an h1 head text (lowercased, or '').
49
+ function ctOfFields(fields) {
50
+ const f = fields.find((x) => x.name.toString().toLowerCase() === 'content-type');
51
+ return f ? f.value.toString().toLowerCase() : '';
52
+ }
53
+ function ctOfHead(text) {
54
+ const line = text.split('\r\n').find((l) => l.toLowerCase().startsWith('content-type:'));
55
+ return line ? line.slice(line.indexOf(':') + 1).trim().toLowerCase() : '';
56
+ }
57
+
58
+ function stamp() {
59
+ const d = new Date();
60
+ const p = (n, w = 2) => String(n).padStart(w, '0');
61
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`;
62
+ }
63
+
64
+ // Tracks how one direction's body is written: decide formatter-vs-raw on the
65
+ // first chunk (event-stream → raw; otherwise pretty-print if it looks like JSON,
66
+ // i.e. the first non-whitespace byte is { or [). Writes the section header once.
67
+ export class BodyWriter {
68
+ constructor(write, label, contentType) {
69
+ this.write = write;
70
+ this.label = label;
71
+ this.isStream = /event-stream/.test(contentType);
72
+ this.decided = false;
73
+ this.fmt = null;
74
+ this.headerWritten = false;
75
+ }
76
+ chunk(buf) {
77
+ if (!buf.length) return;
78
+ if (!this.headerWritten) { this.write(`\n\n=== ${this.label} ===\n`); this.headerWritten = true; }
79
+ if (!this.decided) {
80
+ const first = buf.toString('latin1').trimStart()[0];
81
+ if (!this.isStream && (first === '{' || first === '[')) this.fmt = new JsonStreamFormatter();
82
+ this.decided = true;
83
+ }
84
+ this.write(this.fmt ? this.fmt.push(buf) : buf.toString('latin1'));
85
+ }
86
+ }
87
+
88
+ export function makeMitmTap(logDir, accountName = '') {
89
+ if (!logDir) return null;
90
+ mkdir(logDir, { recursive: true }).catch(() => {});
91
+ const recs = new Map();
92
+
93
+ function open() {
94
+ const file = join(logDir, `${stamp()}_mitm_${String(++seq).padStart(5, '0')}.log`);
95
+ const ws = createWriteStream(file, { flags: 'a' });
96
+ ws.on('error', () => {});
97
+ return ws;
98
+ }
99
+
100
+ function rec(id) {
101
+ let r = recs.get(id);
102
+ if (!r) {
103
+ r = { ws: open(), reqBody: null, resBody: null, ended: false };
104
+ // Write strings as latin1 so a body's original bytes (which the formatter
105
+ // and raw path pass through 1:1 as latin1) round-trip exactly — writing as
106
+ // utf8 would re-encode bytes >127 and corrupt non-ASCII content.
107
+ r.write = (s) => { if (!r.ended && s) r.ws.write(Buffer.from(String(s), 'latin1')); };
108
+ recs.set(id, r);
109
+ }
110
+ return r;
111
+ }
112
+
113
+ return {
114
+ req(id, fields) {
115
+ const r = rec(id);
116
+ r.write(`=== REQUEST (h2${accountName ? `, account: ${accountName}` : ''}) ===\n${get(fields, ':method')} ${get(fields, ':path')}\n${fmtFields(fields, { pseudo: false })}`);
117
+ r.reqBody = new BodyWriter(r.write, 'REQUEST BODY', ctOfFields(fields));
118
+ },
119
+ reqHead(id, text) {
120
+ const r = rec(id);
121
+ r.write(`=== REQUEST (h1${accountName ? `, account: ${accountName}` : ''}) ===\n${maskHeadText(text).trimEnd()}`);
122
+ r.reqBody = new BodyWriter(r.write, 'REQUEST BODY', ctOfHead(text));
123
+ },
124
+ reqData(id, buf) { rec(id).reqBody?.chunk(buf); },
125
+ res(id, fields) {
126
+ const r = rec(id);
127
+ r.write(`\n\n=== RESPONSE ${get(fields, ':status')} ===\n${fmtFields(fields, { pseudo: false })}`);
128
+ r.resBody = new BodyWriter(r.write, 'RESPONSE BODY', ctOfFields(fields));
129
+ },
130
+ resHead(id, text) {
131
+ const r = rec(id);
132
+ const status = (text.split('\r\n')[0].split(' ')[1]) || '';
133
+ r.write(`\n\n=== RESPONSE ${status} (h1) ===\n${maskHeadText(text).trimEnd()}`);
134
+ r.resBody = new BodyWriter(r.write, 'RESPONSE BODY', ctOfHead(text));
135
+ },
136
+ resData(id, buf) { rec(id).resBody?.chunk(buf); },
137
+ end(id) {
138
+ const r = recs.get(id);
139
+ if (!r) return;
140
+ recs.delete(id);
141
+ if (!r.ended) { r.ended = true; r.ws.end('\n'); }
142
+ },
143
+ };
144
+ }
145
+
146
+ let activitySeq = 0; // module-global so TUI ids are unique across MITM connections
147
+
148
+ // A tap (same interface as makeMitmTap) that, instead of writing to disk,
149
+ // translates each relayed request's lifecycle into the server's TUI hooks —
150
+ // so MITM traffic shows up in the live activity feed like reverse-proxy traffic.
151
+ // Relay-local ids (h2 stream ids / h1 request ids restart per connection) are
152
+ // mapped to globally-unique string ids ("m<n>") so they never collide with the
153
+ // reverse-proxy's numeric ids or with each other across connections.
154
+ export function makeActivityTap(hooks, accountName = '') {
155
+ if (!hooks || (!hooks.onRequestStart && !hooks.onRequestEnd)) return null;
156
+ const ids = new Map(); // relay-local id -> { gid, method, path, status }
157
+
158
+ function start(localId, method, path) {
159
+ const gid = `m${++activitySeq}`;
160
+ ids.set(localId, { gid, method, path, status: null });
161
+ hooks.onRequestStart?.(gid, { method, path });
162
+ if (accountName) hooks.onRequestRouted?.(gid, { account: accountName });
163
+ }
164
+
165
+ return {
166
+ req(id, fields) { start(id, get(fields, ':method'), get(fields, ':path')); },
167
+ reqHead(id, text) {
168
+ const parts = text.split('\r\n')[0].split(' ');
169
+ start(id, (parts[0] || '').toUpperCase(), parts[1] || '');
170
+ },
171
+ reqData() {},
172
+ res(id, fields) { const r = ids.get(id); if (r) r.status = get(fields, ':status'); },
173
+ resHead(id, text) { const r = ids.get(id); if (r) r.status = text.split('\r\n')[0].split(' ')[1] || ''; },
174
+ resData() {},
175
+ end(id) {
176
+ const r = ids.get(id);
177
+ if (!r) return;
178
+ ids.delete(id);
179
+ hooks.onRequestEnd?.(r.gid, { method: r.method, path: r.path, account: accountName, status: r.status });
180
+ },
181
+ };
182
+ }
183
+
184
+ // Fan one relay's tap callbacks out to several taps (disk + TUI activity).
185
+ // Returns null if none are live, the single tap if only one is, else a proxy.
186
+ export function combineTaps(...taps) {
187
+ const live = taps.filter(Boolean);
188
+ if (live.length <= 1) return live[0] || null;
189
+ const fan = (m) => (...a) => { for (const t of live) t[m]?.(...a); };
190
+ return {
191
+ req: fan('req'), reqHead: fan('reqHead'), reqData: fan('reqData'),
192
+ res: fan('res'), resHead: fan('resHead'), resData: fan('resData'), end: fan('end'),
193
+ };
194
+ }
package/src/server.js CHANGED
@@ -1,6 +1,11 @@
1
1
  import http from 'node:http';
2
- import { writeFile, mkdir } from 'node:fs/promises';
2
+ import { createWriteStream } from 'node:fs';
3
+ import { mkdir } from 'node:fs/promises';
3
4
  import { join } from 'node:path';
5
+ import { ensureCerts, createConnectHandler } from './mitm.js';
6
+ import { patchAccountUuid } from './account-uuid-rewrite.js';
7
+ import { BodyWriter } from './request-log.js';
8
+ import { upstreamFetch } from './upstream-fetch.js';
4
9
 
5
10
 
6
11
  const HOP_BY_HOP_HEADERS = new Set([
@@ -8,7 +13,7 @@ const HOP_BY_HOP_HEADERS = new Set([
8
13
  'te', 'trailer', 'upgrade', 'proxy-authorization', 'proxy-authenticate',
9
14
  ]);
10
15
 
11
- export function createProxyServer(accountManager, config, hooks = {}) {
16
+ export function createProxyServer(accountManager, config, hooks = {}, sx = null) {
12
17
  const upstream = config.upstream || 'https://api.anthropic.com';
13
18
  const proxyApiKey = config.proxy?.apiKey;
14
19
  const logDir = config.logDir || null;
@@ -18,9 +23,9 @@ export function createProxyServer(accountManager, config, hooks = {}) {
18
23
  mkdir(logDir, { recursive: true }).catch(() => {});
19
24
  }
20
25
 
21
- const server = http.createServer(async (req, res) => {
26
+ const requestHandler = async (req, res) => {
22
27
  try {
23
- // Auth check — skip for localhost connections
28
+ // Auth check — skip for localhost connections.
24
29
  const clientKey = req.headers['x-api-key'];
25
30
  const remoteAddr = req.socket.remoteAddress;
26
31
  const isLocal = remoteAddr === '127.0.0.1' || remoteAddr === '::1' || remoteAddr === '::ffff:127.0.0.1';
@@ -40,11 +45,31 @@ export function createProxyServer(accountManager, config, hooks = {}) {
40
45
  return;
41
46
  }
42
47
 
48
+ // Reload endpoint — re-sync accounts from config without a restart. This
49
+ // is the headless equivalent of pressing 'R' in the TUI. Local control
50
+ // only (no upstream calls); the auth gate above already applies.
51
+ if (req.method === 'POST' && req.url === '/teamclaude/reload') {
52
+ if (!hooks.reload) {
53
+ res.writeHead(501, { 'Content-Type': 'application/json' });
54
+ res.end(JSON.stringify({ ok: false, error: 'reload not supported' }));
55
+ return;
56
+ }
57
+ try {
58
+ const added = await hooks.reload();
59
+ res.writeHead(200, { 'Content-Type': 'application/json' });
60
+ res.end(JSON.stringify({ ok: true, added: added || 0 }));
61
+ } catch (err) {
62
+ res.writeHead(500, { 'Content-Type': 'application/json' });
63
+ res.end(JSON.stringify({ ok: false, error: err.message }));
64
+ }
65
+ return;
66
+ }
67
+
43
68
  // Let client token refresh requests pass through to upstream untouched.
44
69
  // The proxy manages its own tokens via ensureTokenFresh(); intercepting
45
70
  // or rewriting client refreshes would cause token rotation conflicts.
46
71
  if (req.method === 'POST' && req.url === '/v1/oauth/token') {
47
- await relayRaw(req, res, upstream);
72
+ await relayRaw(req, res, upstream, sx);
48
73
  return;
49
74
  }
50
75
 
@@ -59,9 +84,9 @@ export function createProxyServer(accountManager, config, hooks = {}) {
59
84
  }
60
85
  const body = Buffer.concat(bodyChunks);
61
86
 
62
- const ctx = { account: null, status: null };
87
+ const ctx = { account: null, status: null, tried: new Set() };
63
88
  try {
64
- await forwardRequest(req, res, body, accountManager, upstream, 0, hooks, reqId, ctx, logDir);
89
+ await forwardRequest(req, res, body, accountManager, upstream, 0, hooks, reqId, ctx, logDir, sx);
65
90
  } catch (err) {
66
91
  ctx.status = ctx.status || 502;
67
92
  console.error('[TeamClaude] Unhandled error:', err);
@@ -81,7 +106,23 @@ export function createProxyServer(accountManager, config, hooks = {}) {
81
106
  } catch (err) {
82
107
  console.error('[TeamClaude] Unhandled error:', err);
83
108
  }
84
- });
109
+ };
110
+
111
+ const server = http.createServer(requestHandler);
112
+
113
+ // Forward-proxy support (always on, so multiple claude instances can use
114
+ // either ANTHROPIC_BASE_URL or HTTPS_PROXY against the same server). A CONNECT
115
+ // to the upstream host is a transparent MITM relay (rewrite only auth); the
116
+ // test host is answered locally; anything else is blind-tunneled. Certs are
117
+ // minted lazily on the first intercepted CONNECT.
118
+ const mitmHost = (() => { try { return new URL(upstream).hostname; } catch { return 'api.anthropic.com'; } })();
119
+ let certsPromise = null;
120
+ const ensureLeaf = async () => {
121
+ certsPromise ||= ensureCerts(mitmHost);
122
+ const c = await certsPromise;
123
+ return { key: c.leafKeyPem, cert: c.leafCertPem };
124
+ };
125
+ server.on('connect', createConnectHandler({ config, accountManager, ensureLeaf, logDir, hooks, log: console.error, sx }));
85
126
 
86
127
  return server;
87
128
  }
@@ -89,13 +130,13 @@ export function createProxyServer(accountManager, config, hooks = {}) {
89
130
  /**
90
131
  * Relay a request to upstream with no header rewriting — pure passthrough.
91
132
  */
92
- async function relayRaw(req, res, upstream) {
133
+ async function relayRaw(req, res, upstream, sx) {
93
134
  const bodyChunks = [];
94
135
  for await (const chunk of req) bodyChunks.push(chunk);
95
136
  const body = Buffer.concat(bodyChunks);
96
137
 
97
138
  try {
98
- const upstreamRes = await fetch(`${upstream}${req.url}`, {
139
+ const upstreamRes = await upstreamFetch(`${upstream}${req.url}`, {
99
140
  method: req.method,
100
141
  headers: {
101
142
  'content-type': req.headers['content-type'] || 'application/json',
@@ -103,7 +144,7 @@ async function relayRaw(req, res, upstream) {
103
144
  'user-agent': req.headers['user-agent'] || 'node',
104
145
  },
105
146
  body: body.length > 0 ? body : undefined,
106
- });
147
+ }, sx, sx?.useByDefault());
107
148
 
108
149
  const responseBody = await upstreamRes.text();
109
150
  const responseHeaders = {};
@@ -129,15 +170,28 @@ function logTimestamp() {
129
170
  return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
130
171
  }
131
172
 
132
- async function writeRequestLog(logDir, reqId, sections) {
133
- if (!logDir) return;
134
- const ts = logTimestamp();
135
- const filename = `${ts}_${String(reqId).padStart(5, '0')}.log`;
136
- try {
137
- await writeFile(join(logDir, filename), sections.join('\n\n'), 'utf-8');
138
- } catch (err) {
139
- console.error(`[TeamClaude] Failed to write log: ${err.message}`);
140
- }
173
+ // A per-request log that streams to disk as the request/response flow, instead
174
+ // of buffering the whole body in memory and writing once at the end. The file
175
+ // is opened on first write; header sections are written verbatim and bodies are
176
+ // streamed through BodyWriter (JSON pretty-printed on the fly, SSE/other raw),
177
+ // so even a ~1M-token response costs only the current chunk.
178
+ function openRequestLog(logDir, reqId) {
179
+ const filename = `${logTimestamp()}_${String(reqId).padStart(5, '0')}.log`;
180
+ const ws = createWriteStream(join(logDir, filename), { flags: 'a' });
181
+ ws.on('error', (err) => console.error(`[TeamClaude] Failed to write log: ${err.message}`));
182
+ let ended = false;
183
+ const write = (s) => { if (!ended && s) ws.write(Buffer.from(String(s), 'latin1')); };
184
+ return {
185
+ write,
186
+ // Stream a complete body buffer under a section header.
187
+ body(label, buf, contentType) {
188
+ if (!buf || !buf.length) { write(`\n\n=== ${label} ===\n(empty)`); return; }
189
+ new BodyWriter(write, label, contentType || '').chunk(buf);
190
+ },
191
+ // A BodyWriter to append chunks incrementally (e.g. an SSE response).
192
+ bodyWriter(label, contentType) { return new BodyWriter(write, label, contentType || ''); },
193
+ end() { if (!ended) { ended = true; ws.end('\n'); } },
194
+ };
141
195
  }
142
196
 
143
197
  function formatHeaders(headers) {
@@ -147,11 +201,14 @@ function formatHeaders(headers) {
147
201
  return Object.entries(headers).map(([k, v]) => ` ${k}: ${v}`).join('\n');
148
202
  }
149
203
 
150
- async function forwardRequest(req, res, body, accountManager, upstream, retryCount, hooks, reqId, ctx, logDir) {
204
+ async function forwardRequest(req, res, body, accountManager, upstream, retryCount, hooks, reqId, ctx, logDir, sx, useSx) {
151
205
  const maxRetries = accountManager.accounts.length;
206
+ // Whether THIS attempt dials via sx.org. Undefined on the first call → derive
207
+ // from the default policy ('always' routes; 'off'/'429' start direct).
208
+ const route = useSx === undefined ? !!(sx?.useByDefault()) : useSx;
152
209
 
153
- // Select account
154
- const account = accountManager.getActiveAccount();
210
+ // Select account, skipping any already tried (and failed) this request.
211
+ const account = accountManager.getActiveAccount(ctx.tried);
155
212
  if (!account) {
156
213
  ctx.status = 429;
157
214
  ctx.account = '(none available)';
@@ -178,7 +235,8 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
178
235
  // Refresh OAuth token if needed
179
236
  await accountManager.ensureTokenFresh(account.index);
180
237
  if (account.status === 'error' && retryCount < maxRetries) {
181
- return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
238
+ ctx.tried.add(account.index);
239
+ return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir, sx, route);
182
240
  }
183
241
 
184
242
  // Build upstream request headers
@@ -203,36 +261,34 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
203
261
  const upstreamUrl = `${upstream}${req.url}`;
204
262
  const method = req.method;
205
263
 
206
- // Build log sections
207
- const logSections = [];
208
- if (logDir) {
264
+ // Align the body's account_uuid (in metadata.user_id) with the account whose
265
+ // token we're injecting (same-length patch; no-op if absent).
266
+ const sendBody = account.accountUuid ? patchAccountUuid(body, account.accountUuid) : body;
267
+
268
+ // Streaming request log, opened lazily on the first terminal outcome (a
269
+ // pure-429-then-retry attempt writes no file, matching prior behavior). The
270
+ // request head+body are written once, just before the response is logged.
271
+ let log = null;
272
+ let reqLogged = false;
273
+ const getLog = () => (logDir ? (log ||= openRequestLog(logDir, reqId)) : null);
274
+ const logRequestHead = () => {
275
+ const l = getLog();
276
+ if (!l || reqLogged) return;
277
+ reqLogged = true;
209
278
  const safeHeaders = { ...headers };
210
- // Mask credentials in logs
211
- if (safeHeaders['x-api-key']) {
212
- safeHeaders['x-api-key'] = safeHeaders['x-api-key'].slice(0, 15) + '...';
213
- }
214
- if (safeHeaders['authorization']) {
215
- safeHeaders['authorization'] = safeHeaders['authorization'].slice(0, 20) + '...';
216
- }
217
- logSections.push(
218
- `=== REQUEST (account: ${account.name}, retry: ${retryCount}) ===\n${method} ${upstreamUrl}\n${formatHeaders(safeHeaders)}`,
219
- );
220
- if (body.length > 0) {
221
- try {
222
- logSections.push(`=== REQUEST BODY ===\n${JSON.stringify(JSON.parse(body.toString()), null, 2)}`);
223
- } catch {
224
- logSections.push(`=== REQUEST BODY (${body.length} bytes) ===\n${body.toString().slice(0, 4096)}`);
225
- }
226
- }
227
- }
279
+ if (safeHeaders['x-api-key']) safeHeaders['x-api-key'] = safeHeaders['x-api-key'].slice(0, 15) + '...';
280
+ if (safeHeaders['authorization']) safeHeaders['authorization'] = safeHeaders['authorization'].slice(0, 20) + '...';
281
+ l.write(`=== REQUEST (account: ${account.name}, retry: ${retryCount}) ===\n${method} ${upstreamUrl}\n${formatHeaders(safeHeaders)}`);
282
+ if (body.length > 0) l.body('REQUEST BODY', body, req.headers['content-type']);
283
+ };
228
284
 
229
285
  try {
230
- const upstreamRes = await fetch(upstreamUrl, {
286
+ const upstreamRes = await upstreamFetch(upstreamUrl, {
231
287
  method,
232
288
  headers,
233
- body: ['GET', 'HEAD'].includes(method) ? undefined : body,
289
+ body: ['GET', 'HEAD'].includes(method) ? undefined : sendBody,
234
290
  redirect: 'manual',
235
- });
291
+ }, sx, route);
236
292
 
237
293
  // Extract rate limit headers
238
294
  const rateLimitHeaders = {};
@@ -244,26 +300,51 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
244
300
  accountManager.updateQuota(account.index, rateLimitHeaders);
245
301
 
246
302
  // On 429, wait the retry-after duration and retry on the same account
247
- // (this is a transient rate limit, not quota exhaustion)
303
+ // (this is a transient rate limit, not quota exhaustion).
248
304
  if (upstreamRes.status === 429) {
249
- const retryAfter = parseInt(upstreamRes.headers.get('retry-after'), 10) || 60;
305
+ // Clamp Retry-After to a sane window: missing/invalid falls back to 60s,
306
+ // and out-of-range values are bounded to [1, 300]. A negative value would
307
+ // otherwise bypass the retry cap — setTimeout returns immediately and
308
+ // markRateLimited would set rateLimitedUntil in the past.
309
+ let retryAfter = parseInt(upstreamRes.headers.get('retry-after'), 10);
310
+ if (Number.isNaN(retryAfter)) retryAfter = 60;
311
+ retryAfter = Math.min(Math.max(retryAfter, 1), 300);
250
312
  // Discard the 429 response body
251
313
  await upstreamRes.body?.cancel();
252
314
 
253
- if (logDir) {
254
- logSections.push(`=== RESPONSE 429 waiting ${retryAfter}s ===\n${formatHeaders(upstreamRes.headers)}`);
315
+ // sx.org failover: 429s are IP-based, so retry via the proxy's egress IP.
316
+ // 'always' is already on sx; '429' switches direct→sx now and skips the
317
+ // wait (a fresh IP isn't throttled). Also arm the sticky window for MITM.
318
+ const nextUseSx = !!(sx?.useOn429());
319
+ const switchingToSx = nextUseSx && !route;
320
+ sx?.noteRateLimited(retryAfter);
321
+
322
+ // Bound the retries: a persistently-throttled upstream must not loop
323
+ // forever (that would tie up the client connection indefinitely).
324
+ // Once retries are exhausted, throttle this account and re-dispatch —
325
+ // getActiveAccount then picks another account, or returns 429 to the
326
+ // client if every account is throttled.
327
+ if (retryCount >= maxRetries) {
328
+ console.log(`[TeamClaude] Persistent 429 on "${account.name}" — throttling ${retryAfter}s and re-dispatching`);
329
+ accountManager.markRateLimited(account.index, retryAfter);
330
+ return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir, sx, nextUseSx);
331
+ }
332
+
333
+ if (switchingToSx) {
334
+ console.log(`[TeamClaude] 429 on "${account.name}" — retrying via sx.org (fresh egress IP)`);
335
+ } else {
336
+ console.log(`[TeamClaude] 429 on "${account.name}" — waiting ${retryAfter}s before retry`);
337
+ await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
255
338
  }
256
- console.log(`[TeamClaude] 429 on "${account.name}" — waiting ${retryAfter}s before retry`);
257
- await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
258
339
  // Client may have disconnected during the wait
259
340
  if (res.destroyed) return;
260
- return forwardRequest(req, res, body, accountManager, upstream, retryCount, hooks, reqId, ctx, logDir);
341
+ return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir, sx, nextUseSx);
261
342
  }
262
343
 
263
- // Log response headers
264
- if (logDir) {
265
- logSections.push(`=== RESPONSE ${upstreamRes.status} ===\n${formatHeaders(upstreamRes.headers)}`);
266
- }
344
+ // Log the request head (once) followed by the response headers, streaming
345
+ // to disk from here on.
346
+ logRequestHead();
347
+ getLog()?.write(`\n\n=== RESPONSE ${upstreamRes.status} ===\n${formatHeaders(upstreamRes.headers)}`);
267
348
 
268
349
  ctx.status = upstreamRes.status;
269
350
 
@@ -279,43 +360,35 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
279
360
  res.writeHead(upstreamRes.status, responseHeaders);
280
361
 
281
362
  if (!upstreamRes.body) {
282
- if (logDir) {
283
- logSections.push(`=== RESPONSE BODY ===\n(empty)`);
284
- writeRequestLog(logDir, reqId, logSections);
285
- }
363
+ const l = getLog();
364
+ if (l) { l.write('\n\n=== RESPONSE BODY ===\n(empty)'); l.end(); }
286
365
  res.end();
287
366
  return;
288
367
  }
289
368
 
290
- const isStreaming = (upstreamRes.headers.get('content-type') || '').includes('text/event-stream');
369
+ const contentType = upstreamRes.headers.get('content-type') || '';
370
+ const isStreaming = contentType.includes('text/event-stream');
291
371
 
292
372
  if (isStreaming) {
293
- const streamLog = logDir ? [] : null;
294
- await streamResponse(upstreamRes.body, res, account.index, accountManager, streamLog);
295
- if (logDir) {
296
- logSections.push(`=== RESPONSE BODY (streamed) ===\n${streamLog.join('')}`);
297
- writeRequestLog(logDir, reqId, logSections);
298
- }
373
+ // Stream each chunk straight to the log as it is relayed — never hold the
374
+ // whole (potentially ~1M-token) SSE body in memory.
375
+ const l = getLog();
376
+ const bw = l ? l.bodyWriter('RESPONSE BODY (streamed)', contentType) : null;
377
+ await streamResponse(upstreamRes.body, res, account.index, accountManager, bw);
378
+ l?.end();
299
379
  } else {
300
380
  const buf = Buffer.from(await upstreamRes.arrayBuffer());
301
381
  extractUsageFromBody(buf, account.index, accountManager);
302
- if (logDir) {
303
- try {
304
- logSections.push(`=== RESPONSE BODY ===\n${JSON.stringify(JSON.parse(buf.toString()), null, 2)}`);
305
- } catch {
306
- logSections.push(`=== RESPONSE BODY (${buf.length} bytes) ===\n${buf.toString().slice(0, 8192)}`);
307
- }
308
- writeRequestLog(logDir, reqId, logSections);
309
- }
382
+ const l = getLog();
383
+ if (l) { l.body('RESPONSE BODY', buf, contentType); l.end(); }
310
384
  res.end(buf);
311
385
  }
312
386
  } catch (err) {
313
387
  console.error(`[TeamClaude] Upstream error (account "${account.name}"):`, err.message);
314
388
 
315
- if (logDir) {
316
- logSections.push(`=== ERROR ===\n${err.stack || err.message}`);
317
- writeRequestLog(logDir, reqId, logSections);
318
- }
389
+ logRequestHead();
390
+ const l = getLog();
391
+ if (l) { l.write(`\n\n=== ERROR ===\n${err.stack || err.message}`); l.end(); }
319
392
 
320
393
  const isTransient = err instanceof Error &&
321
394
  (err.message.includes('fetch failed') ||
@@ -328,9 +401,14 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
328
401
  return;
329
402
  }
330
403
 
404
+ // Any other thrown error is a transport/stream failure, NOT proof the
405
+ // account's credentials are bad — a bad credential comes back as a 401
406
+ // *response*, never a throw. So don't sideline the account (that would drop
407
+ // a healthy account from rotation until a credential change). Instead skip
408
+ // it for the rest of THIS request only and fail over to another account.
331
409
  if (retryCount < maxRetries && !res.headersSent) {
332
- account.status = 'error';
333
- return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
410
+ ctx.tried.add(account.index);
411
+ return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir, sx, route);
334
412
  }
335
413
  ctx.status = 502;
336
414
 
@@ -347,7 +425,7 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
347
425
  /**
348
426
  * Stream an SSE response to the client, parsing usage data along the way.
349
427
  */
350
- async function streamResponse(webStream, res, accountIndex, accountManager, streamLog) {
428
+ async function streamResponse(webStream, res, accountIndex, accountManager, bodyWriter) {
351
429
  const reader = webStream.getReader();
352
430
  const decoder = new TextDecoder();
353
431
  let sseBuffer = '';
@@ -363,10 +441,10 @@ async function streamResponse(webStream, res, accountIndex, accountManager, stre
363
441
  // Forward chunk immediately
364
442
  const ok = res.write(value);
365
443
 
366
- const text = decoder.decode(value, { stream: true });
444
+ // Append to the log as it streams (no whole-body buffering)
445
+ if (bodyWriter) bodyWriter.chunk(Buffer.from(value));
367
446
 
368
- // Capture for logging
369
- if (streamLog) streamLog.push(text);
447
+ const text = decoder.decode(value, { stream: true });
370
448
 
371
449
  // Parse SSE events for usage tracking
372
450
  sseBuffer += text;