@karpeleslab/teamclaude 1.0.0 → 1.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karpeleslab/teamclaude",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Multi-account Claude proxy with automatic quota-based rotation",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -267,6 +267,46 @@ export class AccountManager {
267
267
  this._onTokenRefresh = callback;
268
268
  }
269
269
 
270
+ /**
271
+ * Capture a fresh token from a client request or intercepted token refresh.
272
+ * Updates the first OAuth account whose credential matches the old token,
273
+ * or the first expired/error OAuth account if none match.
274
+ *
275
+ * @param {string} accessToken - The new access token
276
+ * @param {string} [refreshToken] - New refresh token (if available from intercepted refresh)
277
+ * @param {number} [expiresAt] - Token expiry timestamp
278
+ */
279
+ captureClientToken(accessToken, refreshToken, expiresAt) {
280
+ if (!accessToken) return;
281
+
282
+ // Check if any account already has this exact token
283
+ const existing = this.accounts.find(a => a.type === 'oauth' && a.credential === accessToken);
284
+ if (existing) {
285
+ // Update expiry/refresh if we have better info
286
+ if (expiresAt && expiresAt > (existing.expiresAt || 0)) existing.expiresAt = expiresAt;
287
+ if (refreshToken) existing.refreshToken = refreshToken;
288
+ return;
289
+ }
290
+
291
+ // Find the best OAuth account to update: prefer expired/error accounts
292
+ const candidate = this.accounts.find(a =>
293
+ a.type === 'oauth' && (a.status === 'error' || isTokenExpiringSoon(a.expiresAt, 0))
294
+ ) || this.accounts.find(a => a.type === 'oauth');
295
+
296
+ if (!candidate) return;
297
+
298
+ candidate.credential = accessToken;
299
+ if (refreshToken) candidate.refreshToken = refreshToken;
300
+ candidate.expiresAt = expiresAt || Date.now() + 3600 * 1000;
301
+ if (candidate.status === 'error') candidate.status = 'active';
302
+ console.log(`[TeamClaude] Captured fresh token for account "${candidate.name}"`);
303
+ this._onTokenRefresh?.(candidate.index, {
304
+ accessToken,
305
+ refreshToken: candidate.refreshToken,
306
+ expiresAt: candidate.expiresAt,
307
+ });
308
+ }
309
+
270
310
  /**
271
311
  * Add a new account at runtime.
272
312
  */
