@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.
- package/LICENSE +21 -0
- package/README.md +176 -16
- package/package.json +7 -2
- package/src/account-manager.js +382 -24
- package/src/account-uuid-rewrite.js +115 -0
- package/src/alias.js +123 -0
- package/src/config.js +26 -0
- package/src/h2/frames.js +83 -0
- package/src/h2/hpack.js +314 -0
- package/src/h2/relay.js +417 -0
- package/src/identity.js +65 -0
- package/src/index.js +521 -91
- package/src/json-format-stream.js +65 -0
- package/src/mitm.js +387 -0
- package/src/oauth.js +77 -1
- package/src/prober.js +82 -0
- package/src/request-log.js +194 -0
- package/src/server.js +166 -88
- package/src/sx.js +218 -0
- package/src/tui.js +231 -17
- 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 = {};
|
|
@@ -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
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
283
|
-
|
|
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
|
|
369
|
+
const contentType = upstreamRes.headers.get('content-type') || '';
|
|
370
|
+
const isStreaming = contentType.includes('text/event-stream');
|
|
291
371
|
|
|
292
372
|
if (isStreaming) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
303
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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.
|
|
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,
|
|
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
|
-
|
|
444
|
+
// Append to the log as it streams (no whole-body buffering)
|
|
445
|
+
if (bodyWriter) bodyWriter.chunk(Buffer.from(value));
|
|
367
446
|
|
|
368
|
-
|
|
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;
|