maxpool 1.0.6 → 1.1.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 CHANGED
@@ -6,6 +6,19 @@ Sits transparently between Claude Code and the Anthropic API, managing multiple
6
6
 
7
7
  ![Maxpool TUI](screenshots/maxpool.png)
8
8
 
9
+ ## ⚠️ Read this first: account risk and Anthropic's terms
10
+
11
+ This tool sits in a **contested gray area** of Anthropic's terms. Using it could put your Claude accounts at risk. Please read before installing.
12
+
13
+ - **Not blessed by Anthropic.** This is an independent project. Anthropic has not approved multi-account proxying, and nothing here is covered by an official "this is allowed" statement.
14
+ - **The terms can be read to prohibit it.** Anthropic's Consumer Terms (Section 3, automated access) and its February 2026 rule that subscription OAuth tokens are "only authorized for use with Claude Code" can reasonably be read to cover a proxy that selects and injects tokens across accounts. It is genuinely ambiguous, and the ambiguity is not in your favor.
15
+ - **Multiplexing is a detectable pattern.** Many accounts fronted by one machine, with parallel sessions and fast token rotation, looks like the anomaly Anthropic's automated systems flag, regardless of intent. People have lost accounts to pattern detection, and a clawback can hit all your pooled accounts at once.
16
+ - **Owned accounts only. No spoofing.** Only use accounts you personally own and pay for. Never share accounts across people (that is the clear violation), and never add IP/residential proxies, fingerprint spoofing, or MITM. Those are the documented ban triggers, and this fork deliberately leaves them out. PRs adding them will be rejected.
17
+ - **You are accepting the risk.** If you use this, you may lose accounts you pay for. This notice protects honesty, not your account.
18
+ - **Do your own research. This is not legal advice.** The terms have moved several times in 2026. Check the current terms yourself before relying on it.
19
+
20
+ If you want certainty, ask Anthropic support directly, or use **API-key accounts** (the sanctioned path for tooling) instead of subscription OAuth.
21
+
9
22
  ## Features
10
23
 
11
24
  - **Rate-aware load balancing** — ranks accounts by remaining quota ÷ time-to-reset, not raw usage %, so a near-reset account with quota left is drained (use-it-or-lose-it) and one burning fast early in its window is spared; sequential traffic rotates across accounts instead of funnelling onto one
@@ -286,8 +299,11 @@ TEAMCLAUDE_CONFIG=./my-config.json maxpool server
286
299
  "maxWaitMs": 86400000,
287
300
  "autoMaxWaitMs": null,
288
301
  "capacityMaxWaitMs": 900000,
302
+ "weeklyMaxWaitMs": 86400000,
303
+ "nonStreamMaxWaitMs": 300000,
304
+ "maxConcurrentQueued": 64,
305
+ "maxQueuedBytes": 1073741824,
289
306
  "maxQueuedBodyBytes": 268435456,
290
- "weeklyMaxWaitMs": 0,
291
307
  "pollMs": 1000
292
308
  },