package/src/index.js CHANGED
@@ -266,12 +266,14 @@ async function runCommand() {
266
266
  const claudeArgs = args.slice(1);
267
267
  if (claudeArgs[0] === '--') claudeArgs.shift();
268
268
 
269
+ // Only set ANTHROPIC_BASE_URL — Claude Code keeps its own OAuth token
270
+ // which the proxy accepts from localhost. Not setting ANTHROPIC_API_KEY
271
+ // lets Claude Code stay in subscription mode (full model access).
269
272
  const child = spawn('claude', claudeArgs, {
270
273
  stdio: 'inherit',
271
274
  env: {
272
275
  ...process.env,
273
276
  ANTHROPIC_BASE_URL: `http://localhost:${config.proxy.port}`,
274
- ANTHROPIC_API_KEY: config.proxy.apiKey,
275
277
  },
276
278
  });
277
279
 
@@ -332,6 +334,7 @@ async function statusCommand() {
332
334
 
333
335
  async function accountsCommand() {
334
336
  const config = await loadOrCreateConfig();
337
+ const verbose = args.includes('-v') || args.includes('--verbose');
335
338
 
336
339
  if (config.accounts.length === 0) {
337
340
  console.log('No accounts configured.');
@@ -387,6 +390,17 @@ async function accountsCommand() {
387
390
  console.log(` [${i + 1}] ${a.name} (${status}${src})`);
388
391
  if (p?.email && p.email !== a.name) console.log(` Email: ${p.email}`);
389
392
  if (p?.orgName) console.log(` Org: ${p.orgName}`);
393
+ if (verbose && a.expiresAt) {
394
+ const remaining = a.expiresAt - Date.now();
395
+ if (remaining <= 0) {
396
+ console.log(` Token: expired`);
397
+ } else {
398
+ const mins = Math.floor(remaining / 60000);
399
+ const hrs = Math.floor(mins / 60);
400
+ const expiry = hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
401
+ console.log(` Token: expires in ${expiry}`);
402
+ }
403
+ }
390
404
  }
391
405
  }
392
406
 
package/src/oauth.js CHANGED
@@ -28,29 +28,60 @@ const DEFAULT_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
28
28
 
29
29
  /**
30
30
  * Refresh an expired OAuth access token using the refresh token.
31
+ * Retries on 5xx and network errors with exponential backoff.
31
32
  */
32
33
  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
- });
34
+ const maxRetries = 2;
35
+ const baseDelayMs = 500;
36
+
37
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
38
+ try {
39
+ if (attempt > 0) {
40
+ const delay = baseDelayMs * 2 ** (attempt - 1);
41
+ await new Promise(resolve => setTimeout(resolve, delay));
42
+ }
42
43
 
43
- if (!res.ok) {
44
- const text = await res.text();
45
- throw new Error(`Token refresh failed (${res.status}): ${text}`);
46
- }
44
+ const res = await fetch(endpoint, {
45
+ method: 'POST',
46
+ headers: {
47
+ 'Content-Type': 'application/json',
48
+ 'Accept': 'application/json, text/plain, */*',
49
+ 'User-Agent': 'axios/1.13.6',
50
+ },
51
+ body: JSON.stringify({
52
+ grant_type: 'refresh_token',
53
+ refresh_token: refreshToken,
54
+ client_id: DEFAULT_CLIENT_ID,
55
+ }),
56
+ });
57
+
58
+ if (!res.ok) {
59
+ if (res.status >= 500 && attempt < maxRetries) {
60
+ await res.body?.cancel();
61
+ continue;
62
+ }
63
+ const text = await res.text();
64
+ throw new Error(`Token refresh failed (${res.status}): ${text}`);
65
+ }
47
66
 
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
- };
67
+ const data = await res.json();
68
+ return {
69
+ accessToken: data.access_token,
70
+ refreshToken: data.refresh_token || refreshToken,
71
+ expiresAt: data.expires_at || (Date.now() + (data.expires_in || 3600) * 1000),
72
+ };
73
+ } catch (err) {
74
+ const isNetworkError = err instanceof Error &&
75
+ (err.message.includes('fetch failed') ||
76
+ (err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED' ||
77
+ err.code === 'ETIMEDOUT' || err.code === 'UND_ERR_CONNECT_TIMEOUT'));
78
+
79
+ if (attempt < maxRetries && isNetworkError) {
80
+ continue;
81
+ }
82
+ throw err;
83
+ }
84
+ }
54
85
  }
55
86
 
