maxpool 1.5.34 → 1.5.36

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.34",
3
+ "version": "1.5.36",
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/server.js CHANGED
@@ -25,6 +25,11 @@ const UPSTREAM_TTFB_MS = Math.max(5_000, Number(process.env.MAXPOOL_TTFB_MS) ||
25
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
26
  const UPSTREAM_BODY_MS = Math.max(30_000, Number(process.env.MAXPOOL_BODY_MS) || 300_000); // non-streaming body read
27
27
  const CLIENT_DRAIN_MS = Math.max(5_000, Number(process.env.MAXPOOL_DRAIN_MS) || 60_000); // max wait for a backpressured client to drain (half-open client → free the lease)
28
+ // A provider 403 is (unlike a 401) almost always transient QUOTA/PLAN exhaustion — cool
29
+ // the provider down RECOVERABLY for this window, then re-probe, instead of permanently
30
+ // disabling it. Short (not reset-length) so a provider-pinned session's hold window
31
+ // isn't blown, and a still-exhausted provider just re-benches on the next single retry.
32
+ const PROVIDER_FORBIDDEN_COOLDOWN_SEC = Math.max(60, Number(process.env.MAXPOOL_PROVIDER_403_COOLDOWN_SEC) || 1800);
28
33
  // Backstop reaper idle ceiling. Floored WELL above the longest a legit NON-streaming
29
34
  // request can go silent (no heartbeat there): TTFB 120s + nonStreamMaxWaitMs 300s +
30
35
  // UPSTREAM_BODY_MS 300s ≈ 720s — so a slow-but-alive non-streaming request is never
@@ -84,6 +89,11 @@ export function createProxyServer(accountManager, config, hooks = {}) {
84
89
 
85
90
  const server = http.createServer(async (req, res) => {
86
91
  try {
92
+ // Disable Nagle on the client socket: flush each SSE event immediately instead
93
+ // of coalescing small writes into bursts. Smooths streaming cadence toward the
94
+ // HTTP/2-direct shape, reducing how often a client terminal's re-render churns
95
+ // on long streams. Same underlying socket as res, so this covers the write path.
96
+ try { req.socket?.setNoDelay(true); } catch { /* socket may already be gone */ }
87
97
  try { res.setHeader('x-maxpool-worker', workerStamp); } catch { /* headers sent */ }
88
98
  if (draining) {
89
99
  try { res.setHeader('Connection', 'close'); } catch { /* headers may be sent */ }
@@ -654,16 +664,27 @@ async function forwardRequest(
654
664
 
655
665
  if (account.type === 'provider' && isProviderAuthStatus(upstreamRes.status)) {
656
666
  const errorBody = await readErrorBody(upstreamRes);
657
- const reason = upstreamRes.status === 401 ? 'auth_failed' : 'forbidden';
658
- accountManager.markAuthFailed(account.index, upstreamRes.status, reason);
659
- accountManager.releaseAccount(lease, { status: upstreamRes.status, error: reason });
667
+ const forbidden = upstreamRes.status === 403;
668
+ if (forbidden) {
669
+ // 403 = almost always QUOTA/PLAN exhaustion (e.g. Kimi Coding-Plan weekly maxed),
670
+ // NOT bad credentials — the usage probe still succeeds with the same key. Cool it
671
+ // down RECOVERABLY (rides the rate-limit recovery in _isAvailable → auto-un-benches
672
+ // at expiry) instead of the permanent auth-disable that stranded it until restart.
673
+ // `neutral` release so a transient quota-403 doesn't bump failure/scoring counters.
674
+ accountManager.markRateLimited(account.index, PROVIDER_FORBIDDEN_COOLDOWN_SEC, { status: 403, recordFailure: false });
675
+ accountManager.releaseAccount(lease, { neutral: true });
676
+ } else {
677
+ // 401 = genuinely bad/missing credentials → permanent disable (needs re-auth).
678
+ accountManager.markAuthFailed(account.index, upstreamRes.status, 'auth_failed');
679
+ accountManager.releaseAccount(lease, { status: upstreamRes.status, error: 'auth_failed' });
680
+ }
660
681
  excludedIndexes.add(account.index);
661
682
 
662
683
  if (logDir) {
663
- logSections.push(`=== RESPONSE ${upstreamRes.status} — "${account.name}" disabled (${reason}), failing over ===\n${formatHeaders(upstreamRes.headers)}`);
684
+ logSections.push(`=== RESPONSE ${upstreamRes.status} — "${account.name}" ${forbidden ? `cooled down ${PROVIDER_FORBIDDEN_COOLDOWN_SEC}s (quota/forbidden)` : 'disabled (auth)'}, failing over ===\n${formatHeaders(upstreamRes.headers)}`);
664
685
  if (errorBody) logSections.push(`=== ERROR BODY ===\n${errorBody}`);
665
686
  }
666
- console.log(`[Maxpool] ${upstreamRes.status} on provider "${account.name}" — disabled and failing over before first byte`);
687
+ console.log(`[Maxpool] ${upstreamRes.status} on provider "${account.name}" — ${forbidden ? `cooled down ${PROVIDER_FORBIDDEN_COOLDOWN_SEC}s (recoverable)` : 'disabled'} and failing over before first byte`);
667
688
 
668
689
  if (
669
690
  canRetryBufferedBody &&
package/src/tui.js CHANGED
@@ -318,7 +318,14 @@ export class TUI {
318
318
  this.active.delete(id);
319
319
  const dur = r ? ((Date.now() - r.started) / 1000).toFixed(1) : '?';
320
320
  const acct = info.account || r?.account || '?';
321
- this._addLog(`${info.method} ${info.path} → ${acct} (${info.status}, ${dur}s)`);
321
+ // Tag PROVIDER-routed requests with the maxpool session id so provider traffic
322
+ // (when-exhausted overflow, or an incompatible-session pin) is traceable back to
323
+ // the session that caused it: grep the event log for `→ glm-fallback … [sess X]`,
324
+ // then `ps eww -p <pid> | grep x-maxpool-session` to find the terminal. Truthiness
325
+ // guard — a header-less request has sessionKey '' (server.js headerValue) → no tag.
326
+ const isProvider = this.am.accounts.find(a => a.name === acct)?.type === 'provider';
327
+ const tag = (isProvider && r?.sessionKey) ? ` [sess ${String(r.sessionKey).slice(0, 8)}]` : '';
328
+ this._addLog(`${info.method} ${info.path} → ${acct} (${info.status}, ${dur}s)${tag}`);
322
329
  }
323
330
 
324
331
  _addLog(msg) {
@@ -946,6 +953,15 @@ export class TUI {
946
953
  const p = this.am._crossProviderFallbackPolicy?.() || 'when-exhausted';
947
954
  const pc = p === 'never' ? yellow(p) : p === 'always' ? green(p) : cyan(p);
948
955
  xpText = ` ${dim('·')} ${dim('Cross-provider:')} ${pc}`;
956
+ // Overflow visibility: when GLM/Kimi actually served requests recently, surface the
957
+ // volume so provider traffic isn't a mystery — it's overflow that spilled here while
958
+ // OAuth was saturated. Reuses the SAME 15m load window as the per-row "15m Nr" column.
959
+ const now = Date.now();
960
+ let provReq = 0;
961
+ for (const a of this.am.accounts) {
962
+ if (a.type === 'provider') provReq += this.am._loadSummary?.(a, 15 * 60_000, now)?.requests || 0;
963
+ }
964
+ if (provReq > 0) xpText += ` ${dim('·')} ${yellow(`providers: ${provReq} req/15m`)}`;
949
965
  }
950
966
  lines.push(` Routing ${cyan(routing)}${xpText}`);
951
967
  const queuedCount = this.am.queueState?.waiting?.length || 0;