@karpeleslab/teamclaude 1.0.7 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -4
- package/package.json +1 -1
- package/src/account-manager.js +182 -27
- package/src/account-uuid-rewrite.js +115 -0
- package/src/h2/frames.js +83 -0
- package/src/h2/hpack.js +314 -0
- package/src/h2/relay.js +417 -0
- package/src/index.js +177 -22
- package/src/json-format-stream.js +65 -0
- package/src/mitm.js +387 -0
- package/src/oauth.js +76 -1
- package/src/prober.js +82 -0
- package/src/request-log.js +194 -0
- package/src/server.js +148 -90
- package/src/sx.js +218 -0
- package/src/tui.js +166 -5
- package/src/upstream-fetch.js +85 -0
- package/src/x509.js +166 -0
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
211
|
-
if (safeHeaders['
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
286
|
+
const upstreamRes = await upstreamFetch(upstreamUrl, {
|
|
231
287
|
method,
|
|
232
288
|
headers,
|
|
233
|
-
body: ['GET', 'HEAD'].includes(method) ? undefined :
|
|
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
|
-
|
|
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 (
|
|
274
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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
|
-
|
|
303
|
-
|
|
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
|
|
369
|
+
const contentType = upstreamRes.headers.get('content-type') || '';
|
|
370
|
+
const isStreaming = contentType.includes('text/event-stream');
|
|
311
371
|
|
|
312
372
|
if (isStreaming) {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
323
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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.
|
|
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,
|
|
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
|
-
|
|
444
|
+
// Append to the log as it streams (no whole-body buffering)
|
|
445
|
+
if (bodyWriter) bodyWriter.chunk(Buffer.from(value));
|
|
387
446
|
|
|
388
|
-
|
|
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;
|