293
309
  "shutdown": {
@@ -325,7 +341,10 @@ TEAMCLAUDE_CONFIG=./my-config.json maxpool server
325
341
  | `queue.autoMaxWaitMs` | Optional shorter auto-queue cap. Set to `null` or omit it to use `queue.maxWaitMs`; set a number for interactive sessions where you prefer fast errors |
326
342
  | `queue.capacityMaxWaitMs` | Separate cap for repeated upstream 5xx/overload failures; defaults to 15m so broken providers do not park requests for 24h |
327
343
  | `queue.maxQueuedBodyBytes` | Maximum request body Maxpool will hold in memory while waiting for capacity before the request has been sent upstream; defaults to 256 MiB |
328
- | `queue.weeklyMaxWaitMs` | Optional cap for weekly-limit waits. Defaults to `0`, so weekly exhaustion fails fast instead of parking requests for days |
344
+ | `queue.weeklyMaxWaitMs` | How long to hold a request when every account is at its weekly (7d) cap. Defaults to 24h — but the early-exit gates on each account's REAL reset time, so it only waits when a reset genuinely lands inside the window and errors honestly otherwise. (Was `0` = fail-fast, which killed sessions the instant all accounts hit their weekly cap.) |
345
+ | `queue.nonStreamMaxWaitMs` | Max hold for non-streaming requests; defaults to 5m. They have no SSE keepalive, so a longer hold would die on the client timeout anyway |
346
+ | `queue.maxConcurrentQueued` | Backpressure: max requests held waiting at once; defaults to 64. Beyond it, new waiters get a clear "queue full" error instead of growing the heap |
347
+ | `queue.maxQueuedBytes` | Backpressure: max aggregate buffered request-body bytes across all held requests; defaults to 1 GiB |
329
348
  | `queue.pollMs` | How often queued requests check for a recovered account/provider |
330
349
  | `queue.heartbeatMs` | SSE heartbeat interval for queued streaming requests; defaults to 10s so Claude Code keeps the queued connection alive |
331
350
  | `shutdown.drainTimeoutMs` | Maximum time quit/Ctrl-C waits for active requests before exiting |
@@ -355,7 +374,7 @@ The weekly usage bar shows raw upstream utilization and reset timing. Reset-awar
355
374
  11. In the `all` profile only, if all Claude accounts are unavailable, provider fallbacks are tried by priority: GLM before Kimi
356
375
  12. If all eligible accounts/providers are temporarily unavailable for a temporary reason (5h/session limit, provider cooldown, short 429), the proxy queues the request and retries when one recovers
357
376
  13. Repeated upstream 5xx/overload failures use the shorter `capacityMaxWaitMs` cap, not the long quota wait
358
- 14. Weekly exhaustion and non-retryable 4xx errors fail fast by default; if the queue wait expires, returns 429 with the soonest retry time
377
+ 14. Weekly (7d) exhaustion is held and retried like a 5h cap, up to `weeklyMaxWaitMs`, but only when an account's real reset lands inside the window; if the soonest reset is beyond it (or unknown), it returns 429 promptly with an honest message naming the soonest reset rather than spinning. Non-retryable 4xx errors still fail fast
359
378
  15. Temporary OAuth refresh failures cool the account down and queue/fail over; invalid refresh credentials disable only that account and require login
360
379
  16. In an interactive terminal, the server runs under a foreground supervisor so confirmed Restart (`r`) can drain and restart without detaching the replacement TUI
361
380
  17. When Restart is confirmed, new upstream admission pauses immediately. Existing upstream requests finish, queued requests cannot deadlock restart, and their sockets close during relaunch so Claude Code reconnects automatically
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.0.6",
3
+ "version": "1.1.0",
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",
@@ -173,6 +173,7 @@ export class AccountManager {
173
173
  waiting: [],
174
174
  lastAdmissionAt: 0,
175
175
  rampUntil: 0,
176
+ bytes: 0, // aggregate buffered body bytes across all held requests
176
177
  };
177
178
  this.admissionPaused = false;
178
179
  }
@@ -197,6 +198,7 @@ export class AccountManager {
197
198
  let soonestTemporary = Infinity;
198
199
  let temporaryCause = null;
199
200
  let soonestWeekly = Infinity;
201
+ let weeklyUnknownReset = 0; // weekly-exhausted accounts whose reset time we don't know yet
200
202
  let matchingRoutes = 0;
201
203
  const reasons = {};
202
204
 
@@ -244,6 +246,11 @@ export class AccountManager {
244
246
  } else if (retry.cause === 'weekly_exhausted' && retry.retryAt) {
245
247
  const ms = retry.retryAt - Date.now();
246
248
  if (ms < soonestWeekly) soonestWeekly = ms;
249
+ } else if (retry.cause === 'weekly_exhausted' && !retry.retryAt) {
250
+ // Weekly-capped but we haven't learned the reset time (cold start /
251
+ // probe failure). We cannot estimate a wait — flag it so the caller
252
+ // emits an honest "reset time unknown" error instead of waiting forever.
253
+ weeklyUnknownReset++;
247
254
  }
248
255
  }
