@karpeleslab/teamclaude 1.0.7 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 = {};
@@ -256,6 +312,13 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
256
312
  // Discard the 429 response body
257
313
  await upstreamRes.body?.cancel();
258
314
 
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
+
259
322
  // Bound the retries: a persistently-throttled upstream must not loop
260
323
  // forever (that would tie up the client connection indefinitely).
261
324
  // Once retries are exhausted, throttle this account and re-dispatch —
@@ -264,26 +327,24 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
264
327
  if (retryCount >= maxRetries) {
265
328
  console.log(`[TeamClaude] Persistent 429 on "${account.name}" — throttling ${retryAfter}s and re-dispatching`);
266
329
  accountManager.markRateLimited(account.index, retryAfter);
267
- if (logDir) {
268
- logSections.push(`=== RESPONSE 429 — capped after ${retryCount} retries, throttling account ===\n${formatHeaders(upstreamRes.headers)}`);
269
- }
270
- return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
330
+ return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir, sx, nextUseSx);
271
331
  }
272
332
 
273
- if (logDir) {
274
- logSections.push(`=== RESPONSE 429 waiting ${retryAfter}s ===\n${formatHeaders(upstreamRes.headers)}`);
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));
275
338
  }
276
- console.log(`[TeamClaude] 429 on "${account.name}" — waiting ${retryAfter}s before retry`);
277
- await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
278
339
  // Client may have disconnected during the wait
279
340
  if (res.destroyed) return;
280
- return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
341
+ return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir, sx, nextUseSx);
281
342
  }
282
343
 
283
- // Log response headers
284
- if (logDir) {
285
- logSections.push(`=== RESPONSE ${upstreamRes.status} ===\n${formatHeaders(upstreamRes.headers)}`);
286
- }
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)}`);
287
348
 
288
349
  ctx.status = upstreamRes.status;
289
350
 
@@ -299,43 +360,35 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
299
360
  res.writeHead(upstreamRes.status, responseHeaders);
300
361
 
301
362
  if (!upstreamRes.body) {
302
- if (logDir) {
303
- logSections.push(`=== RESPONSE BODY ===\n(empty)`);
304
- writeRequestLog(logDir, reqId, logSections);
305
- }
363
+ const l = getLog();
364
+ if (l) { l.write('\n\n=== RESPONSE BODY ===\n(empty)'); l.end(); }
306
365
  res.end();
307
366
  return;
308
367
  }
309
368
 
310
- 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');
311
371
 
312
372
  if (isStreaming) {
313
- const streamLog = logDir ? [] : null;
314
- await streamResponse(upstreamRes.body, res, account.index, accountManager, streamLog);
315
- if (logDir) {
316
- logSections.push(`=== RESPONSE BODY (streamed) ===\n${streamLog.join('')}`);
317
- writeRequestLog(logDir, reqId, logSections);
318
- }
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();
319
379
  } else {
320
380
  const buf = Buffer.from(await upstreamRes.arrayBuffer());
321
381
  extractUsageFromBody(buf, account.index, accountManager);
322
- if (logDir) {
323
- try {
324
- logSections.push(`=== RESPONSE BODY ===\n${JSON.stringify(JSON.parse(buf.toString()), null, 2)}`);
325
- } catch {
326
- logSections.push(`=== RESPONSE BODY (${buf.length} bytes) ===\n${buf.toString().slice(0, 8192)}`);
327
- }
328
- writeRequestLog(logDir, reqId, logSections);
329
- }
382
+ const l = getLog();
383
+ if (l) { l.body('RESPONSE BODY', buf, contentType); l.end(); }
330
384
  res.end(buf);
331
385
  }
332
386
  } catch (err) {
333
387
  console.error(`[TeamClaude] Upstream error (account "${account.name}"):`, err.message);
334
388
 
335
- if (logDir) {
336
- logSections.push(`=== ERROR ===\n${err.stack || err.message}`);
337
- writeRequestLog(logDir, reqId, logSections);
338
- }
389
+ logRequestHead();
390
+ const l = getLog();
391
+ if (l) { l.write(`\n\n=== ERROR ===\n${err.stack || err.message}`); l.end(); }
339
392
 
340
393
  const isTransient = err instanceof Error &&
341
394
  (err.message.includes('fetch failed') ||
@@ -348,9 +401,14 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
348
401
  return;
349
402
  }
350
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.
351
409
  if (retryCount < maxRetries && !res.headersSent) {
352
- account.status = 'error';
353
- 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);
354
412
  }
355
413
  ctx.status = 502;
356
414
 
@@ -367,7 +425,7 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
367
425
  /**
368
426
  * Stream an SSE response to the client, parsing usage data along the way.
369
427
  */
370
- async function streamResponse(webStream, res, accountIndex, accountManager, streamLog) {
428
+ async function streamResponse(webStream, res, accountIndex, accountManager, bodyWriter) {
371
429
  const reader = webStream.getReader();
372
430
  const decoder = new TextDecoder();
373
431
  let sseBuffer = '';
@@ -383,10 +441,10 @@ async function streamResponse(webStream, res, accountIndex, accountManager, stre
383
441
  // Forward chunk immediately
384
442
  const ok = res.write(value);
385
443
 
386
- 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));
387
446
 
388
- // Capture for logging
389
- if (streamLog) streamLog.push(text);
447
+ const text = decoder.decode(value, { stream: true });
390
448
 
391
449
  // Parse SSE events for usage tracking
392
450
  sseBuffer += text;