maxpool 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": "maxpool",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Multi-account Claude Code proxy with adaptive, rate-aware load balancing across Claude accounts",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/config.js CHANGED
@@ -36,6 +36,12 @@ export function createDefaultConfig() {
36
36
  apiKey: 'mp-' + randomBytes(24).toString('base64url'),
37
37
  },
38
38
  upstream: 'https://api.anthropic.com',
39
+ // Per-account "stop using this account" gate, applied to BOTH the 5h
40
+ // session window and the 7d weekly window (whichever utilization is
41
+ // higher). 0.90 = stop routing to an account once it crosses 90% of a
42
+ // window, leaving a 10% safety margin so it is never hard-limited (429).
43
+ // Raise toward 0.97 to squeeze more out of accounts before rotating
44
+ // (less margin, slightly higher 429 risk); lower to rotate more eagerly.
39
45
  switchThreshold: 0.90,
40
46
  quotaProbeSeconds: 0, // background quota probe; 0 = off (opt-in)
41
47
  routing: {
@@ -48,11 +54,28 @@ export function createDefaultConfig() {
48
54
  safetyMaxGlobalActive: 150,
49
55
  cooldownMs: 30_000,
50
56
  maxCooldownMs: 15 * 60_000,
57
+ // Weekly (7d) quota tiers — how aggressively to de-prioritise an account
58
+ // as its weekly usage climbs. Each is a fraction (0..1) of the weekly
59
+ // limit. Below soft = full speed; soft..reserve = mild penalty;
60
+ // reserve..critical = heavy penalty; above exhausted = effectively
61
+ // parked until the weekly window resets. Tune to trade burst capacity
62
+ // against weekly-limit safety.
63
+ weeklySoftThreshold: 0.65,
64
+ weeklyReserveThreshold: 0.85,
65
+ weeklyCriticalThreshold: 0.95,
66
+ weeklyExhaustedThreshold: 0.985,
51
67
  },
52
68
  retry: {
53
69
  maxAttemptsPerRequest: 0,
54
70
  maxRetryBufferBytes: 10 * 1024 * 1024,
55
71
  },
72
+ // On quit (q / Ctrl-C / SIGTERM): stop accepting new requests, give
73
+ // in-flight requests up to drainTimeoutMs to finish, then force-exit.
74
+ // A second signal forces an immediate exit. Kept short so quit works
75
+ // under a continuous request flood instead of hanging.
76
+ shutdown: {
77
+ drainTimeoutMs: 15_000,
78
+ },
56
79
  queue: {
57
80
  enabled: true,
58
81
  maxWaitMs: 24 * 60 * 60 * 1000,
package/src/index.js CHANGED
@@ -193,7 +193,11 @@ async function serverWorkerCommand() {
193
193
  let syncTimer = null;
194
194
  let draining = false;
195
195
  let restartController = null;
196
- const drainTimeoutMs = Math.max(1000, Number(config.shutdown?.drainTimeoutMs) || 10 * 60_000);
196
+ // Quit drains in-flight requests, then force-exits. Kept short so a single
197
+ // 'q' / Ctrl-C / SIGTERM actually quits under a continuous request flood
198
+ // (where there are always active requests) instead of waiting indefinitely.
199
+ // A second signal forces an immediate exit. Override via config.shutdown.drainTimeoutMs.
200
+ const drainTimeoutMs = Math.max(1000, Number(config.shutdown?.drainTimeoutMs) || 15_000);
197
201
  const hooks = {
198
202
  onRequestStart: (id, info) => {
199
203
  const accepted = restartController.requestStarted(id);
@@ -242,7 +246,7 @@ async function serverWorkerCommand() {
242
246
  if (tui?.running) tui.stop();
243
247
 
244
248
  console.log(`\n[Maxpool] Draining shutdown (${reason}).`);
245
- console.log(`[Maxpool] Stopped accepting new requests; waiting for ${restartController.activeRequests.size} active request(s). Press Ctrl-C again to force.`);
249
+ console.log(`[Maxpool] Stopped accepting new requests; waiting up to ${Math.ceil(drainTimeoutMs / 1000)}s for ${restartController.activeRequests.size} active request(s), then forcing exit. Press Ctrl-C again to force now.`);
246
250
 
247
251
  let done = false;
248
252
  let reportTimer = null;
package/src/oauth.js CHANGED
@@ -1,19 +1,65 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { homedir } from 'node:os';
2
+ import { homedir, userInfo } from 'node:os';
3
3
  import { randomBytes, createHash } from 'node:crypto';
4
- import { exec } from 'node:child_process';
4
+ import { exec, execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
5
6
  import { createInterface } from 'node:readline';
6
7
  import http from 'node:http';
7
8
 
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ const KEYCHAIN_SERVICE = 'Claude Code-credentials';
12
+
8
13
  /**
9
- * Import OAuth credentials from a Claude Code credentials file.
14
+ * Read Claude Code credentials from the macOS Keychain.
15
+ * Claude Code (recent versions, macOS) stores OAuth creds in the login
16
+ * Keychain under service "Claude Code-credentials", account = the OS
17
+ * username — NOT in ~/.claude/.credentials.json. Returns the parsed
18
+ * credential object (unwrapped from "claudeAiOauth"), or null if absent.
10
19
  */
11
- export async function importCredentials(filePath) {
20
+ async function readMacKeychainCredentials() {
21
+ if (process.platform !== 'darwin') return null;
22
+ const account = userInfo().username;
23
+ try {
24
+ const { stdout } = await execFileAsync('security', [
25
+ 'find-generic-password', '-s', KEYCHAIN_SERVICE, '-a', account, '-w',
26
+ ]);
27
+ const raw = JSON.parse(stdout.trim());
28
+ return raw.claudeAiOauth || raw;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Import OAuth credentials from a Claude Code credentials file, falling back
36
+ * to the macOS Keychain when the file is absent (the default on macOS).
37
+ */
38
+ export async function importCredentials(filePath = '~/.claude/.credentials.json') {
12
39
  const resolvedPath = filePath.replace(/^~/, homedir());
13
- const raw = JSON.parse(await readFile(resolvedPath, 'utf-8'));
14
40
 
15
- // Claude Code stores credentials nested under "claudeAiOauth"
16
- const data = raw.claudeAiOauth || raw;
41
+ let data;
42
+ try {
43
+ const raw = JSON.parse(await readFile(resolvedPath, 'utf-8'));
44
+ // Claude Code stores credentials nested under "claudeAiOauth"
45
+ data = raw.claudeAiOauth || raw;
46
+ } catch (fileErr) {
47
+ // No file → try the macOS Keychain (where Claude Code now stores creds).
48
+ data = await readMacKeychainCredentials();
49
+ if (!data) {
50
+ throw new Error(
51
+ process.platform === 'darwin'
52
+ ? `No credentials at ${resolvedPath} and none in the macOS Keychain ` +
53
+ `("${KEYCHAIN_SERVICE}"). Is Claude Code logged in on this machine? ` +
54
+ `Run 'claude' once to log in, or paste a token with 'maxpool import --json ...'.`
55
+ : `Could not read credentials from ${resolvedPath}: ${fileErr.message}`,
56
+ );
57
+ }
58
+ }
59
+
60
+ if (!data.accessToken) {
61
+ throw new Error('Imported credentials have no accessToken');
62
+ }
17
63
  return {
18
64
  accessToken: data.accessToken,
19
65
  refreshToken: data.refreshToken,