249
256
 
@@ -267,6 +274,16 @@ export class AccountManager {
267
274
  };
268
275
  }
269
276
 
277
+ if (weeklyUnknownReset > 0) {
278
+ return {
279
+ available: false,
280
+ retryAfterMs: Infinity,
281
+ cause: 'weekly_reset_unknown',
282
+ reasons,
283
+ matchingRoutes,
284
+ };
285
+ }
286
+
270
287
  return {
271
288
  available: false,
272
289
  retryAfterMs: Infinity,
@@ -574,13 +591,41 @@ export class AccountManager {
574
591
  });
575
592
  }
576
593
 
577
- registerQueuedRequest(requestInfo = {}) {
594
+ // Drop wedged head tickets (deadline passed or explicitly marked dead) so a
595
+ // single orphaned waiter cannot block every other request behind it. Cheap;
596
+ // safe to call before every head check.
597
+ _reapStaleQueueHead() {
598
+ const q = this.queueState;
599
+ const now = Date.now();
600
+ let guard = 0;
601
+ while (q.waiting.length && guard++ < 10_000) {
602
+ const head = q.waiting[0];
603
+ const stale = head.dead === true || (head.deadlineAt && now > head.deadlineAt);
604
+ if (!stale) break;
605
+ q.waiting.shift();
606
+ q.bytes = Math.max(0, q.bytes - (head.bytes || 0));
607
+ }
608
+ }
609
+
610
+ // Register a waiter. Returns the ticket, or null if a backpressure limit
611
+ // (maxConcurrentQueued / maxQueuedBytes) would be exceeded — the caller then
612
+ // rejects the request with a "queue full" error instead of holding it.
613
+ registerQueuedRequest(requestInfo = {}, opts = {}) {
578
614
  if (requestInfo.queueTicket) return requestInfo.queueTicket;
615
+ this._reapStaleQueueHead();
616
+ const bytes = Math.max(0, Number(opts.bytes) || 0);
617
+ const { maxConcurrentQueued, maxQueuedBytes } = opts;
618
+ if (maxConcurrentQueued != null && this.queueState.waiting.length >= maxConcurrentQueued) return null;
619
+ if (maxQueuedBytes != null && this.queueState.waiting.length > 0
620
+ && this.queueState.bytes + bytes > maxQueuedBytes) return null;
579
621
  const ticket = {
580
622
  id: this.queueState.nextId++,
581
623
  queuedAt: Date.now(),
624
+ bytes,
625
+ deadlineAt: opts.deadlineAt || null,
582
626
  };
583
627
  this.queueState.waiting.push(ticket);
628
+ this.queueState.bytes += bytes;
584
629
  requestInfo.queueTicket = ticket;
585
630
  return ticket;
586
631
  }
@@ -588,10 +633,12 @@ export class AccountManager {
588
633
  canAdmitQueuedRequest(requestInfo = {}) {
589
634
  const ticket = requestInfo.queueTicket;
590
635
  if (!ticket) return true;
636
+ this._reapStaleQueueHead();
591
637
  if (this.queueState.waiting[0]?.id !== ticket.id) return false;
592
638
  const now = Date.now();
593
639
  if (now < this.queueState.rampUntil && now - this.queueState.lastAdmissionAt < 250) return false;
594
640
  this.queueState.waiting.shift();
641
+ this.queueState.bytes = Math.max(0, this.queueState.bytes - (ticket.bytes || 0));
595
642
  this.queueState.lastAdmissionAt = now;
596
643
  requestInfo.queueTicket = null;
597
644
  requestInfo.queueAdmitted = true;
@@ -602,7 +649,10 @@ export class AccountManager {
602
649
  const ticket = requestInfo.queueTicket;
603
650
  if (!ticket) return;
604
651
  const index = this.queueState.waiting.findIndex(entry => entry.id === ticket.id);
605
- if (index >= 0) this.queueState.waiting.splice(index, 1);
652
+ if (index >= 0) {
653
+ this.queueState.waiting.splice(index, 1);
654
+ this.queueState.bytes = Math.max(0, this.queueState.bytes - (ticket.bytes || 0));
655
+ }
606
656
  requestInfo.queueTicket = null;
607
657
  }
608
658
 
package/src/config.js CHANGED
@@ -81,13 +81,20 @@ export function createDefaultConfig() {
81
81
  shutdown: {
82
82
  drainTimeoutMs: 15_000,
83
83
  },
84
+ // When every account is rate-limited, hold the request and retry until one
85
+ // frees up, instead of erroring and killing the session. Only error if
86
+ // nothing recovers within the window below (the early-exit gates on each
87
+ // account's REAL reset time, so a generous bound never spins pointlessly).
84
88
  queue: {
85
89
  enabled: true,
86
- maxWaitMs: 24 * 60 * 60 * 1000,
87
- autoMaxWaitMs: null,
88
- capacityMaxWaitMs: 15 * 60 * 1000,
89
- maxQueuedBodyBytes: 256 * 1024 * 1024,
90
- weeklyMaxWaitMs: 0,
90
+ maxWaitMs: 24 * 60 * 60 * 1000, // hard ceiling for any hold
91
+ autoMaxWaitMs: null, // 5h/session-cap hold (null = maxWaitMs)
92
+ capacityMaxWaitMs: 15 * 60 * 1000, // upstream 529/overload — stays short, never governed by the others
93
+ weeklyMaxWaitMs: 24 * 60 * 60 * 1000, // weekly (7d) cap hold; was 0 (fail-fast) — that killed sessions on weekly cap
94
+ nonStreamMaxWaitMs: 5 * 60 * 1000, // non-streaming requests have no keepalive; cap their wait
95
+ maxConcurrentQueued: 64, // backpressure: max requests held at once
96
+ maxQueuedBytes: 1024 * 1024 * 1024, // backpressure: max aggregate buffered body bytes (1 GiB)
97
+ maxQueuedBodyBytes: 256 * 1024 * 1024, // per-request cap on a queueable body
91
98
  pollMs: 1000,
92
99
  heartbeatMs: 10_000,
93
100
  },
package/src/server.js CHANGED
@@ -19,7 +19,19 @@ const DEFAULT_QUEUE = {
19
19
  maxWaitMs: 24 * 60 * 60 * 1000,
20
20
  autoMaxWaitMs: null,
21
21
  capacityMaxWaitMs: 15 * 60 * 1000,
22
- weeklyMaxWaitMs: 0,
22
+ // Weekly (7d) cap hold. A generous bound is SAFE because the early-exit
23
+ // gates on the REAL reset time (unified7dReset - now): it only waits when a
24
+ // reset genuinely lands inside the window, and errors honestly otherwise.
25
+ // 0 here was the bug — it fail-fast-killed sessions the instant every
26
+ // account hit its weekly cap, instead of waiting for the soonest reset.
27
+ weeklyMaxWaitMs: 24 * 60 * 60 * 1000,
28
+ // Non-streaming requests have no SSE heartbeat to keep them alive, so a long
29
+ // hold would die on the client timeout anyway. Cap their wait conservatively.
30
+ nonStreamMaxWaitMs: 5 * 60 * 1000,
31
+ // Backpressure: holds used to be 0ms, now they can be hours. Bound the queue
32
+ // so 22 retrying agents can't grow the heap without limit.
33
+ maxConcurrentQueued: 64,
34
+ maxQueuedBytes: 1024 * 1024 * 1024, // 1 GiB aggregate across all held bodies
23
35
  pollMs: 1000,
24
36
  heartbeatMs: 10_000,
25
37
  };
@@ -825,6 +837,14 @@ function hasEligibleRoute(accountManager, requestInfo = {}, excludedIndexes = ne
825
837
  return accountManager.hasAvailableRoute?.(requestInfo, excludedIndexes) || false;
826
838
  }
827
839
 
840
+ function formatRetryDuration(seconds) {
841
+ const s = Math.max(0, Math.round(Number(seconds) || 0));
842
+ if (s >= 86400) return `${Math.round(s / 86400)}d`;
843
+ if (s >= 3600) return `${Math.round(s / 3600)}h`;
844
+ if (s >= 60) return `${Math.round(s / 60)}m`;
845
+ return `${s}s`;
846
+ }
847
+
828
848
  function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRecoverSoon = true) {
829
849
  const thinking = requestInfo.requiresAnthropicThinkingIntegrity
830
850
  || accountManager._requiresAnthropicThinkingIntegrity?.(requestInfo);
@@ -834,7 +854,10 @@ function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRe
834
854
  // account is at its own 5h/weekly limit. A short "retry in Ns" would be a lie;
835
855
  // tell the user the real fix.
836
856
  if (!willRecoverSoon) {
837
- const base = `No Claude account can take this request — all ${n} are at their 5h or weekly limit. Add another Claude account or wait for a quota reset.`;
857
+ const eta = Number.isFinite(retryAfter) && retryAfter > 0
858
+ ? ` Soonest reset in ~${formatRetryDuration(retryAfter)}, beyond the hold window.`
859
+ : '';
860
+ const base = `No Claude account can take this request — all ${n} are at their 5h or weekly limit.${eta} Add another Claude account or wait for a quota reset.`;
838
861
  return thinking
839
862
  ? `${base} GLM/Kimi fallback is unavailable because this session contains Anthropic signed thinking blocks; start a fresh non-thinking session to use them.`
840
863
  : base;
@@ -1062,29 +1085,64 @@ async function queueAndRetry(
1062
1085
  ? autoMaxWaitMs
1063
1086
  : Math.max(0, Number(queueConfig.capacityMaxWaitMs) || 0);
1064
1087
  const weeklyMaxWaitMs = Math.max(0, Number(queueConfig.weeklyMaxWaitMs) || 0);
1088
+ const nonStreamMaxWaitMs = queueConfig.nonStreamMaxWaitMs == null
1089
+ ? 5 * 60_000
1090
+ : Math.max(0, Number(queueConfig.nonStreamMaxWaitMs) || 0);
1065
1091
  const retryPlan = accountManager.nextRetryForRequest?.(requestInfo, new Set()) || {
1066
1092
  retryAfterMs: Infinity,
1067
1093
  cause: 'unavailable',
1068
1094
  };
1069
- const queueWindowMs = retryPlan.cause === 'weekly_exhausted'
1070
- ? Math.min(maxWaitMs, weeklyMaxWaitMs)
1071
- : cause === 'capacity'
1072
- ? Math.min(maxWaitMs, capacityMaxWaitMs)
1073
- : Math.min(maxWaitMs, autoMaxWaitMs);
1074
- if (queueWindowMs <= 0) return finishQueuedStreamIfNeeded(res, requestInfo, 'No retry window is available.');
1095
+
1096
+ // Honest, cause-/thinking-aware message used for every give-up path below.
1097
+ const honestMessage = unavailableMessage(
1098
+ accountManager, requestInfo,
1099
+ Math.ceil((Number.isFinite(retryPlan.retryAfterMs) ? retryPlan.retryAfterMs : 0) / 1000),
1100
+ false,
1101
+ );
1102
+
1103
+ // Weekly-capped but the reset time is unknown (cold start / probe failure):
1104
+ // we can't estimate a wait, so don't pretend to — error honestly now.
1105
+ if (retryPlan.cause === 'weekly_reset_unknown') {
1106
+ return finishQueuedStreamIfNeeded(res, requestInfo, honestMessage);
1107
+ }
1108
+
1109
+ // Pick the wait window. Capacity (upstream 529/overload) MUST stay on its own
1110
+ // short cap even when it coincides with weekly exhaustion — never let a
1111
+ // transient overload inherit the long weekly bound.
1112
+ let queueWindowMs = cause === 'capacity'
1113
+ ? Math.min(maxWaitMs, capacityMaxWaitMs)
1114
+ : retryPlan.cause === 'weekly_exhausted'
1115
+ ? Math.min(maxWaitMs, weeklyMaxWaitMs)
1116
+ : Math.min(maxWaitMs, autoMaxWaitMs);
1117
+ // Non-streaming requests have no SSE heartbeat, so a long hold would die on
1118
+ // the client timeout. Cap them so we never promise a wait we can't deliver.
1119
+ if (!requestInfo.stream) queueWindowMs = Math.min(queueWindowMs, nonStreamMaxWaitMs);
1120
+
1121
+ if (queueWindowMs <= 0) return finishQueuedStreamIfNeeded(res, requestInfo, honestMessage);
1075
1122
 
1076
1123
  const retryAfterMs = retryPlan.retryAfterMs;
1077
1124
  if (!Number.isFinite(retryAfterMs) || retryAfterMs > queueWindowMs) {
1078
- return finishQueuedStreamIfNeeded(res, requestInfo, 'No route is expected to recover within the configured queue window.');
1125
+ return finishQueuedStreamIfNeeded(res, requestInfo, honestMessage);
1079
1126
  }
1080
1127
 
1081
1128
  requestInfo.queueStartedAt ||= Date.now();
1082
- accountManager.registerQueuedRequest?.(requestInfo);
1129
+ const ticket = accountManager.registerQueuedRequest?.(requestInfo, {
1130
+ bytes: body?.length || 0,
1131
+ deadlineAt: requestInfo.queueStartedAt + queueWindowMs,
1132
+ maxConcurrentQueued: queueConfig.maxConcurrentQueued,
1133
+ maxQueuedBytes: queueConfig.maxQueuedBytes,
1134
+ });
1135
+ if (ticket === null) {
1136
+ // Backpressure: too many requests already waiting / too many bytes buffered.
1137
+ // Reject honestly instead of growing the heap unbounded.
1138
+ return finishQueuedStreamIfNeeded(res, requestInfo,
1139
+ 'Maxpool queue is full — too many requests are already waiting for capacity. Try again shortly.');
1140
+ }
1083
1141
  const elapsed = Date.now() - requestInfo.queueStartedAt;
1084
1142
  const remaining = queueWindowMs - elapsed;
1085
1143
  if (remaining <= 0) {
1086
1144
  accountManager.removeQueuedRequest?.(requestInfo);
1087
- return finishQueuedStreamIfNeeded(res, requestInfo, 'The Maxpool queue wait expired.');
1145
+ return finishQueuedStreamIfNeeded(res, requestInfo, honestMessage);
1088
1146
  }
1089
1147
 
1090
1148
  ctx.account = '(queued)';
@@ -1096,7 +1154,7 @@ async function queueAndRetry(
1096
1154
  if (!available) {
1097
1155
  if (res.destroyed || req.destroyed) return true;
1098
1156
  accountManager.removeQueuedRequest?.(requestInfo);
1099
- return finishQueuedStreamIfNeeded(res, requestInfo, 'The Maxpool queue wait expired.');
1157
+ return finishQueuedStreamIfNeeded(res, requestInfo, honestMessage);
1100
1158
  }
1101
1159
 
1102
1160
  return forwardRequest(
@@ -1163,7 +1221,10 @@ async function waitForAvailableRoute(req, res, accountManager, requestInfo, queu
1163
1221
  ) return true;
1164
1222
 
1165
1223
  const remaining = maxWaitMs - (Date.now() - startedAt);
1166
- await sleep(Math.min(pollMs, remaining));
1224
+ // Jitter the poll so a synchronized weekly-reset event doesn't re-align
1225
+ // every waiter's poll into the same instant (thundering scan).
1226
+ const jittered = pollMs * (0.8 + Math.random() * 0.4);
1227
+ await sleep(Math.min(jittered, remaining));
1167
1228
  }
1168
1229
 
1169
1230
  return accountManager.hasAvailableRoute(requestInfo, new Set())