@karpeleslab/teamclaude 1.0.0
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 +169 -0
- package/package.json +27 -0
- package/src/account-manager.js +323 -0
- package/src/config.js +48 -0
- package/src/index.js +577 -0
- package/src/oauth.js +220 -0
- package/src/server.js +351 -0
- package/src/tui.js +388 -0
package/src/oauth.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { randomBytes, createHash } from 'node:crypto';
|
|
4
|
+
import { exec } from 'node:child_process';
|
|
5
|
+
import http from 'node:http';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Import OAuth credentials from a Claude Code credentials file.
|
|
9
|
+
*/
|
|
10
|
+
export async function importCredentials(filePath) {
|
|
11
|
+
const resolvedPath = filePath.replace(/^~/, homedir());
|
|
12
|
+
const raw = JSON.parse(await readFile(resolvedPath, 'utf-8'));
|
|
13
|
+
|
|
14
|
+
// Claude Code stores credentials nested under "claudeAiOauth"
|
|
15
|
+
const data = raw.claudeAiOauth || raw;
|
|
16
|
+
return {
|
|
17
|
+
accessToken: data.accessToken,
|
|
18
|
+
refreshToken: data.refreshToken,
|
|
19
|
+
expiresAt: data.expiresAt,
|
|
20
|
+
subscriptionType: data.subscriptionType,
|
|
21
|
+
rateLimitTier: data.rateLimitTier,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile';
|
|
26
|
+
const DEFAULT_TOKEN_ENDPOINT = 'https://platform.claude.com/v1/oauth/token';
|
|
27
|
+
const DEFAULT_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Refresh an expired OAuth access token using the refresh token.
|
|
31
|
+
*/
|
|
32
|
+
export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_ENDPOINT) {
|
|
33
|
+
const res = await fetch(endpoint, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'Content-Type': 'application/json' },
|
|
36
|
+
body: JSON.stringify({
|
|
37
|
+
grant_type: 'refresh_token',
|
|
38
|
+
refresh_token: refreshToken,
|
|
39
|
+
client_id: DEFAULT_CLIENT_ID,
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
const text = await res.text();
|
|
45
|
+
throw new Error(`Token refresh failed (${res.status}): ${text}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const data = await res.json();
|
|
49
|
+
return {
|
|
50
|
+
accessToken: data.access_token,
|
|
51
|
+
refreshToken: data.refresh_token || refreshToken,
|
|
52
|
+
expiresAt: data.expires_at || (Date.now() + (data.expires_in || 3600) * 1000),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Check if an OAuth token is expiring within the given threshold.
|
|
58
|
+
*/
|
|
59
|
+
export function isTokenExpiringSoon(expiresAt, thresholdMs = 5 * 60 * 1000) {
|
|
60
|
+
if (!expiresAt) return false;
|
|
61
|
+
return Date.now() + thresholdMs >= expiresAt;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Fetch account profile for an OAuth token.
|
|
66
|
+
* Returns { email, name, orgName, orgType } or null on failure.
|
|
67
|
+
*/
|
|
68
|
+
export async function fetchProfile(accessToken) {
|
|
69
|
+
try {
|
|
70
|
+
const res = await fetch(PROFILE_URL, {
|
|
71
|
+
headers: { 'Authorization': `Bearer ${accessToken}` },
|
|
72
|
+
});
|
|
73
|
+
if (!res.ok) return null;
|
|
74
|
+
const data = await res.json();
|
|
75
|
+
return {
|
|
76
|
+
accountUuid: data.account?.uuid,
|
|
77
|
+
email: data.account?.email,
|
|
78
|
+
name: data.account?.display_name,
|
|
79
|
+
orgName: data.organization?.name,
|
|
80
|
+
orgType: data.organization?.organization_type,
|
|
81
|
+
hasClaudeMax: data.account?.has_claude_max,
|
|
82
|
+
hasClaudePro: data.account?.has_claude_pro,
|
|
83
|
+
};
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// OAuth config (extracted from Claude Code)
|
|
90
|
+
const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
91
|
+
const OAUTH_AUTHORIZE = 'https://claude.ai/oauth/authorize';
|
|
92
|
+
const OAUTH_TOKEN = 'https://platform.claude.com/v1/oauth/token';
|
|
93
|
+
const OAUTH_SCOPES = 'org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload';
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Perform OAuth login via browser with PKCE flow.
|
|
97
|
+
* Opens the user's browser, waits for the callback, exchanges the code for tokens.
|
|
98
|
+
*/
|
|
99
|
+
export async function loginOAuth() {
|
|
100
|
+
// Generate PKCE
|
|
101
|
+
const codeVerifier = randomBytes(32).toString('base64url');
|
|
102
|
+
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
|
|
103
|
+
const state = randomBytes(32).toString('base64url');
|
|
104
|
+
|
|
105
|
+
// Start local callback server on a random port
|
|
106
|
+
const { port, codePromise, server } = await startCallbackServer(state);
|
|
107
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
108
|
+
|
|
109
|
+
// Build authorization URL
|
|
110
|
+
const authUrl = new URL(OAUTH_AUTHORIZE);
|
|
111
|
+
authUrl.searchParams.set('code', 'true');
|
|
112
|
+
authUrl.searchParams.set('client_id', OAUTH_CLIENT_ID);
|
|
113
|
+
authUrl.searchParams.set('response_type', 'code');
|
|
114
|
+
authUrl.searchParams.set('redirect_uri', redirectUri);
|
|
115
|
+
authUrl.searchParams.set('scope', OAUTH_SCOPES);
|
|
116
|
+
authUrl.searchParams.set('code_challenge', codeChallenge);
|
|
117
|
+
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
118
|
+
authUrl.searchParams.set('state', state);
|
|
119
|
+
|
|
120
|
+
// Open browser
|
|
121
|
+
console.log('Opening browser for authentication...');
|
|
122
|
+
console.log(`If it doesn't open, visit:\n ${authUrl.toString()}\n`);
|
|
123
|
+
openBrowser(authUrl.toString());
|
|
124
|
+
|
|
125
|
+
// Wait for the authorization code
|
|
126
|
+
let code;
|
|
127
|
+
try {
|
|
128
|
+
code = await codePromise;
|
|
129
|
+
} finally {
|
|
130
|
+
server.close();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Exchange code for tokens
|
|
134
|
+
console.log('Exchanging authorization code for tokens...');
|
|
135
|
+
const tokenRes = await fetch(OAUTH_TOKEN, {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: { 'Content-Type': 'application/json' },
|
|
138
|
+
body: JSON.stringify({
|
|
139
|
+
code,
|
|
140
|
+
state,
|
|
141
|
+
grant_type: 'authorization_code',
|
|
142
|
+
client_id: OAUTH_CLIENT_ID,
|
|
143
|
+
redirect_uri: redirectUri,
|
|
144
|
+
code_verifier: codeVerifier,
|
|
145
|
+
}),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (!tokenRes.ok) {
|
|
149
|
+
const text = await tokenRes.text();
|
|
150
|
+
throw new Error(`Token exchange failed (${tokenRes.status}): ${text}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const tokens = await tokenRes.json();
|
|
154
|
+
return {
|
|
155
|
+
accessToken: tokens.access_token,
|
|
156
|
+
refreshToken: tokens.refresh_token,
|
|
157
|
+
expiresAt: tokens.expires_at || (Date.now() + (tokens.expires_in || 3600) * 1000),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function startCallbackServer(expectedState) {
|
|
162
|
+
return new Promise((resolve, reject) => {
|
|
163
|
+
let resolveCode, rejectCode;
|
|
164
|
+
const codePromise = new Promise((res, rej) => { resolveCode = res; rejectCode = rej; });
|
|
165
|
+
|
|
166
|
+
const server = http.createServer((req, res) => {
|
|
167
|
+
const url = new URL(req.url, `http://localhost`);
|
|
168
|
+
|
|
169
|
+
if (url.pathname === '/callback') {
|
|
170
|
+
const code = url.searchParams.get('code');
|
|
171
|
+
const error = url.searchParams.get('error');
|
|
172
|
+
const state = url.searchParams.get('state');
|
|
173
|
+
|
|
174
|
+
if (error) {
|
|
175
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
176
|
+
res.end('<html><body><h2>Authentication failed</h2><p>You can close this tab.</p></body></html>');
|
|
177
|
+
rejectCode(new Error(`OAuth error: ${error} - ${url.searchParams.get('error_description') || ''}`));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (expectedState && state !== expectedState) {
|
|
182
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
183
|
+
res.end('<html><body><h2>Authentication failed</h2><p>State mismatch. You can close this tab.</p></body></html>');
|
|
184
|
+
rejectCode(new Error('OAuth state mismatch'));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (code) {
|
|
189
|
+
res.writeHead(302, { 'Location': 'https://platform.claude.com/oauth/code/success?app=claude-code' });
|
|
190
|
+
res.end();
|
|
191
|
+
resolveCode(code);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
res.writeHead(404);
|
|
197
|
+
res.end('Not found');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
server.listen(0, () => {
|
|
201
|
+
resolve({ port: server.address().port, codePromise, server });
|
|
202
|
+
});
|
|
203
|
+
server.on('error', reject);
|
|
204
|
+
|
|
205
|
+
// Timeout after 2 minutes (unref so it doesn't keep the process alive)
|
|
206
|
+
const timer = setTimeout(() => {
|
|
207
|
+
rejectCode(new Error('Login timed out after 2 minutes'));
|
|
208
|
+
server.close();
|
|
209
|
+
}, 120_000);
|
|
210
|
+
timer.unref();
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function openBrowser(url) {
|
|
215
|
+
const platform = process.platform;
|
|
216
|
+
const cmd = platform === 'darwin' ? 'open'
|
|
217
|
+
: platform === 'win32' ? 'start'
|
|
218
|
+
: 'xdg-open';
|
|
219
|
+
exec(`${cmd} ${JSON.stringify(url)}`, () => {});
|
|
220
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { writeFile, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
6
|
+
'host', 'connection', 'keep-alive', 'transfer-encoding',
|
|
7
|
+
'te', 'trailer', 'upgrade', 'proxy-authorization', 'proxy-authenticate',
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
export function createProxyServer(accountManager, config, hooks = {}) {
|
|
11
|
+
const upstream = config.upstream || 'https://api.anthropic.com';
|
|
12
|
+
const proxyApiKey = config.proxy?.apiKey;
|
|
13
|
+
const logDir = config.logDir || null;
|
|
14
|
+
let requestCounter = 0;
|
|
15
|
+
|
|
16
|
+
if (logDir) {
|
|
17
|
+
mkdir(logDir, { recursive: true }).catch(() => {});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const server = http.createServer(async (req, res) => {
|
|
21
|
+
try {
|
|
22
|
+
// Auth check
|
|
23
|
+
const clientKey = req.headers['x-api-key'];
|
|
24
|
+
if (proxyApiKey && clientKey !== proxyApiKey) {
|
|
25
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
26
|
+
res.end(JSON.stringify({
|
|
27
|
+
type: 'error',
|
|
28
|
+
error: { type: 'authentication_error', message: 'Invalid proxy API key' },
|
|
29
|
+
}));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Status endpoint
|
|
34
|
+
if (req.method === 'GET' && req.url === '/teamclaude/status') {
|
|
35
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
36
|
+
res.end(JSON.stringify(accountManager.getStatus(), null, 2));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Track request
|
|
41
|
+
const reqId = ++requestCounter;
|
|
42
|
+
hooks.onRequestStart?.(reqId, { method: req.method, path: req.url });
|
|
43
|
+
|
|
44
|
+
// Buffer request body (needed for retry on 429)
|
|
45
|
+
const bodyChunks = [];
|
|
46
|
+
for await (const chunk of req) {
|
|
47
|
+
bodyChunks.push(chunk);
|
|
48
|
+
}
|
|
49
|
+
const body = Buffer.concat(bodyChunks);
|
|
50
|
+
|
|
51
|
+
const ctx = { account: null, status: null };
|
|
52
|
+
await forwardRequest(req, res, body, accountManager, upstream, 0, hooks, reqId, ctx, logDir);
|
|
53
|
+
|
|
54
|
+
hooks.onRequestEnd?.(reqId, {
|
|
55
|
+
method: req.method, path: req.url,
|
|
56
|
+
account: ctx.account, status: ctx.status,
|
|
57
|
+
});
|
|
58
|
+
} catch (err) {
|
|
59
|
+
console.error('[TeamClaude] Unhandled error:', err);
|
|
60
|
+
if (!res.headersSent) {
|
|
61
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
62
|
+
res.end(JSON.stringify({
|
|
63
|
+
type: 'error',
|
|
64
|
+
error: { type: 'proxy_error', message: 'Internal proxy error' },
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return server;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function logTimestamp() {
|
|
74
|
+
const d = new Date();
|
|
75
|
+
const pad = (n, w = 2) => String(n).padStart(w, '0');
|
|
76
|
+
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function writeRequestLog(logDir, reqId, sections) {
|
|
80
|
+
if (!logDir) return;
|
|
81
|
+
const ts = logTimestamp();
|
|
82
|
+
const filename = `${ts}_${String(reqId).padStart(5, '0')}.log`;
|
|
83
|
+
try {
|
|
84
|
+
await writeFile(join(logDir, filename), sections.join('\n\n'), 'utf-8');
|
|
85
|
+
} catch (err) {
|
|
86
|
+
console.error(`[TeamClaude] Failed to write log: ${err.message}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function formatHeaders(headers) {
|
|
91
|
+
if (headers.entries) {
|
|
92
|
+
return [...headers.entries()].map(([k, v]) => ` ${k}: ${v}`).join('\n');
|
|
93
|
+
}
|
|
94
|
+
return Object.entries(headers).map(([k, v]) => ` ${k}: ${v}`).join('\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function forwardRequest(req, res, body, accountManager, upstream, retryCount, hooks, reqId, ctx, logDir) {
|
|
98
|
+
const maxRetries = accountManager.accounts.length;
|
|
99
|
+
|
|
100
|
+
// Select account
|
|
101
|
+
const account = accountManager.getActiveAccount();
|
|
102
|
+
if (!account) {
|
|
103
|
+
ctx.status = 429;
|
|
104
|
+
const status = accountManager.getStatus();
|
|
105
|
+
const retryAfter = computeRetryAfter(status.accounts);
|
|
106
|
+
res.writeHead(429, {
|
|
107
|
+
'Content-Type': 'application/json',
|
|
108
|
+
'retry-after': String(retryAfter),
|
|
109
|
+
});
|
|
110
|
+
res.end(JSON.stringify({
|
|
111
|
+
type: 'error',
|
|
112
|
+
error: {
|
|
113
|
+
type: 'rate_limit_error',
|
|
114
|
+
message: `All ${accountManager.accounts.length} accounts exhausted. Retry in ${retryAfter}s.`,
|
|
115
|
+
},
|
|
116
|
+
}));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Track which account handles this request
|
|
121
|
+
ctx.account = account.name;
|
|
122
|
+
hooks.onRequestRouted?.(reqId, { account: account.name });
|
|
123
|
+
|
|
124
|
+
// Refresh OAuth token if needed
|
|
125
|
+
await accountManager.ensureTokenFresh(account.index);
|
|
126
|
+
if (account.status === 'error' && retryCount < maxRetries) {
|
|
127
|
+
return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Build upstream request headers
|
|
131
|
+
const headers = {};
|
|
132
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
133
|
+
const lk = key.toLowerCase();
|
|
134
|
+
if (HOP_BY_HOP_HEADERS.has(lk)) continue;
|
|
135
|
+
if (lk === 'x-api-key') continue;
|
|
136
|
+
// Strip accept-encoding: Node fetch auto-decompresses, which would
|
|
137
|
+
// mismatch the Content-Encoding header we forward to the client
|
|
138
|
+
if (lk === 'accept-encoding') continue;
|
|
139
|
+
headers[key] = value;
|
|
140
|
+
}
|
|
141
|
+
headers['x-api-key'] = account.credential;
|
|
142
|
+
|
|
143
|
+
const upstreamUrl = `${upstream}${req.url}`;
|
|
144
|
+
const method = req.method;
|
|
145
|
+
|
|
146
|
+
// Build log sections
|
|
147
|
+
const logSections = [];
|
|
148
|
+
if (logDir) {
|
|
149
|
+
const safeHeaders = { ...headers };
|
|
150
|
+
// Mask the credential in logs
|
|
151
|
+
if (safeHeaders['x-api-key']) {
|
|
152
|
+
safeHeaders['x-api-key'] = safeHeaders['x-api-key'].slice(0, 15) + '...';
|
|
153
|
+
}
|
|
154
|
+
logSections.push(
|
|
155
|
+
`=== REQUEST (account: ${account.name}, retry: ${retryCount}) ===\n${method} ${upstreamUrl}\n${formatHeaders(safeHeaders)}`,
|
|
156
|
+
);
|
|
157
|
+
if (body.length > 0) {
|
|
158
|
+
try {
|
|
159
|
+
logSections.push(`=== REQUEST BODY ===\n${JSON.stringify(JSON.parse(body.toString()), null, 2)}`);
|
|
160
|
+
} catch {
|
|
161
|
+
logSections.push(`=== REQUEST BODY (${body.length} bytes) ===\n${body.toString().slice(0, 4096)}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const upstreamRes = await fetch(upstreamUrl, {
|
|
168
|
+
method,
|
|
169
|
+
headers,
|
|
170
|
+
body: ['GET', 'HEAD'].includes(method) ? undefined : body,
|
|
171
|
+
redirect: 'manual',
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Extract rate limit headers
|
|
175
|
+
const rateLimitHeaders = {};
|
|
176
|
+
for (const [key, value] of upstreamRes.headers.entries()) {
|
|
177
|
+
if (key.startsWith('anthropic-ratelimit-')) {
|
|
178
|
+
rateLimitHeaders[key] = value;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
accountManager.updateQuota(account.index, rateLimitHeaders);
|
|
182
|
+
|
|
183
|
+
// Log response headers
|
|
184
|
+
if (logDir) {
|
|
185
|
+
logSections.push(`=== RESPONSE ${upstreamRes.status} ===\n${formatHeaders(upstreamRes.headers)}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Handle 429 — retry with next account
|
|
189
|
+
if (upstreamRes.status === 429 && retryCount < maxRetries) {
|
|
190
|
+
const retryAfter = parseInt(upstreamRes.headers.get('retry-after') || '60', 10);
|
|
191
|
+
accountManager.markRateLimited(account.index, retryAfter);
|
|
192
|
+
const drainBuf = await upstreamRes.arrayBuffer();
|
|
193
|
+
if (logDir) {
|
|
194
|
+
logSections.push(`=== RESPONSE BODY (429) ===\n${Buffer.from(drainBuf).toString()}`);
|
|
195
|
+
logSections.push(`=== RETRYING with next account ===`);
|
|
196
|
+
writeRequestLog(logDir, reqId, logSections);
|
|
197
|
+
}
|
|
198
|
+
return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
ctx.status = upstreamRes.status;
|
|
202
|
+
|
|
203
|
+
// Build response headers (skip hop-by-hop and encoding headers)
|
|
204
|
+
const responseHeaders = {};
|
|
205
|
+
for (const [key, value] of upstreamRes.headers.entries()) {
|
|
206
|
+
if (key === 'transfer-encoding' || key === 'connection') continue;
|
|
207
|
+
// Strip content-encoding/content-length since fetch may auto-decompress
|
|
208
|
+
if (key === 'content-encoding' || key === 'content-length') continue;
|
|
209
|
+
responseHeaders[key] = value;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
res.writeHead(upstreamRes.status, responseHeaders);
|
|
213
|
+
|
|
214
|
+
if (!upstreamRes.body) {
|
|
215
|
+
if (logDir) {
|
|
216
|
+
logSections.push(`=== RESPONSE BODY ===\n(empty)`);
|
|
217
|
+
writeRequestLog(logDir, reqId, logSections);
|
|
218
|
+
}
|
|
219
|
+
res.end();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const isStreaming = (upstreamRes.headers.get('content-type') || '').includes('text/event-stream');
|
|
224
|
+
|
|
225
|
+
if (isStreaming) {
|
|
226
|
+
const streamLog = logDir ? [] : null;
|
|
227
|
+
await streamResponse(upstreamRes.body, res, account.index, accountManager, streamLog);
|
|
228
|
+
if (logDir) {
|
|
229
|
+
logSections.push(`=== RESPONSE BODY (streamed) ===\n${streamLog.join('')}`);
|
|
230
|
+
writeRequestLog(logDir, reqId, logSections);
|
|
231
|
+
}
|
|
232
|
+
} else {
|
|
233
|
+
const buf = Buffer.from(await upstreamRes.arrayBuffer());
|
|
234
|
+
extractUsageFromBody(buf, account.index, accountManager);
|
|
235
|
+
if (logDir) {
|
|
236
|
+
try {
|
|
237
|
+
logSections.push(`=== RESPONSE BODY ===\n${JSON.stringify(JSON.parse(buf.toString()), null, 2)}`);
|
|
238
|
+
} catch {
|
|
239
|
+
logSections.push(`=== RESPONSE BODY (${buf.length} bytes) ===\n${buf.toString().slice(0, 8192)}`);
|
|
240
|
+
}
|
|
241
|
+
writeRequestLog(logDir, reqId, logSections);
|
|
242
|
+
}
|
|
243
|
+
res.end(buf);
|
|
244
|
+
}
|
|
245
|
+
} catch (err) {
|
|
246
|
+
console.error(`[TeamClaude] Upstream error (account "${account.name}"):`, err.message);
|
|
247
|
+
|
|
248
|
+
if (logDir) {
|
|
249
|
+
logSections.push(`=== ERROR ===\n${err.stack || err.message}`);
|
|
250
|
+
writeRequestLog(logDir, reqId, logSections);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (retryCount < maxRetries) {
|
|
254
|
+
account.status = 'error';
|
|
255
|
+
return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
|
|
256
|
+
}
|
|
257
|
+
ctx.status = 502;
|
|
258
|
+
|
|
259
|
+
if (!res.headersSent) {
|
|
260
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
261
|
+
res.end(JSON.stringify({
|
|
262
|
+
type: 'error',
|
|
263
|
+
error: { type: 'proxy_error', message: `Upstream error: ${err.message}` },
|
|
264
|
+
}));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Stream an SSE response to the client, parsing usage data along the way.
|
|
271
|
+
*/
|
|
272
|
+
async function streamResponse(webStream, res, accountIndex, accountManager, streamLog) {
|
|
273
|
+
const reader = webStream.getReader();
|
|
274
|
+
const decoder = new TextDecoder();
|
|
275
|
+
let sseBuffer = '';
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
while (true) {
|
|
279
|
+
const { done, value } = await reader.read();
|
|
280
|
+
if (done) break;
|
|
281
|
+
|
|
282
|
+
// Forward chunk immediately
|
|
283
|
+
const ok = res.write(value);
|
|
284
|
+
|
|
285
|
+
const text = decoder.decode(value, { stream: true });
|
|
286
|
+
|
|
287
|
+
// Capture for logging
|
|
288
|
+
if (streamLog) streamLog.push(text);
|
|
289
|
+
|
|
290
|
+
// Parse SSE events for usage tracking
|
|
291
|
+
sseBuffer += text;
|
|
292
|
+
const events = sseBuffer.split('\n\n');
|
|
293
|
+
sseBuffer = events.pop(); // keep incomplete event
|
|
294
|
+
|
|
295
|
+
for (const event of events) {
|
|
296
|
+
parseSSEUsage(event, accountIndex, accountManager);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Handle backpressure
|
|
300
|
+
if (!ok) {
|
|
301
|
+
await new Promise(resolve => res.once('drain', resolve));
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Parse any remaining buffer
|
|
306
|
+
if (sseBuffer.trim()) {
|
|
307
|
+
parseSSEUsage(sseBuffer, accountIndex, accountManager);
|
|
308
|
+
}
|
|
309
|
+
} finally {
|
|
310
|
+
res.end();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function parseSSEUsage(event, accountIndex, accountManager) {
|
|
315
|
+
const dataLine = event.split('\n').find(l => l.startsWith('data: '));
|
|
316
|
+
if (!dataLine) return;
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
const data = JSON.parse(dataLine.slice(6));
|
|
320
|
+
if (data.type === 'message_start' && data.message?.usage) {
|
|
321
|
+
accountManager.updateUsage(accountIndex, data.message.usage.input_tokens, 0);
|
|
322
|
+
} else if (data.type === 'message_delta' && data.usage) {
|
|
323
|
+
accountManager.updateUsage(accountIndex, 0, data.usage.output_tokens);
|
|
324
|
+
}
|
|
325
|
+
} catch {
|
|
326
|
+
// not valid JSON, skip
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function extractUsageFromBody(buffer, accountIndex, accountManager) {
|
|
331
|
+
try {
|
|
332
|
+
const json = JSON.parse(buffer.toString());
|
|
333
|
+
if (json.usage) {
|
|
334
|
+
accountManager.updateUsage(accountIndex, json.usage.input_tokens, json.usage.output_tokens);
|
|
335
|
+
}
|
|
336
|
+
} catch {
|
|
337
|
+
// not JSON or no usage
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function computeRetryAfter(accounts) {
|
|
342
|
+
let soonest = Infinity;
|
|
343
|
+
for (const acct of accounts) {
|
|
344
|
+
const reset = acct.rateLimitedUntil || acct.quota.resetsAt;
|
|
345
|
+
if (reset) {
|
|
346
|
+
const ms = new Date(reset).getTime() - Date.now();
|
|
347
|
+
if (ms < soonest) soonest = ms;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return soonest === Infinity ? 60 : Math.max(1, Math.ceil(soonest / 1000));
|
|
351
|
+
}
|