maxpool 1.5.22 → 1.5.24

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.5.22",
3
+ "version": "1.5.24",
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",
@@ -37,6 +37,6 @@
37
37
  "url": "https://github.com/2solarmax/maxpool/issues"
38
38
  },
39
39
  "engines": {
40
- "node": ">=18.0.0"
40
+ "node": ">=20.3.0"
41
41
  }
42
42
  }
@@ -2276,6 +2276,11 @@ export class AccountManager {
2276
2276
  // for the bounded drain; the lease holder owns all rotation.
2277
2277
  if (!this.writerLease) return true;
2278
2278
 
2279
+ // A dead refresh token (invalid_grant) is PERMANENT until browser re-auth —
2280
+ // never auto-retry it. Without this the prober re-POSTs the rejected token every
2281
+ // ~60s forever (hammering Anthropic's OAuth endpoint). Cleared on re-login.
2282
+ if (account.refreshDead) return false;
2283
+
2279
2284
  if (!force && !isTokenExpiringSoon(account.expiresAt)) return true;
2280
2285
 
2281
2286
  // Coalesce concurrent refreshes
@@ -2309,6 +2314,11 @@ export class AccountManager {
2309
2314
  this.markTransientFailure(accountIndex, `token_refresh_${err.status || 'network'}`, { network: !err.status });
2310
2315
  } else {
2311
2316
  this.markAuthFailed(accountIndex, err.status || 401, 'token_refresh_failed');
2317
+ // The refresh TOKEN itself was rejected (invalid_grant) — permanent until
2318
+ // browser re-auth. Latch so ensureTokenFresh + the prober stop retrying a
2319
+ // dead token. Set ONLY here (a rejected refresh), never in markAuthFailed
2320
+ // (shared with provider auth failures).
2321
+ account.refreshDead = true;
2312
2322
  }
2313
2323
  return false;
2314
2324
  }