56
87
  /**
package/src/server.js CHANGED
@@ -19,9 +19,11 @@ export function createProxyServer(accountManager, config, hooks = {}) {
19
19
 
20
20
  const server = http.createServer(async (req, res) => {
21
21
  try {
22
- // Auth check
22
+ // Auth check — skip for localhost connections
23
23
  const clientKey = req.headers['x-api-key'];
24
- if (proxyApiKey && clientKey !== proxyApiKey) {
24
+ const remoteAddr = req.socket.remoteAddress;
25
+ const isLocal = remoteAddr === '127.0.0.1' || remoteAddr === '::1' || remoteAddr === '::ffff:127.0.0.1';
26
+ if (proxyApiKey && clientKey !== proxyApiKey && !isLocal) {
25
27
  res.writeHead(401, { 'Content-Type': 'application/json' });
26
28
  res.end(JSON.stringify({
27
29
  type: 'error',
@@ -37,6 +39,12 @@ export function createProxyServer(accountManager, config, hooks = {}) {
37
39
  return;
38
40
  }
39
41
 
42
+ // Intercept token refresh requests — forward to real endpoint and capture new tokens
43
+ if (req.method === 'POST' && req.url === '/v1/oauth/token') {
44
+ await handleTokenRefresh(req, res, accountManager, hooks);
45
+ return;
46
+ }
47
+
40
48
  // Track request
41
49
  const reqId = ++requestCounter;
42
50
  hooks.onRequestStart?.(reqId, { method: req.method, path: req.url });
@@ -70,6 +78,64 @@ export function createProxyServer(accountManager, config, hooks = {}) {
70
78
  return server;
71
79
  }
72
80
 
81
+ const TOKEN_ENDPOINT = 'https://platform.claude.com/v1/oauth/token';
82
+
83
+ /**
84
+ * Forward a token refresh request to the real token endpoint, capture new tokens,
85
+ * and pass the response back to the client.
86
+ */
87
+ async function handleTokenRefresh(req, res, accountManager, hooks) {
88
+ const bodyChunks = [];
89
+ for await (const chunk of req) {
90
+ bodyChunks.push(chunk);
91
+ }
92
+ const body = Buffer.concat(bodyChunks);
93
+
94
+ try {
95
+ // Forward to the real token endpoint
96
+ const upstreamRes = await fetch(TOKEN_ENDPOINT, {
97
+ method: 'POST',
98
+ headers: {
99
+ 'Content-Type': req.headers['content-type'] || 'application/json',
100
+ 'Accept': 'application/json, text/plain, */*',
101
+ 'User-Agent': req.headers['user-agent'] || 'axios/1.13.6',
102
+ },
103
+ body,
104
+ });
105
+
106
+ const responseBody = await upstreamRes.text();
107
+
108
+ // Capture tokens from successful refresh
109
+ if (upstreamRes.ok) {
110
+ try {
111
+ const tokens = JSON.parse(responseBody);
112
+ if (tokens.access_token) {
113
+ accountManager.captureClientToken(tokens.access_token, tokens.refresh_token,
114
+ tokens.expires_at || (Date.now() + (tokens.expires_in || 3600) * 1000));
115
+ }
116
+ } catch {}
117
+ }
118
+
119
+ // Forward response to client
120
+ const responseHeaders = {};
121
+ for (const [key, value] of upstreamRes.headers.entries()) {
122
+ if (key === 'transfer-encoding' || key === 'connection') continue;
123
+ responseHeaders[key] = value;
124
+ }
125
+ res.writeHead(upstreamRes.status, responseHeaders);
126
+ res.end(responseBody);
127
+ } catch (err) {
128
+ console.error('[TeamClaude] Token refresh proxy error:', err.message);
129
+ if (!res.headersSent) {
130
+ res.writeHead(502, { 'Content-Type': 'application/json' });
131
+ res.end(JSON.stringify({
132
+ type: 'error',
133
+ error: { type: 'proxy_error', message: `Token refresh failed: ${err.message}` },
134
+ }));
135
+ }
136
+ }
137
+ }
138
+
73
139
  function logTimestamp() {
74
140
  const d = new Date();
75
141
  const pad = (n, w = 2) => String(n).padStart(w, '0');
@@ -101,6 +167,7 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
101
167
  const account = accountManager.getActiveAccount();
102
168
  if (!account) {
103
169
  ctx.status = 429;
170
+ ctx.account = '(none available)';
104
171
  const status = accountManager.getStatus();
105
172
  const retryAfter = computeRetryAfter(status.accounts);
106
173
  res.writeHead(429, {
@@ -128,6 +195,7 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
128
195
  }
129
196
 
130
197
  // Build upstream request headers
198
+ const isOAuth = account.type === 'oauth';
131
199
  const headers = {};
132
200
  for (const [key, value] of Object.entries(req.headers)) {
133
201
  const lk = key.toLowerCase();
@@ -138,7 +206,18 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
138
206
  if (lk === 'accept-encoding') continue;
139
207
  headers[key] = value;
140
208
  }
141
- headers['x-api-key'] = account.credential;
209
+
210
+ if (isOAuth) {
211
+ headers['authorization'] = `Bearer ${account.credential}`;
212
+ } else {
213
+ headers['x-api-key'] = account.credential;
214
+ }
215
+
216
+ // Capture fresh Bearer tokens from the client to keep stored credentials up to date
217
+ const clientBearer = req.headers['authorization']?.match(/^Bearer (.+)/i)?.[1];
218
+ if (clientBearer) {
219
+ accountManager.captureClientToken(clientBearer);
220
+ }
142
221
 
143
222
  const upstreamUrl = `${upstream}${req.url}`;
144
223
  const method = req.method;
@@ -147,10 +226,13 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
147
226
  const logSections = [];
148
227
  if (logDir) {
149
228
  const safeHeaders = { ...headers };
150
- // Mask the credential in logs
229
+ // Mask credentials in logs
151
230
  if (safeHeaders['x-api-key']) {
152
231
  safeHeaders['x-api-key'] = safeHeaders['x-api-key'].slice(0, 15) + '...';
153
232
  }
233
+ if (safeHeaders['authorization']) {
234
+ safeHeaders['authorization'] = safeHeaders['authorization'].slice(0, 20) + '...';
235
+ }
154
236
  logSections.push(
155
237
  `=== REQUEST (account: ${account.name}, retry: ${retryCount}) ===\n${method} ${upstreamUrl}\n${formatHeaders(safeHeaders)}`,
156
238
  );
@@ -185,19 +267,6 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
185
267
  logSections.push(`=== RESPONSE ${upstreamRes.status} ===\n${formatHeaders(upstreamRes.headers)}`);
186
268
  }
187
269
 
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
270
  ctx.status = upstreamRes.status;
202
271
 
203
272
  // Build response headers (skip hop-by-hop and encoding headers)