maxpool 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/src/oauth.js ADDED
@@ -0,0 +1,391 @@
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 { createInterface } from 'node:readline';
6
+ import http from 'node:http';
7
+
8
+ /**
9
+ * Import OAuth credentials from a Claude Code credentials file.
10
+ */
11
+ export async function importCredentials(filePath) {
12
+ const resolvedPath = filePath.replace(/^~/, homedir());
13
+ const raw = JSON.parse(await readFile(resolvedPath, 'utf-8'));
14
+
15
+ // Claude Code stores credentials nested under "claudeAiOauth"
16
+ const data = raw.claudeAiOauth || raw;
17
+ return {
18
+ accessToken: data.accessToken,
19
+ refreshToken: data.refreshToken,
20
+ expiresAt: data.expiresAt,
21
+ subscriptionType: data.subscriptionType,
22
+ rateLimitTier: data.rateLimitTier,
23
+ };
24
+ }
25
+
26
+ const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile';
27
+ const DEFAULT_TOKEN_ENDPOINT = 'https://platform.claude.com/v1/oauth/token';
28
+ const DEFAULT_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
29
+
30
+ /**
31
+ * Refresh an expired OAuth access token using the refresh token.
32
+ * Retries on 5xx and network errors with exponential backoff.
33
+ */
34
+ export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_ENDPOINT) {
35
+ const maxRetries = 2;
36
+ const baseDelayMs = 500;
37
+
38
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
39
+ try {
40
+ if (attempt > 0) {
41
+ const delay = baseDelayMs * 2 ** (attempt - 1);
42
+ await new Promise(resolve => setTimeout(resolve, delay));
43
+ }
44
+
45
+ const res = await fetch(endpoint, {
46
+ method: 'POST',
47
+ headers: {
48
+ 'Content-Type': 'application/json',
49
+ 'Accept': 'application/json, text/plain, */*',
50
+ 'User-Agent': 'axios/1.13.6',
51
+ },
52
+ body: JSON.stringify({
53
+ grant_type: 'refresh_token',
54
+ refresh_token: refreshToken,
55
+ client_id: DEFAULT_CLIENT_ID,
56
+ }),
57
+ });
58
+
59
+ if (!res.ok) {
60
+ if (res.status >= 500 && attempt < maxRetries) {
61
+ await res.body?.cancel();
62
+ continue;
63
+ }
64
+ const text = await res.text();
65
+ const error = new Error(`Token refresh failed (${res.status}): ${text}`);
66
+ error.status = res.status;
67
+ error.retryable = res.status === 429 || res.status >= 500;
68
+ throw error;
69
+ }
70
+
71
+ const data = await res.json();
72
+ return {
73
+ accessToken: data.access_token,
74
+ refreshToken: data.refresh_token || refreshToken,
75
+ expiresAt: normalizeExpiresAt(data.expires_at) || (Date.now() + (data.expires_in || 3600) * 1000),
76
+ };
77
+ } catch (err) {
78
+ const isNetworkError = err instanceof Error &&
79
+ (err.message.includes('fetch failed') ||
80
+ (err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED' ||
81
+ err.code === 'ETIMEDOUT' || err.code === 'UND_ERR_CONNECT_TIMEOUT'));
82
+
83
+ if (attempt < maxRetries && isNetworkError) {
84
+ continue;
85
+ }
86
+ if (isNetworkError) err.retryable = true;
87
+ throw err;
88
+ }
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Normalize an expires_at value to milliseconds.
94
+ * OAuth endpoints may return seconds; Claude Code credentials use milliseconds.
95
+ */
96
+ export function normalizeExpiresAt(expiresAt) {
97
+ if (!expiresAt) return expiresAt;
98
+ // If the value is plausibly in seconds (< 10^12 ≈ year 2001 in ms, year 33658 in s),
99
+ // convert to milliseconds
100
+ return expiresAt < 1e12 ? expiresAt * 1000 : expiresAt;
101
+ }
102
+
103
+ /**
104
+ * Check if an OAuth token is expiring within the given threshold.
105
+ */
106
+ export function isTokenExpiringSoon(expiresAt, thresholdMs = 5 * 60 * 1000) {
107
+ if (!expiresAt) return false;
108
+ return Date.now() + thresholdMs >= normalizeExpiresAt(expiresAt);
109
+ }
110
+
111
+ /**
112
+ * Fetch account profile for an OAuth token.
113
+ * Returns { email, name, orgName, orgType, ... } on success,
114
+ * or { error: 'reason' } on failure.
115
+ */
116
+ export async function fetchProfile(accessToken) {
117
+ try {
118
+ const res = await fetch(PROFILE_URL, {
119
+ headers: { 'Authorization': `Bearer ${accessToken}` },
120
+ });
121
+ if (!res.ok) {
122
+ let detail = '';
123
+ try {
124
+ const body = await res.json();
125
+ detail = body?.error?.message || JSON.stringify(body).slice(0, 200);
126
+ } catch {
127
+ detail = await res.text().catch(() => '');
128
+ }
129
+ return { error: `HTTP ${res.status}${detail ? ': ' + detail : ''}` };
130
+ }
131
+ const data = await res.json();
132
+ return {
133
+ accountUuid: data.account?.uuid,
134
+ email: data.account?.email,
135
+ name: data.account?.display_name,
136
+ orgName: data.organization?.name,
137
+ orgType: data.organization?.organization_type,
138
+ hasClaudeMax: data.account?.has_claude_max,
139
+ hasClaudePro: data.account?.has_claude_pro,
140
+ };
141
+ } catch (err) {
142
+ return { error: err.message || String(err) };
143
+ }
144
+ }
145
+
146
+ const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
147
+ const OAUTH_USAGE_BETA = 'oauth-2025-04-20';
148
+
149
+ /** Normalize one usage bucket from the /api/oauth/usage response to
150
+ * { utilization: 0..1, resetAt: ms-timestamp } (tolerant of field-name and
151
+ * percentage/epoch-vs-iso variations). */
152
+ export function normalizeUsageBucket(bucket) {
153
+ if (!bucket || typeof bucket !== 'object') return null;
154
+
155
+ const rawPct = bucket.used_percentage ?? bucket.utilization ?? bucket.usedPercentage;
156
+ const parsedPct = typeof rawPct === 'number' ? rawPct : parseFloat(rawPct);
157
+ const utilization = Number.isFinite(parsedPct)
158
+ ? (parsedPct > 1 ? parsedPct / 100 : parsedPct)
159
+ : null;
160
+
161
+ const rawReset = bucket.resets_at ?? bucket.resetsAt ?? bucket.reset_at ?? bucket.resetAt;
162
+ let resetAt = null;
163
+ if (typeof rawReset === 'number') {
164
+ resetAt = rawReset < 1e12 ? rawReset * 1000 : rawReset;
165
+ } else if (typeof rawReset === 'string') {
166
+ const asNum = Number(rawReset);
167
+ if (Number.isFinite(asNum) && rawReset.trim() !== '') {
168
+ resetAt = asNum < 1e12 ? asNum * 1000 : asNum;
169
+ } else {
170
+ const parsed = Date.parse(rawReset);
171
+ if (Number.isFinite(parsed)) resetAt = parsed;
172
+ }
173
+ }
174
+
175
+ return { utilization, resetAt };
176
+ }
177
+
178
+ /** Read an account's quota from the zero-spend /api/oauth/usage endpoint.
179
+ * Returns { fiveHour, sevenDay, sevenDaySonnet } buckets, or { error, status }. */
180
+ export async function fetchUsage(accessToken) {
181
+ try {
182
+ const res = await fetch(USAGE_URL, {
183
+ headers: {
184
+ 'Authorization': `Bearer ${accessToken}`,
185
+ 'anthropic-beta': OAUTH_USAGE_BETA,
186
+ 'Accept': 'application/json',
187
+ },
188
+ });
189
+
190
+ if (!res.ok) {
191
+ let detail = '';
192
+ try {
193
+ const body = await res.json();
194
+ detail = body?.error?.message || JSON.stringify(body).slice(0, 200);
195
+ } catch {
196
+ detail = await res.text().catch(() => '');
197
+ }
198
+ return { error: `HTTP ${res.status}${detail ? ': ' + detail : ''}`, status: res.status };
199
+ }
200
+
201
+ const data = await res.json();
202
+ return {
203
+ fiveHour: normalizeUsageBucket(data?.five_hour),
204
+ sevenDay: normalizeUsageBucket(data?.seven_day),
205
+ sevenDaySonnet: normalizeUsageBucket(data?.seven_day_sonnet),
206
+ };
207
+ } catch (err) {
208
+ return { error: err.message || String(err), status: null };
209
+ }
210
+ }
211
+
212
+ // OAuth config (extracted from Claude Code)
213
+ const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
214
+ const OAUTH_AUTHORIZE = 'https://claude.ai/oauth/authorize';
215
+ const OAUTH_TOKEN = 'https://platform.claude.com/v1/oauth/token';
216
+ const OAUTH_SCOPES = 'org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload';
217
+
218
+ /**
219
+ * Perform OAuth login via browser with PKCE flow.
220
+ * Opens the user's browser, waits for the callback, exchanges the code for tokens.
221
+ */
222
+ export async function loginOAuth() {
223
+ // Generate PKCE
224
+ const codeVerifier = randomBytes(32).toString('base64url');
225
+ const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
226
+ const state = randomBytes(32).toString('base64url');
227
+
228
+ // Start local callback server on a random port
229
+ const { port, codePromise, server } = await startCallbackServer(state);
230
+ const redirectUri = `http://localhost:${port}/callback`;
231
+
232
+ // Build authorization URL
233
+ const authUrl = new URL(OAUTH_AUTHORIZE);
234
+ authUrl.searchParams.set('code', 'true');
235
+ authUrl.searchParams.set('client_id', OAUTH_CLIENT_ID);
236
+ authUrl.searchParams.set('response_type', 'code');
237
+ authUrl.searchParams.set('redirect_uri', redirectUri);
238
+ authUrl.searchParams.set('scope', OAUTH_SCOPES);
239
+ authUrl.searchParams.set('code_challenge', codeChallenge);
240
+ authUrl.searchParams.set('code_challenge_method', 'S256');
241
+ authUrl.searchParams.set('state', state);
242
+
243
+ // Open browser
244
+ console.log('Opening browser for authentication...');
245
+ console.log(`If it doesn't open, visit:\n ${authUrl.toString()}\n`);
246
+ openBrowser(authUrl.toString());
247
+
248
+ // Wait for either the callback server or manual paste from stdin
249
+ let code;
250
+ try {
251
+ code = await raceWithStdinCode(codePromise, state);
252
+ } finally {
253
+ server.close();
254
+ }
255
+
256
+ // Exchange code for tokens
257
+ console.log('Exchanging authorization code for tokens...');
258
+ const tokenRes = await fetch(OAUTH_TOKEN, {
259
+ method: 'POST',
260
+ headers: { 'Content-Type': 'application/json' },
261
+ body: JSON.stringify({
262
+ code,
263
+ state,
264
+ grant_type: 'authorization_code',
265
+ client_id: OAUTH_CLIENT_ID,
266
+ redirect_uri: redirectUri,
267
+ code_verifier: codeVerifier,
268
+ }),
269
+ });
270
+
271
+ if (!tokenRes.ok) {
272
+ const text = await tokenRes.text();
273
+ throw new Error(`Token exchange failed (${tokenRes.status}): ${text}`);
274
+ }
275
+
276
+ const tokens = await tokenRes.json();
277
+ return {
278
+ accessToken: tokens.access_token,
279
+ refreshToken: tokens.refresh_token,
280
+ expiresAt: normalizeExpiresAt(tokens.expires_at) || (Date.now() + (tokens.expires_in || 3600) * 1000),
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Race the callback server promise against manual code entry from stdin.
286
+ * The user can paste the full callback URL or just the authorization code.
287
+ */
288
+ function raceWithStdinCode(callbackPromise, expectedState) {
289
+ if (!process.stdin.isTTY) return callbackPromise;
290
+
291
+ return new Promise((resolve, reject) => {
292
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
293
+ let settled = false;
294
+
295
+ const settle = (fn, val) => {
296
+ if (settled) return;
297
+ settled = true;
298
+ rl.close();
299
+ fn(val);
300
+ };
301
+
302
+ rl.question('Paste authorization code here (or wait for browser callback): ', answer => {
303
+ const trimmed = answer.trim();
304
+ if (!trimmed) return; // empty input, keep waiting for callback
305
+
306
+ // Try to parse as a URL with ?code= parameter
307
+ try {
308
+ const url = new URL(trimmed);
309
+ const code = url.searchParams.get('code');
310
+ const state = url.searchParams.get('state');
311
+ if (code) {
312
+ if (expectedState && state && state !== expectedState) {
313
+ settle(reject, new Error('OAuth state mismatch'));
314
+ } else {
315
+ settle(resolve, code);
316
+ }
317
+ return;
318
+ }
319
+ } catch {}
320
+
321
+ // Treat raw input as the authorization code
322
+ settle(resolve, trimmed);
323
+ });
324
+
325
+ callbackPromise.then(
326
+ code => settle(resolve, code),
327
+ err => settle(reject, err),
328
+ );
329
+ });
330
+ }
331
+
332
+ function startCallbackServer(expectedState) {
333
+ return new Promise((resolve, reject) => {
334
+ let resolveCode, rejectCode;
335
+ const codePromise = new Promise((res, rej) => { resolveCode = res; rejectCode = rej; });
336
+
337
+ const server = http.createServer((req, res) => {
338
+ const url = new URL(req.url, `http://localhost`);
339
+
340
+ if (url.pathname === '/callback') {
341
+ const code = url.searchParams.get('code');
342
+ const error = url.searchParams.get('error');
343
+ const state = url.searchParams.get('state');
344
+
345
+ if (error) {
346
+ res.writeHead(200, { 'Content-Type': 'text/html' });
347
+ res.end('<html><body><h2>Authentication failed</h2><p>You can close this tab.</p></body></html>');
348
+ rejectCode(new Error(`OAuth error: ${error} - ${url.searchParams.get('error_description') || ''}`));
349
+ return;
350
+ }
351
+
352
+ if (expectedState && state !== expectedState) {
353
+ res.writeHead(200, { 'Content-Type': 'text/html' });
354
+ res.end('<html><body><h2>Authentication failed</h2><p>State mismatch. You can close this tab.</p></body></html>');
355
+ rejectCode(new Error('OAuth state mismatch'));
356
+ return;
357
+ }
358
+
359
+ if (code) {
360
+ res.writeHead(302, { 'Location': 'https://platform.claude.com/oauth/code/success?app=claude-code' });
361
+ res.end();
362
+ resolveCode(code);
363
+ return;
364
+ }
365
+ }
366
+
367
+ res.writeHead(404);
368
+ res.end('Not found');
369
+ });
370
+
371
+ server.listen(0, 'localhost', () => {
372
+ resolve({ port: server.address().port, codePromise, server });
373
+ });
374
+ server.on('error', reject);
375
+
376
+ // Timeout after 2 minutes (unref so it doesn't keep the process alive)
377
+ const timer = setTimeout(() => {
378
+ rejectCode(new Error('Login timed out after 2 minutes'));
379
+ server.close();
380
+ }, 120_000);
381
+ timer.unref();
382
+ });
383
+ }
384
+
385
+ function openBrowser(url) {
386
+ const platform = process.platform;
387
+ const cmd = platform === 'darwin' ? 'open'
388
+ : platform === 'win32' ? 'start'
389
+ : 'xdg-open';
390
+ exec(`${cmd} ${JSON.stringify(url)}`, () => {});
391
+ }
package/src/prober.js ADDED
@@ -0,0 +1,82 @@
1
+ // Opt-in background quota probe.
2
+ //
3
+ // DISABLED BY DEFAULT. When enabled (config.quotaProbeSeconds > 0), periodically
4
+ // reads each OAuth account's quota from the zero-spend /api/oauth/usage endpoint
5
+ // so idle accounts' utilization/reset stay fresh without waiting to be rotated
6
+ // to — and without consuming any message quota. This is the one sanctioned
7
+ // active-upstream feature; the proxy is otherwise passive.
8
+
9
+ import { fetchUsage } from './oauth.js';
10
+
11
+ export class Prober {
12
+ constructor(accountManager, { intervalMs = 0, probeFn = fetchUsage, timeoutMs = 10_000, log = console.log } = {}) {
13
+ this.am = accountManager;
14
+ this.intervalMs = intervalMs;
15
+ this.probeFn = probeFn;
16
+ this.timeoutMs = timeoutMs;
17
+ this.log = log;
18
+ this.timer = null;
19
+ this._running = false;
20
+ }
21
+
22
+ start() {
23
+ if (this.intervalMs > 0) this.reschedule(this.intervalMs);
24
+ }
25
+
26
+ /** Change the interval at runtime (0 = off). Probes once immediately when on. */
27
+ reschedule(intervalMs) {
28
+ const wasOn = this.intervalMs > 0 && this.timer;
29
+ this.intervalMs = intervalMs;
30
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
31
+
32
+ if (intervalMs > 0) {
33
+ // Probe right away so quota populates without waiting a full cycle.
34
+ this.probeAll().catch(() => {});
35
+ this.timer = setInterval(() => this.probeAll().catch(() => {}), intervalMs);
36
+ this.timer.unref?.();
37
+ this.log(`[Maxpool] Quota probe enabled (every ${Math.round(intervalMs / 1000)}s)`);
38
+ } else if (wasOn) {
39
+ this.log('[Maxpool] Quota probe disabled');
40
+ }
41
+ }
42
+
43
+ stop() {
44
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
45
+ }
46
+
47
+ /** Probe every OAuth account once. Overlapping cycles are skipped. */
48
+ async probeAll() {
49
+ if (this._running) return;
50
+ this._running = true;
51
+ try {
52
+ const accounts = this.am.accounts.filter(a => a.type === 'oauth' && a.credential);
53
+ await Promise.all(accounts.map(a => this.probeOne(a)));
54
+ } finally {
55
+ this._running = false;
56
+ }
57
+ }
58
+
59
+ async probeOne(account) {
60
+ try {
61
+ await this.am.ensureTokenFresh(account.index);
62
+ let usage = await this._withTimeout(this.probeFn(account.credential));
63
+ if (usage?.status === 401) {
64
+ // Token rejected — force a refresh and retry once.
65
+ await this.am.ensureTokenFresh(account.index, true);
66
+ usage = await this._withTimeout(this.probeFn(account.credential));
67
+ }
68
+ if (!usage || usage.error) return; // transient — try again next cycle
69
+ this.am.applyUsageData(account.index, usage);
70
+ } catch { /* best-effort; never let a probe throw */ }
71
+ }
72
+
73
+ _withTimeout(promise) {
74
+ return Promise.race([
75
+ promise,
76
+ new Promise(resolve => {
77
+ const t = setTimeout(() => resolve(null), this.timeoutMs);
78
+ t.unref?.();
79
+ }),
80
+ ]);
81
+ }
82
+ }
@@ -0,0 +1,58 @@
1
+ const NON_UPSTREAM_ROUTES = new Set(['(queued)', '(none available)']);
2
+
3
+ export class RestartController {
4
+ constructor({ pauseAdmission, restartNow, log = console.log }) {
5
+ this.pauseAdmission = pauseAdmission;
6
+ this.restartNow = restartNow;
7
+ this.log = log;
8
+ this.activeRequests = new Set();
9
+ this.upstreamRequests = new Set();
10
+ this.pending = false;
11
+ this.restarting = false;
12
+ }
13
+
14
+ requestStarted(id) {
15
+ if (this.pending || this.restarting) return false;
16
+ this.activeRequests.add(id);
17
+ return true;
18
+ }
19
+
20
+ requestRouted(id, account) {
21
+ if (!this.activeRequests.has(id)) return false;
22
+ if (NON_UPSTREAM_ROUTES.has(account)) this.upstreamRequests.delete(id);
23
+ else this.upstreamRequests.add(id);
24
+ this._maybeRestart();
25
+ return true;
26
+ }
27
+
28
+ requestEnded(id) {
29
+ this.activeRequests.delete(id);
30
+ this.upstreamRequests.delete(id);
31
+ this._maybeRestart();
32
+ }
33
+
34
+ requestRestart() {
35
+ if (this.restarting) return;
36
+ this.pauseAdmission();
37
+ if (this.upstreamRequests.size === 0) {
38
+ this._restart();
39
+ return;
40
+ }
41
+
42
+ this.pending = true;
43
+ const queuedOrIdle = Math.max(0, this.activeRequests.size - this.upstreamRequests.size);
44
+ this.log(`[Maxpool] Restart pending; admission paused while ${this.upstreamRequests.size} upstream request(s) finish. ${queuedOrIdle} queued/idle request(s) will reconnect after restart.`);
45
+ }
46
+
47
+ _maybeRestart() {
48
+ if (!this.pending || this.restarting || this.upstreamRequests.size > 0) return;
49
+ this._restart();
50
+ }
51
+
52
+ _restart() {
53
+ if (this.restarting) return;
54
+ this.restarting = true;
55
+ this.pending = false;
56
+ this.restartNow();
57
+ }
58
+ }