@@ -2350,6 +2360,7 @@ export class AccountManager {
2350
2360
  account.credential = accessToken;
2351
2361
  if (refreshToken) account.refreshToken = refreshToken;
2352
2362
  account.expiresAt = expiresAt;
2363
+ account.refreshDead = false; // fresh tokens from re-auth revive a dead-refresh account
2353
2364
  if (account.status === 'error') account.status = 'active';
2354
2365
  console.log(`[Maxpool] Updated tokens for account "${account.name}"`);
2355
2366
  this._onTokenRefresh?.(accountIndex, {
@@ -2434,6 +2445,7 @@ export class AccountManager {
2434
2445
  account.lastError = null;
2435
2446
  account.lastErrorAt = null;
2436
2447
  account.consecutiveFailures = 0;
2448
+ account.refreshDead = false;
2437
2449
  }
2438
2450
  return idx;
2439
2451
  }
@@ -2575,6 +2587,7 @@ export class AccountManager {
2575
2587
  priority: a.priority,
2576
2588
  runtime: a.runtime,
2577
2589
  status: a.status,
2590
+ refreshDead: Boolean(a.refreshDead),
2578
2591
  inFlight: a.inFlight,
2579
2592
  activeWeight: a.activeWeight,
2580
2593
  completedRequests: a.completedRequests,
package/src/oauth.js CHANGED
@@ -268,14 +268,92 @@ export function classifyZaiLimit(l, now = Date.now()) {
268
268
  return { bucket, utilization, resetAt };
269
269
  }
270
270
 
271
- /** Read a provider account's quota. z.ai has a pollable monitor endpoint mapped
272
- * to Ses/Wk token windows; Kimi (Moonshot coding key) has NO pollable quota
273
- * (web console only), so it returns a `console-only` marker instead of fake
274
- * bars. Returns { ses, wk, level } | { error, status?, source? }. */
271
+ // Kimi Coding Plan usage — the coding key (sk-kimi-…) reads its own quota at
272
+ // <base>/v1/usages (the community `kimi-code-usage` tool's endpoint). NOTE the
273
+ // User-Agent: the endpoint is served by the Kimi Code platform, not the Open
274
+ // Platform (whose /users/me/balance 401s a coding key). Best-effort, non-load-
275
+ // bearing UA — the failure path degrades to last-known + the staleness marker.
276
+ const KIMI_USAGE_UA = 'KimiCLI/1.6';
277
+
278
+ /** ms of one Kimi limit `window`, normalized from its timeUnit so the SHORTEST
279
+ * window (the 5h session) is picked regardless of unit. null when unknown. */
280
+ export function kimiWindowMs(window) {
281
+ const dur = Number(window?.duration);
282
+ if (!Number.isFinite(dur) || dur <= 0) return null;
283
+ const unit = String(window?.timeUnit || window?.time_unit || '').toUpperCase();
284
+ const mult = unit.includes('SECOND') ? 1e3
285
+ : unit.includes('MINUTE') ? 60e3
286
+ : unit.includes('HOUR') ? 3600e3
287
+ : unit.includes('DAY') ? 86400e3
288
+ : unit.includes('WEEK') ? 7 * 86400e3
289
+ : unit.includes('MONTH') ? 30 * 86400e3
290
+ : null;
291
+ return mult == null ? null : dur * mult;
292
+ }
293
+
294
+ /** Normalize a Kimi usage row {limit,used,remaining,resetTime} (values are STRINGS)
295
+ * to { utilization, resetAt }. CRITICAL: utilization is `null` — never a computed
296
+ * NaN — when the inputs are absent or limit<=0, because clamp01(NaN)=0 downstream
297
+ * would render a blind account as "0% used = fully available" and route real
298
+ * traffic onto quota we can't see. */
299
+ export function parseKimiRow(d) {
300
+ if (!d || typeof d !== 'object') return null;
301
+ const limit = Number(d.limit ?? d.limit_amount);
302
+ let used = d.used != null ? Number(d.used ?? d.used_amount) : null;
303
+ const remaining = d.remaining != null ? Number(d.remaining) : null;
304
+ if (used == null && remaining != null && Number.isFinite(limit)) used = limit - remaining;
305
+ const utilization = (Number.isFinite(limit) && limit > 0 && used != null && Number.isFinite(used))
306
+ ? Math.max(0, Math.min(1, used / limit))
307
+ : null;
308
+ const resetRaw = d.resetTime || d.reset_at || d.reset_time;
309
+ const parsed = resetRaw ? Date.parse(resetRaw) : NaN;
310
+ return { utilization, resetAt: Number.isFinite(parsed) ? parsed : null };
311
+ }
312
+
313
+ /** Read the Kimi Coding Plan quota → { ses, wk, source:'kimi' } | { error, status? }.
314
+ * Weekly = the top-level `usage` rolling window; Ses (5h) = the shortest `limits[]`
315
+ * window. A transient/partial response returns {error} or null buckets so
316
+ * applyProviderUsage keeps the last-known values (never a phantom 0%). */
317
+ export async function fetchKimiUsage(account) {
318
+ const token = account?.credential;
319
+ if (!token) return { error: 'unsupported', source: null };
320
+ const base = String(account.upstream || 'https://api.kimi.com/coding').replace(/\/+$/, '');
321
+ const headers = { 'Authorization': `Bearer ${token}`, 'User-Agent': KIMI_USAGE_UA, 'Accept': 'application/json' };
322
+ try {
323
+ let res = await fetch(`${base}/v1/usages`, { headers, signal: AbortSignal.timeout(10_000) });
324
+ if (res.status === 404) res = await fetch(`${base}/v1/usage`, { headers, signal: AbortSignal.timeout(10_000) });
325
+ if (!res.ok) return { error: `HTTP ${res.status}`, status: res.status };
326
+ const data = await res.json();
327
+ // Weekly = top-level `usage` (ALWAYS present in a valid coding-plan response).
328
+ // If it's missing, the response is partial/malformed → return an error so
329
+ // applyProviderUsage keeps the last-known weekly instead of the falsy-wk branch
330
+ // BLANKING it (unlike z.ai, where a missing weekly genuinely means "no weekly").
331
+ const wkRow = parseKimiRow(data?.usage);
332
+ if (!wkRow || wkRow.utilization == null) return { error: 'partial_response', source: 'kimi' };
333
+ const wk = wkRow;
334
+ // Ses = the shortest limits[] window (the 5h). Fall back to first if durations unknown.
335
+ let ses = null, sesMs = Infinity;
336
+ for (const l of (Array.isArray(data?.limits) ? data.limits : [])) {
337
+ const detail = l && typeof l.detail === 'object' ? l.detail : l;
338
+ const row = parseKimiRow(detail);
339
+ if (!row || row.utilization == null) continue;
340
+ const ms = kimiWindowMs(l?.window);
341
+ if (ms != null && ms < sesMs) { sesMs = ms; ses = row; }
342
+ else if (ses == null && ms == null) ses = row;
343
+ }
344
+ return { ses, wk, source: 'kimi', level: data?.user?.membership?.level || null };
345
+ } catch (err) {
346
+ return { error: err.message || String(err), status: null };
347
+ }
348
+ }
349
+
350
+ /** Read a provider account's quota. z.ai and Kimi both have a pollable coding-plan
351
+ * usage endpoint mapped to Ses/Wk windows. Returns { ses, wk, source } |
352
+ * { error, status?, source? }. */
275
353
  export async function fetchProviderUsage(account) {
276
354
  const provider = account?.provider;
277
355
  const token = account?.credential;
278
- if (provider === 'kimi') return { error: 'unsupported', source: 'console-only' };
356
+ if (provider === 'kimi') return fetchKimiUsage(account);
279
357
  if (provider !== 'zai' || !token) return { error: 'unsupported', source: null };
280
358
  try {
281
359
  const res = await fetch(ZAI_QUOTA_URL, {
package/src/prober.js CHANGED
@@ -59,7 +59,9 @@ export class Prober {
59
59
  this._running = true;
60
60
  this._inflight = (async () => {
61
61
  try {
62
- const oauth = this.am.accounts.filter(a => a.type === 'oauth' && a.credential);
62
+ // Skip auth-dead accounts (dead refresh token): probing them just re-POSTs
63
+ // the rejected token every cycle — a 400 storm. They recover only on re-auth.
64
+ const oauth = this.am.accounts.filter(a => a.type === 'oauth' && a.credential && !a.refreshDead);
63
65
  const providers = this.am.accounts.filter(a => a.type === 'provider' && a.credential);
64
66
  await Promise.all([
65
67
  ...oauth.map(a => this.probeOne(a)),
package/src/server.js CHANGED
@@ -15,6 +15,16 @@ const DEFAULT_RETRY = {
15
15
  maxRetryBufferBytes: 10 * 1024 * 1024,
16
16
  };
17
17
 
18
+ // Upstream-hang guards. A half-open upstream (headers then silence, never closes)
19
+ // would block the request forever and LEAK its in-flight lease — the account climbs
20
+ // to safetyMaxActivePerAccount and is falsely "full", breaking routing (the "106
21
+ // phantom active" incident). Env-overridable with conservative defaults: this path
22
+ // serves EVERY provider (Anthropic/GLM/Kimi), so the idle gap is generous enough
23
+ // that a legitimately-slow-but-alive stream is never cut (each chunk resets it).
24
+ const UPSTREAM_TTFB_MS = Math.max(5_000, Number(process.env.MAXPOOL_TTFB_MS) || 120_000); // headers must arrive within this
25
+ const STREAM_IDLE_MS = Math.max(30_000, Number(process.env.MAXPOOL_STREAM_IDLE_MS) || 300_000); // max gap BETWEEN streamed chunks (reset per chunk)
26
+ const UPSTREAM_BODY_MS = Math.max(30_000, Number(process.env.MAXPOOL_BODY_MS) || 300_000); // non-streaming body read
27
+
18
28
  const DEFAULT_QUEUE = {
19
29
  enabled: true,
20
30
  maxWaitMs: 24 * 60 * 60 * 1000,
@@ -429,14 +439,31 @@ async function forwardRequest(
429
439
  }
430
440
  }
431
441
 
442
+ // TTFB guard: an upstream that accepts the connection but never sends response
443
+ // headers would hang `await fetch` forever (signal only covers client-disconnect)
444
+ // and leak the lease. Abort via a DEDICATED controller (so the caller can tell a
445
+ // TTFB timeout apart from a client-gone abort) and CLEAR it the moment headers
446
+ // arrive — the streaming body is then governed by the per-chunk idle guard, never
447
+ // this fixed clock, so a long healthy stream is never cut.
448
+ const ttfbController = new AbortController();
449
+ const ttfbTimer = setTimeout(
450
+ () => ttfbController.abort(Object.assign(new Error('upstream TTFB timeout'), { code: 'UPSTREAM_TTFB' })),
451
+ UPSTREAM_TTFB_MS,
452
+ );
453
+ ttfbTimer.unref?.();
432
454
  try {
433
- const upstreamRes = await fetch(upstreamUrl, {
434
- method,
435
- headers,
436
- body: ['GET', 'HEAD'].includes(method) ? undefined : upstreamBody,
437
- redirect: 'manual',
438
- signal: clientGone.signal,
439
- });
455
+ let upstreamRes;
456
+ try {
457
+ upstreamRes = await fetch(upstreamUrl, {
458
+ method,
459
+ headers,
460
+ body: ['GET', 'HEAD'].includes(method) ? undefined : upstreamBody,
461
+ redirect: 'manual',
462
+ signal: AbortSignal.any([clientGone.signal, ttfbController.signal]),
463
+ });
464
+ } finally {
465
+ clearTimeout(ttfbTimer);
466
+ }
440
467
  // Response arrived — the pre-response leak window is over. Stop guarding for
441
468
  // client-disconnect via abort (streamResponse handles mid-stream disconnects).
442
469
  res.off('close', onClientClose);
@@ -840,7 +867,26 @@ async function forwardRequest(
840
867
  writeRequestLog(logDir, reqId, logSections);
841
868
  }
842
869
  } else {
843
- const buf = Buffer.from(await upstreamRes.arrayBuffer());
870
+ // Bound the non-streaming body read so a mid-body upstream stall can't hang
871
+ // the request forever and leak the lease (same class as the streaming idle
872
+ // guard). On timeout → UPSTREAM_BODY → the caller frees the lease.
873
+ const bodyP = upstreamRes.arrayBuffer();
874
+ bodyP.catch(() => {});
875
+ let bodyTimer;
876
+ const bodyTimeout = new Promise((_, reject) => {
877
+ bodyTimer = setTimeout(
878
+ () => reject(Object.assign(new Error('upstream body timeout'), { code: 'UPSTREAM_BODY' })),
879
+ UPSTREAM_BODY_MS,
880
+ );
881
+ bodyTimer.unref?.();
882
+ });
883
+ let arr;
884
+ try {
885
+ arr = await Promise.race([bodyP, bodyTimeout]);
886
+ } finally {
887
+ clearTimeout(bodyTimer);
888
+ }
889
+ const buf = Buffer.from(arr);
844
890
  extractUsageFromBody(buf, account.index, accountManager);
845
891
  markThinkingFromResponse(buf, accountManager, requestInfo);
846
892
  accountManager.releaseAccount(lease, { success: upstreamRes.status < 500, status: upstreamRes.status });
@@ -896,6 +942,11 @@ async function forwardRequest(
896
942
  (err.message.includes('fetch failed') ||
897
943
  err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED' ||
898
944
  err.code === 'ETIMEDOUT' || err.code === 'UND_ERR_CONNECT_TIMEOUT' ||
945
+ // Upstream-hang guards: a stalled connection is network-class, not the
946
+ // account's fault → 5s cooldown + release + (pre-headers) retry elsewhere;
947
+ // once committed, isTransient falls through to sendErrorResponse which ends
948
+ // the client's SSE cleanly (frees the lease AND unhangs the client).
949
+ err.code === 'UPSTREAM_TTFB' || err.code === 'UPSTREAM_IDLE' || err.code === 'UPSTREAM_BODY' ||
899
950
  err.message.includes('terminated'));
900
951
 
901
952
  if (isTransient) {
@@ -1045,7 +1096,7 @@ function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRe
1045
1096
  return `All ${n} accounts exhausted. Retry in ${retryAfter}s.`;
1046
1097
  }
1047
1098
 
1048
- export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, describeRequest, classifyRateLimit, detectTranscriptOrigin, isAnthropicIncompatBody };
1099
+ export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, describeRequest, classifyRateLimit, detectTranscriptOrigin, isAnthropicIncompatBody, streamResponse };
1049
1100
 
1050
1101
  async function readErrorBody(upstreamRes, limitBytes = 64 * 1024) {
1051
1102
  if (!upstreamRes.body) return '';
@@ -1696,7 +1747,7 @@ function mappedModel(originalModel, account) {
1696
1747
  /**
1697
1748
  * Stream an SSE response to the client, parsing usage data along the way.
1698
1749
  */
1699
- async function streamResponse(webStream, res, status, responseHeaders, accountIndex, accountManager, streamLog, requestInfo = {}) {
1750
+ async function streamResponse(webStream, res, status, responseHeaders, accountIndex, accountManager, streamLog, requestInfo = {}, idleMs = STREAM_IDLE_MS) {
1700
1751
  const reader = webStream.getReader();
1701
1752
  const decoder = new TextDecoder();
1702
1753
  let sseBuffer = '';
@@ -1711,9 +1762,39 @@ async function streamResponse(webStream, res, status, responseHeaders, accountIn
1711
1762
  // pre-byte failover can still re-hold the session via queueAndRetry.
1712
1763
  clearQueueHeartbeat(requestInfo);
1713
1764
 
1765
+ // Client left mid-stream: the caller drops its disconnect-abort once headers
1766
+ // arrive, and the loop below only checks res.destroyed AFTER a read resolves — so
1767
+ // a BLOCKED read() would never notice. Cancel the reader on 'close' to unblock it
1768
+ // (resolves the pending read as done) → the loop breaks → the lease frees instead
1769
+ // of leaking until the (maybe never) upstream close.
1770
+ const onClose = () => { reader.cancel().catch(() => {}); };
1771
+ res.once('close', onClose);
1772
+
1714
1773
  try {
1715
1774
  while (true) {
1716
- const { done, value } = await reader.read();
1775
+ // Idle guard: a half-open upstream (headers, then silence, never closes) would
1776
+ // block reader.read() forever → the whole request handler hangs → neither the
1777
+ // lease nor onRequestEnd is released (the "106 phantom active" leak). Bound each
1778
+ // read by a per-chunk idle timeout, RESET every chunk so a healthy slow stream
1779
+ // is never cut; on a real stall, UPSTREAM_IDLE → the caller frees the lease and
1780
+ // ends the client SSE cleanly (isTransient → sendErrorResponse).
1781
+ const readP = reader.read();
1782
+ readP.catch(() => {}); // race-loser must not surface as an unhandled rejection
1783
+ let idleTimer;
1784
+ const idle = new Promise((_, reject) => {
1785
+ idleTimer = setTimeout(
1786
+ () => reject(Object.assign(new Error('upstream idle timeout'), { code: 'UPSTREAM_IDLE' })),
1787
+ idleMs,
1788
+ );
1789
+ idleTimer.unref?.();
1790
+ });
1791
+ let result;
1792
+ try {
1793
+ result = await Promise.race([readP, idle]);
1794
+ } finally {
1795
+ clearTimeout(idleTimer); // single live timer at a time — no per-chunk timer accumulation
1796
+ }
1797
+ const { done, value } = result;
1717
1798
  if (done) break;
1718
1799
 
1719
1800
  // Client disconnected — stop reading from upstream
@@ -1760,6 +1841,7 @@ async function streamResponse(webStream, res, status, responseHeaders, accountIn
1760
1841
  readFailed = true;
1761
1842
  throw err;
1762
1843
  } finally {
1844
+ res.off('close', onClose);
1763
1845
  // Cancel upstream reader to stop consuming data nobody needs
1764
1846
  reader.cancel().catch(() => {});
1765
1847
  if (!readFailed) {
package/src/tui.js CHANGED
@@ -664,6 +664,10 @@ export class TUI {
664
664
  amAcct.expiresAt = creds.expiresAt;
665
665
  amAcct.accountUuid = entry.accountUuid;
666
666
  amAcct.name = name;
667
+ // Fresh tokens from re-auth revive a dead-refresh account. This in-place
668
+ // update is the actual re-login path (the index.js reconcile sees no change
669
+ // and never calls updateAccountTokens), so refreshDead MUST be cleared here.
670
+ amAcct.refreshDead = false;
667
671
  if (amAcct.status === 'error') amAcct.status = 'active';
668
672
  }
669
673
  this._addLog(`Updated account "${name}"`);
@@ -975,8 +979,13 @@ export class TUI {
975
979
  if (a.enabled !== false && a.status === 'active' && this.am._isAccountWideRejected?.(a)) {
976
980
  effectiveStatus = 'blocked';
977
981
  }
982
+ // A dead refresh token surfaces as "reauth" (re-login needed) rather than a
983
+ // generic "error", so the user knows the fix. Display-only — account.status
984
+ // stays 'error' so routing/eligibility still exclude it.
985
+ if (a.enabled !== false && a.refreshDead) effectiveStatus = 'reauth';
978
986
  switch (effectiveStatus) {
979
987
  case 'active': status = isCur ? green('active') : 'active'; break;
988
+ case 'reauth': status = yellow('reauth'); break;
980
989
  case 'blocked': status = red('blocked'); break;
981
990
  case 'probing': status = green('probing'); break;
982
991
  case 'waiting': status = yellow('waiting'); break;