maxpool 1.24.21 → 1.24.23
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 +1 -1
- package/src/server.js +32 -11
- package/src/tui.js +71 -4
package/package.json
CHANGED
package/src/server.js
CHANGED
|
@@ -366,7 +366,10 @@ export function createProxyServer(accountManager, config, hooks = {}) {
|
|
|
366
366
|
message: 'Maxpool did not receive the full request in time (the upload stalled — usually a network problem on this machine). Retrying should work once the network recovers.',
|
|
367
367
|
},
|
|
368
368
|
});
|
|
369
|
-
|
|
369
|
+
// end-then-DESTROY: a half-closed socket kept parsing, so a client that resumed its
|
|
370
|
+
// upload after the 408 got its request forwarded upstream anyway — a duplicate,
|
|
371
|
+
// possibly billed call (red-team 2026-10-12, finding 6).
|
|
372
|
+
socket.end(`HTTP/1.1 408 Request Timeout\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`, () => socket.destroy());
|
|
370
373
|
return;
|
|
371
374
|
}
|
|
372
375
|
socket.destroy(err);
|
|
@@ -516,10 +519,10 @@ async function forwardRequest(
|
|
|
516
519
|
// useful header is "retry soon": every SDK retries ≤60s values with backoff,
|
|
517
520
|
// and the pool answers again immediately with a fresh estimate. The body text
|
|
518
521
|
// still carries the real wait.
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
522
|
+
// Header = CLI-retriable cap; message = the REAL wait (2026-10-12: the capped
|
|
523
|
+
// number leaked into the text as "Retry in ~1m" while the real wait was ~1h).
|
|
524
|
+
const retryAfter = clientRetryAfterSeconds(retryPlan.retryAfterMs);
|
|
525
|
+
const realWaitS = willRecoverSoon ? Math.max(1, Math.ceil(retryPlan.retryAfterMs / 1000)) : 60;
|
|
523
526
|
// Surface the routing decision (logs go to the TUI; this is the only record
|
|
524
527
|
// of WHY a request was rejected rather than queued).
|
|
525
528
|
console.log(`[Maxpool] No route for request — returning 429 (cause: ${retryPlan.cause || 'unavailable'}, recovers-soon: ${willRecoverSoon})`);
|
|
@@ -527,7 +530,7 @@ async function forwardRequest(
|
|
|
527
530
|
type: 'error',
|
|
528
531
|
error: {
|
|
529
532
|
type: 'rate_limit_error',
|
|
530
|
-
message: unavailableMessage(accountManager, requestInfo,
|
|
533
|
+
message: unavailableMessage(accountManager, requestInfo, realWaitS, willRecoverSoon),
|
|
531
534
|
},
|
|
532
535
|
}, { 'retry-after': String(retryAfter) });
|
|
533
536
|
return;
|
|
@@ -900,15 +903,15 @@ async function forwardRequest(
|
|
|
900
903
|
if (logDir) writeRequestLog(logDir, reqId, logSections);
|
|
901
904
|
const retryPlan = accountManager.nextRetryForRequest?.(requestInfo, new Set()) || {};
|
|
902
905
|
const willRecoverSoon = Number.isFinite(retryPlan.retryAfterMs);
|
|
903
|
-
const
|
|
906
|
+
const realWaitS = willRecoverSoon ? Math.max(1, Math.ceil(retryPlan.retryAfterMs / 1000)) : 60;
|
|
904
907
|
console.log(`[Maxpool] No route after failover — returning 429 (cause: ${retryPlan.cause || 'unavailable'}, recovers-soon: ${willRecoverSoon})`);
|
|
905
908
|
sendErrorResponse(res, requestInfo, 429, {
|
|
906
909
|
type: 'error',
|
|
907
910
|
error: {
|
|
908
911
|
type: 'rate_limit_error',
|
|
909
|
-
message: unavailableMessage(accountManager, requestInfo,
|
|
912
|
+
message: unavailableMessage(accountManager, requestInfo, realWaitS, willRecoverSoon),
|
|
910
913
|
},
|
|
911
|
-
}, { 'retry-after': String(
|
|
914
|
+
}, { 'retry-after': String(clientRetryAfterSeconds(retryPlan.retryAfterMs)) });
|
|
912
915
|
return;
|
|
913
916
|
}
|
|
914
917
|
|
|
@@ -2727,8 +2730,11 @@ async function queueAndRetry(
|
|
|
2727
2730
|
// the capacity window exists precisely to shed that load (the soft-deadlock
|
|
2728
2731
|
// guard — pinned at cap forever must 429 within the window, never spin).
|
|
2729
2732
|
// Only quota-class resets (a scheduled 5h/weekly rollover) chain.
|
|
2733
|
+
// Keyed on WHAT the reset is (retryPlan.cause), never on the caller's coarse label:
|
|
2734
|
+
// the failover path calls with cause='capacity' even when the plan is a scheduled
|
|
2735
|
+
// quota rollover, which wrongly lost the chain (red-team 2026-10-12, finding 3).
|
|
2730
2736
|
if (requestInfo.stream && queueWindowMs > 0 && Number.isFinite(retryAfterMs)
|
|
2731
|
-
&& retryPlan.cause
|
|
2737
|
+
&& !CAPACITY_CLASS_RETRY_CAUSES.has(retryPlan.cause)) {
|
|
2732
2738
|
requestInfo.queueHoldChain = true;
|
|
2733
2739
|
} else {
|
|
2734
2740
|
return finishQueuedStreamIfNeeded(res, requestInfo, honestMessage);
|
|
@@ -3729,9 +3735,24 @@ function markThinkingFromResponse(buffer, accountManager, requestInfo = {}) {
|
|
|
3729
3735
|
}
|
|
3730
3736
|
}
|
|
3731
3737
|
|
|
3738
|
+
// CLI-RETRIABLE retry-after (2026-10-11/12). Claude Code 2.1.296 retries a 429 whose
|
|
3739
|
+
// retry-after is ≤60s (+25% headroom) and treats anything longer as TERMINAL — the
|
|
3740
|
+
// turn dies (binary-verified, then reproduced against the real CLI: retry-after 3600
|
|
3741
|
+
// → "API Error: Request rejected (429)" at once; retry-after 60 → waits, retries,
|
|
3742
|
+
// recovers). Every 429 the pool emits goes through this cap; the real wait still
|
|
3743
|
+
// rides in the message text.
|
|
3744
|
+
const RETRY_AFTER_RETRIABLE_S = 60;
|
|
3745
|
+
// Retry causes whose 'reset' is a re-poll guess (load), not a scheduled quota rollover —
|
|
3746
|
+
// these shed within the capacity window instead of hold-chaining (soft-deadlock guard).
|
|
3747
|
+
const CAPACITY_CLASS_RETRY_CAUSES = new Set(['concurrency_cap']);
|
|
3748
|
+
function clientRetryAfterSeconds(retryAfterMs) {
|
|
3749
|
+
if (!Number.isFinite(retryAfterMs)) return RETRY_AFTER_RETRIABLE_S;
|
|
3750
|
+
return Math.min(Math.max(1, Math.ceil(retryAfterMs / 1000)), RETRY_AFTER_RETRIABLE_S);
|
|
3751
|
+
}
|
|
3752
|
+
|
|
3732
3753
|
function computeRetryAfter(accountManager, requestInfo = {}) {
|
|
3733
3754
|
const ms = accountManager.nextRetryForRequest?.(requestInfo, new Set())?.retryAfterMs ?? Infinity;
|
|
3734
|
-
return
|
|
3755
|
+
return clientRetryAfterSeconds(ms);
|
|
3735
3756
|
}
|
|
3736
3757
|
|
|
3737
3758
|
// Test seam: prepareRuntimeProviders with a plain headers object.
|
package/src/tui.js
CHANGED
|
@@ -274,6 +274,20 @@ function settingsTags(am, a) {
|
|
|
274
274
|
/** True when the reservation is what is currently keeping traffic off this account
|
|
275
275
|
* — either window at or past the cap. Reads the manager's own predicate so the
|
|
276
276
|
* label can never disagree with routing. */
|
|
277
|
+
/** The 5h window is used up past the bench — same predicate the router's
|
|
278
|
+
* _isSessionQuotaUnavailable applies, without its expiry-clearing side effect (a
|
|
279
|
+
* window whose reset has passed is NOT full: the router clears it on next use). */
|
|
280
|
+
function sessionFull(am, a) {
|
|
281
|
+
const q = a?.quota || {};
|
|
282
|
+
const now = Date.now();
|
|
283
|
+
const bench = am?._sessionBenchThreshold?.(a, now);
|
|
284
|
+
if (!Number.isFinite(bench)) return false;
|
|
285
|
+
const live = (util, reset) => util != null && !(reset && now >= reset) && util >= bench;
|
|
286
|
+
return a.type === 'provider'
|
|
287
|
+
? live(q.providerSes, q.providerSesReset)
|
|
288
|
+
: live(q.unified5h, q.unified5hReset);
|
|
289
|
+
}
|
|
290
|
+
|
|
277
291
|
function capBenched(am, a) {
|
|
278
292
|
if (a?.capUtilization == null) return false;
|
|
279
293
|
const q = a.quota || {};
|
|
@@ -1002,8 +1016,8 @@ export class TUI {
|
|
|
1002
1016
|
_keyRouting(k) {
|
|
1003
1017
|
if (k === 'a') {
|
|
1004
1018
|
this._confirm(
|
|
1005
|
-
'
|
|
1006
|
-
'
|
|
1019
|
+
'Unpin and use automatic routing?',
|
|
1020
|
+
'Removes the pinned account. New requests are spread across all healthy accounts again.',
|
|
1007
1021
|
() => this._setAutomaticRouting(),
|
|
1008
1022
|
);
|
|
1009
1023
|
} else if (k === 'p' && this.am.accounts.some(account => account.type !== 'provider')) {
|
|
@@ -1999,6 +2013,7 @@ export class TUI {
|
|
|
1999
2013
|
// 2 = separator + footer; confirm adds its detail line; updates adds its detail block.
|
|
2000
2014
|
const footerH = this.mode === 'confirm' ? 3
|
|
2001
2015
|
: this.mode === 'updates' ? 2 + this._renderUpdatesDetail().length
|
|
2016
|
+
: this.mode === 'routing' ? 2 + this._renderRoutingDetail().length
|
|
2002
2017
|
: 2;
|
|
2003
2018
|
const space = Math.max(0, H - lines.length - footerH);
|
|
2004
2019
|
for (let i = 0; i < space && i < this.log.length; i++) {
|
|
@@ -2015,6 +2030,7 @@ export class TUI {
|
|
|
2015
2030
|
// very bottom of a busy screen, which reads as "nothing happened" (reported twice).
|
|
2016
2031
|
// Every other mode draws something visible; this one must too.
|
|
2017
2032
|
if (this.mode === 'updates') lines.push(...this._renderUpdatesDetail());
|
|
2033
|
+
if (this.mode === 'routing') lines.push(...this._renderRoutingDetail());
|
|
2018
2034
|
lines.push(this._renderFooter());
|
|
2019
2035
|
|
|
2020
2036
|
this._writeScreen(lines, W, H);
|
|
@@ -2094,6 +2110,20 @@ export class TUI {
|
|
|
2094
2110
|
// credentials were fine or long dead, so re-enabling it later silently produced a
|
|
2095
2111
|
// broken account. Reported 2026-08-10 with all 8 disabled accounts sitting on dead
|
|
2096
2112
|
// credentials (HTTP 401) and no way to see it.
|
|
2113
|
+
// HELD BY ITS OWN LIMITS (2026-10-13). An account the router refuses must not read
|
|
2114
|
+
// "active": 16 requests sat parked for 35 minutes while two rows said "active" next
|
|
2115
|
+
// to an account held at its usage cap and another whose 5h window was full. Only
|
|
2116
|
+
// relabels an otherwise-active row; exhausted / throttled / auth states keep their
|
|
2117
|
+
// own (more specific) word. "capped" = the owner's usage cap is holding it;
|
|
2118
|
+
// "5h full" = its session window is used up until the reset shown on the row.
|
|
2119
|
+
// READ-ONLY by design: _weeklyRawState/_isSessionQuotaUnavailable call
|
|
2120
|
+
// _clearExpiredQuotas, which MUTATES the quota (nulls an expired window). A render
|
|
2121
|
+
// must never change the state it is drawing — the row's own bars, read later in
|
|
2122
|
+
// this function, then showed blanks. capBenched + _sessionBenchThreshold are pure.
|
|
2123
|
+
if (a.enabled !== false && effectiveStatus === 'active') {
|
|
2124
|
+
if (capBenched(this.am, a)) effectiveStatus = 'capped';
|
|
2125
|
+
else if (sessionFull(this.am, a)) effectiveStatus = '5h full';
|
|
2126
|
+
}
|
|
2097
2127
|
if (a.refreshDead) effectiveStatus = a.enabled === false ? 'disabled-reauth' : 'reauth';
|
|
2098
2128
|
// Subscription gone (org-disabled 403): a DISTINCT state from reauth — re-logging in
|
|
2099
2129
|
// will NOT fix it until the subscription is re-purchased. Says the actionable thing.
|
|
@@ -2130,6 +2160,8 @@ export class TUI {
|
|
|
2130
2160
|
break;
|
|
2131
2161
|
}
|
|
2132
2162
|
case 'exhausted': status = red('exhausted'); break;
|
|
2163
|
+
case 'capped': status = yellow('capped'); break;
|
|
2164
|
+
case '5h full': status = yellow('5h full'); break;
|
|
2133
2165
|
case 'error': status = red('error'); break;
|
|
2134
2166
|
default: status = a.status || 'ready';
|
|
2135
2167
|
}
|
|
@@ -2493,6 +2525,37 @@ export class TUI {
|
|
|
2493
2525
|
return out;
|
|
2494
2526
|
}
|
|
2495
2527
|
|
|
2528
|
+
/**
|
|
2529
|
+
* Routing DETAIL — what each key on this screen controls and how to undo it.
|
|
2530
|
+
* (2026-10-13: "the cap setting only changes GLM", "I pinned an account and have no
|
|
2531
|
+
* idea how to switch it back", "where is the cap for individual accounts?")
|
|
2532
|
+
*/
|
|
2533
|
+
_renderRoutingDetail() {
|
|
2534
|
+
// Every line fits 80 columns: a truncated explanation is no explanation.
|
|
2535
|
+
const out = [];
|
|
2536
|
+
const pinned = this.am.routingMode === 'preferred' && this.am.preferredAccountName;
|
|
2537
|
+
// The manual pin: state, what it does, and the way out — always visible.
|
|
2538
|
+
if (pinned) {
|
|
2539
|
+
out.push(` ${yellow(`Pinned to ${this.am.preferredAccountName}`)} ${bold('a')} Unpin ${bold('p')} Change`);
|
|
2540
|
+
out.push(` ${dim('It gets every request it can take; the others only cover for it.')}`);
|
|
2541
|
+
} else {
|
|
2542
|
+
out.push(` ${bold('p')} Pin one Claude account ${dim('(undo:')} ${bold('a')}${dim(')')}`);
|
|
2543
|
+
out.push(` ${dim('Sends everything to it first; the others only cover for it.')}`);
|
|
2544
|
+
}
|
|
2545
|
+
const hasPeak = this.am.accounts.some(a => a.type === 'provider'
|
|
2546
|
+
&& (this.am._peakSettingsFor?.(a.provider)?.windows || []).length > 0);
|
|
2547
|
+
if (hasPeak) {
|
|
2548
|
+
out.push(` ${bold('c')} GLM peak cap ${dim('— GLM accounts only, in GLM peak hours. Not Claude.')}`);
|
|
2549
|
+
}
|
|
2550
|
+
// Where the per-account cap actually is.
|
|
2551
|
+
out.push(` ${dim('Usage cap for ONE account:')} ${bold('Esc')} ${dim('then')} ${bold('a')} ${dim('→')} ${bold('u')} ${dim('(Claude or GLM)')}`);
|
|
2552
|
+
const capped = this.am.accounts.filter(a => a.capUtilization != null && a.enabled !== false);
|
|
2553
|
+
if (capped.length) {
|
|
2554
|
+
out.push(` ${dim('Capped now:')} ${capped.map(a => `${a.name} ${Math.round(a.capUtilization * 100)}%`).join(', ')}`);
|
|
2555
|
+
}
|
|
2556
|
+
return out;
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2496
2559
|
_renderFooter() {
|
|
2497
2560
|
switch (this.mode) {
|
|
2498
2561
|
case 'normal':
|
|
@@ -2531,9 +2594,13 @@ export class TUI {
|
|
|
2531
2594
|
const dep = ps.depreference ? yellow('GLM last') : cyan('normal');
|
|
2532
2595
|
const cap = ps.cap >= 1 ? 'off' : ps.cap === 0 ? 'never' : `${Math.round(ps.cap * 100)}%`;
|
|
2533
2596
|
const wk = this.am.scheduler.weeklyAwareScoring === false ? yellow('5h-only') : cyan('weekly');
|
|
2534
|
-
|
|
2597
|
+
// "cap" alone read as THE usage cap for every account (2026-10-13). It is not:
|
|
2598
|
+
// it only limits GLM accounts, only during GLM's peak hours. Say so.
|
|
2599
|
+
peakPart = ` ${dim('│')} ${bold(' d ')}Peak${now}: ${dep} ${bold(' c ')}GLM peak cap ${cyan(cap)} ${bold(' w ')}score:${wk}`;
|
|
2535
2600
|
}
|
|
2536
|
-
|
|
2601
|
+
// The pin and the account-cap pointer live in the DETAIL block above this line
|
|
2602
|
+
// (_renderRoutingDetail) — one footer line cannot carry them without truncating.
|
|
2603
|
+
return ` ${bold('f')} Routing: ${cyan(mode.label)} ↻${provPart}${peakPart} ${bold('Esc')} Back`;
|
|
2537
2604
|
}
|
|
2538
2605
|
case 'select': {
|
|
2539
2606
|
const act = this.selAction === 'prefer'
|