maxpool 1.5.37 → 1.5.39
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 +6 -2
- package/src/account-manager.js +79 -4
- package/src/index.js +19 -0
- package/src/server.js +77 -11
- package/src/tui.js +104 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "maxpool",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.39",
|
|
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",
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"start": "node src/index.js",
|
|
16
16
|
"test": "bash scripts/run-tests.sh",
|
|
17
17
|
"lint": "eslint src/ test/",
|
|
18
|
-
"release": "bash scripts/release.sh"
|
|
18
|
+
"release": "bash scripts/release.sh",
|
|
19
|
+
"version": "bash scripts/update-changelog.sh"
|
|
19
20
|
},
|
|
20
21
|
"keywords": [
|
|
21
22
|
"claude",
|
|
@@ -38,5 +39,8 @@
|
|
|
38
39
|
},
|
|
39
40
|
"engines": {
|
|
40
41
|
"node": ">=20.3.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"git-cliff": "2.13.1"
|
|
41
45
|
}
|
|
42
46
|
}
|
package/src/account-manager.js
CHANGED
|
@@ -15,6 +15,15 @@ const BOUNDED_REPOLL_HOLD_MS = 60_000;
|
|
|
15
15
|
// session can never ping-pong between two similarly-loaded accounts.
|
|
16
16
|
const REBALANCE_SCORE_MARGIN = 0.5; // candidate score must be ≤ 50% of the bound account's
|
|
17
17
|
const REBALANCE_MIN_ABS_GAP = 0.5; // …with a small absolute floor so near-zero scores don't micro-churn
|
|
18
|
+
// Warmup-pull: a freshly-ADDED account (mid-session, no reload) would otherwise idle
|
|
19
|
+
// until a reload clears bindings, because bound sessions only leave a HOT account. So
|
|
20
|
+
// for a bounded window pull migration-safe sessions onto it. Keyed on the account's
|
|
21
|
+
// own onboarding state (addedAt + completedRequests), NOT relative load — so it
|
|
22
|
+
// PROVABLY TERMINATES (once it has served WARMUP_REQUESTS, or WARMUP_MS elapses, it
|
|
23
|
+
// stops warming and never fires again) and cannot oscillate for any #sessions/#accounts
|
|
24
|
+
// ratio, unlike a share/concurrency-gap rebalance (which flaps on the lagging load signal).
|
|
25
|
+
const WARMUP_MS = 5 * 60 * 1000; // a just-added account stays "warming" this long…
|
|
26
|
+
const WARMUP_REQUESTS = 5; // …or until it has served this many requests, whichever comes first
|
|
18
27
|
// Weekly-pressure tiers, healthiest first. A fresh (unknown) account is the best
|
|
19
28
|
// migration target; migration requires the candidate be a STRICTLY healthier tier.
|
|
20
29
|
const WEEKLY_TIER = { unknown: 0, normal: 0, soft: 1, reserve: 2, critical: 3, exhausted: 4 };
|
|
@@ -1138,6 +1147,54 @@ export class AccountManager {
|
|
|
1138
1147
|
* All gates must hold: thinking-safe + not mid queue-admission + bound is hot +
|
|
1139
1148
|
* a genuinely-healthy alternative that is BOTH much cheaper AND a strictly
|
|
1140
1149
|
* healthier weekly tier (so concurrency jitter alone can never trigger a move). */
|
|
1150
|
+
/** Is this account still in its post-add onboarding window? A freshly-ADDED
|
|
1151
|
+
* account (addedAt set only by addAccount, never on boot) that has served fewer
|
|
1152
|
+
* than WARMUP_REQUESTS within WARMUP_MS. Providers are never "warming" targets. */
|
|
1153
|
+
_isWarming(account, now = Date.now()) {
|
|
1154
|
+
if (!account || account.type === 'provider') return false;
|
|
1155
|
+
if (account.addedAt == null) return false; // boot/config account → never warming
|
|
1156
|
+
if (account.completedRequests >= WARMUP_REQUESTS) return false; // onboarded → terminates the pull
|
|
1157
|
+
return (now - account.addedAt) < WARMUP_MS;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/** Warmup-pull target: the best (lowest-score) healthy, migration-eligible,
|
|
1161
|
+
* still-WARMING NON-provider account to onboard a freshly-added account WITHOUT a
|
|
1162
|
+
* reload, or null. Returned DIRECTLY (not via the score-loop fallthrough) so the
|
|
1163
|
+
* destination is GUARANTEED non-provider even under 'always' cross-provider policy
|
|
1164
|
+
* — a signed-thinking session can never be shuttled onto a provider here (which
|
|
1165
|
+
* the shared candidate loop, keyed only on _matchesRequest, would not prevent).
|
|
1166
|
+
* Fires only when the bound account is itself established (not warming) AND is
|
|
1167
|
+
* actually carrying recent load — relieving a real carrier onto the fresh account,
|
|
1168
|
+
* never churning fresh↔fresh or re-homing an idle session. */
|
|
1169
|
+
_warmupPullTarget(bound, profile, excludedIndexes, requestInfo, now = Date.now()) {
|
|
1170
|
+
// Cheapest early-out first: the overwhelmingly common steady state has NO warming
|
|
1171
|
+
// account, so bail before the migration-safety / load / fleet-scoring work. This
|
|
1172
|
+
// runs on every bound request's selection — keep the no-warming path near-free.
|
|
1173
|
+
if (!this.accounts.some(a => this._isWarming(a, now))) return null;
|
|
1174
|
+
if (!this._migrationSafeForRequest(requestInfo)) return null;
|
|
1175
|
+
if (requestInfo.queueTicket || requestInfo.queueAdmitted) return null;
|
|
1176
|
+
if (this._isWarming(bound, now)) return null;
|
|
1177
|
+
if (this._loadSummary(bound, this.scheduler.spreadWindowMs, now).weight <= 0) return null;
|
|
1178
|
+
const ctx = this._scoringContext();
|
|
1179
|
+
let best = null;
|
|
1180
|
+
let bestScore = Infinity;
|
|
1181
|
+
for (const account of this.accounts) {
|
|
1182
|
+
if (account.index === bound.index) continue;
|
|
1183
|
+
if (account.type === 'provider') continue; // GUARANTEED non-provider destination
|
|
1184
|
+
if (excludedIndexes.has(account.index)) continue;
|
|
1185
|
+
if (!this._isWarming(account, now)) continue;
|
|
1186
|
+
if (!this._matchesRequest(account, profile, requestInfo)) continue;
|
|
1187
|
+
// Genuinely-healthy target only (same bar as the hot-rebalance candidate scan).
|
|
1188
|
+
if (!this._isAvailable(account, { allowWeeklyReserve: false, allowWeeklyCritical: false, model: requestInfo.model })) continue;
|
|
1189
|
+
const score = this._scoreAccount(account, requestInfo, ctx);
|
|
1190
|
+
if (score < bestScore) {
|
|
1191
|
+
bestScore = score;
|
|
1192
|
+
best = account;
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
return best;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1141
1198
|
_shouldRebalanceBoundSession(bound, profile, excludedIndexes, requestInfo, scoringCtx) {
|
|
1142
1199
|
if (!this._migrationSafeForRequest(requestInfo)) return false;
|
|
1143
1200
|
if (requestInfo.queueTicket || requestInfo.queueAdmitted) return false;
|
|
@@ -1350,10 +1407,24 @@ export class AccountManager {
|
|
|
1350
1407
|
}
|
|
1351
1408
|
}
|
|
1352
1409
|
const bound = this._boundAccount(requestInfo.sessionKey, profile, excludedIndexes, requestInfo);
|
|
1353
|
-
if (bound
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
return
|
|
1410
|
+
if (bound) {
|
|
1411
|
+
// Warmup-pull: onboard a freshly-ADDED account (added mid-session, no reload)
|
|
1412
|
+
// by DIRECTLY re-homing this migration-safe session onto the warming account,
|
|
1413
|
+
// before the sticky "stay bound" return. Directed (not via the score loop) so
|
|
1414
|
+
// the destination is guaranteed non-provider. Bounded + self-terminating via
|
|
1415
|
+
// _isWarming; each session re-homes at most once → no flap. Skipped for a
|
|
1416
|
+
// preferred/pinned request (handled above) and any non-migration-safe request.
|
|
1417
|
+
const warmupTarget = this._warmupPullTarget(
|
|
1418
|
+
bound, profile, excludedIndexes, requestInfo, scoringCtx?.now,
|
|
1419
|
+
);
|
|
1420
|
+
if (warmupTarget) {
|
|
1421
|
+
this.currentIndex = warmupTarget.index;
|
|
1422
|
+
return warmupTarget; // _bindSession re-homes the session on acquire
|
|
1423
|
+
}
|
|
1424
|
+
if (!this._hasHigherPriorityAvailable(bound, profile, excludedIndexes, requestInfo)
|
|
1425
|
+
&& !this._shouldRebalanceBoundSession(bound, profile, excludedIndexes, requestInfo, scoringCtx)) {
|
|
1426
|
+
return bound;
|
|
1427
|
+
}
|
|
1357
1428
|
}
|
|
1358
1429
|
// Else fall through to the candidate score loop, which re-homes the session
|
|
1359
1430
|
// onto the best healthy account via _bindSession on acquire.
|
|
@@ -2488,6 +2559,10 @@ export class AccountManager {
|
|
|
2488
2559
|
provisionalRateLimitFingerprint: null,
|
|
2489
2560
|
recoveredAt: null,
|
|
2490
2561
|
lastQuotaLogKey: null,
|
|
2562
|
+
// Onboarding clock for the warmup-pull — set ONLY here (mid-session add),
|
|
2563
|
+
// never on the boot/config construction path, so an established fleet account
|
|
2564
|
+
// is never "warming". Lets a just-added account draw load without a reload.
|
|
2565
|
+
addedAt: Date.now(),
|
|
2491
2566
|
});
|
|
2492
2567
|
return index;
|
|
2493
2568
|
}
|
package/src/index.js
CHANGED
|
@@ -702,6 +702,7 @@ async function serverWorkerCommand() {
|
|
|
702
702
|
let tui = null;
|
|
703
703
|
let server = null;
|
|
704
704
|
let syncTimer = null;
|
|
705
|
+
let updateTimer = null;
|
|
705
706
|
let draining = false;
|
|
706
707
|
let restartController = null;
|
|
707
708
|
// Set once this worker hands the terminal to a new worker during a seamless TUI
|
|
@@ -805,6 +806,7 @@ async function serverWorkerCommand() {
|
|
|
805
806
|
if (draining) return;
|
|
806
807
|
draining = true;
|
|
807
808
|
if (syncTimer) clearInterval(syncTimer);
|
|
809
|
+
if (updateTimer) clearInterval(updateTimer);
|
|
808
810
|
if (tui?.running) { tui.stop(); }
|
|
809
811
|
console.log('\n[Maxpool] Restarting server now; queued requests will reconnect automatically.');
|
|
810
812
|
server.closeAllConnections?.();
|
|
@@ -893,6 +895,7 @@ async function serverWorkerCommand() {
|
|
|
893
895
|
|
|
894
896
|
draining = true;
|
|
895
897
|
if (syncTimer) clearInterval(syncTimer);
|
|
898
|
+
if (updateTimer) clearInterval(updateTimer);
|
|
896
899
|
if (tui?.running) tui.stop();
|
|
897
900
|
// Best-effort final flush + settle writes (fire-and-forget; the drain timeout
|
|
898
901
|
// below bounds total quit time, and write barriers protect the on-disk state).
|
|
@@ -1015,6 +1018,21 @@ async function serverWorkerCommand() {
|
|
|
1015
1018
|
}, syncIntervalMs);
|
|
1016
1019
|
syncTimer.unref();
|
|
1017
1020
|
|
|
1021
|
+
// Periodic update re-check. The startup probe (in becomePrimary) is one-shot, but a
|
|
1022
|
+
// long-lived session (days) must still be reminded of versions published AFTER it
|
|
1023
|
+
// started. This timer is UNCONDITIONAL (NOT reload-guarded) so it survives a seamless
|
|
1024
|
+
// `r`-reload — otherwise taking an update (which the banner tells users to do via `r`)
|
|
1025
|
+
// would permanently disable all future update detection. It only refreshes
|
|
1026
|
+
// versionInfo (the persistent TUI banner is the reminder, so no repeated log spam);
|
|
1027
|
+
// autoUpdate progress lines still surface. unref so it never blocks a clean exit.
|
|
1028
|
+
const updateIntervalMs = Math.max(60_000, Number(process.env.MAXPOOL_UPDATE_CHECK_INTERVAL_MS) || 6 * 60 * 60 * 1000);
|
|
1029
|
+
updateTimer = setInterval(() => {
|
|
1030
|
+
if (config?.updateCheck === false) return;
|
|
1031
|
+
const autoNotify = msg => { if (config?.autoUpdate) (tui?._addLog ? tui._addLog(msg) : console.log(`[Maxpool] ${msg}`)); };
|
|
1032
|
+
maybeCheckForUpdate(config, autoNotify, info => { accountManager.versionInfo = info; }).catch(() => {});
|
|
1033
|
+
}, updateIntervalMs);
|
|
1034
|
+
updateTimer.unref();
|
|
1035
|
+
|
|
1018
1036
|
// Become the live primary: start serving UI/logs, take the writer lease, run
|
|
1019
1037
|
// the update check. `viaTakeover` true means we acquired the socket through the
|
|
1020
1038
|
// baton (a reload) — freeze the update check so a reload doesn't re-probe npm
|
|
@@ -1092,6 +1110,7 @@ async function serverWorkerCommand() {
|
|
|
1092
1110
|
// proceeding (not a rollback). Disarm the rollback self-heal.
|
|
1093
1111
|
if (reloadWatchdog) { clearTimeout(reloadWatchdog); reloadWatchdog = null; }
|
|
1094
1112
|
if (syncTimer) clearInterval(syncTimer);
|
|
1113
|
+
if (updateTimer) clearInterval(updateTimer);
|
|
1095
1114
|
// Stop accepting NEW connections; KEEP in-flight requests alive.
|
|
1096
1115
|
server.maxpoolBeginDrain?.();
|
|
1097
1116
|
server.close(() => {});
|
package/src/server.js
CHANGED
|
@@ -56,6 +56,17 @@ const DEFAULT_QUEUE = {
|
|
|
56
56
|
// Non-streaming requests have no SSE heartbeat to keep them alive, so a long
|
|
57
57
|
// hold would die on the client timeout anyway. Cap their wait conservatively.
|
|
58
58
|
nonStreamMaxWaitMs: 5 * 60 * 1000,
|
|
59
|
+
// Proactive streaming keep-alive: a STREAMING request gets ZERO client bytes
|
|
60
|
+
// while maxpool selects a route, cycles failover, and waits for the upstream's
|
|
61
|
+
// first byte (up to UPSTREAM_TTFB_MS=120s) — the queue heartbeat only starts
|
|
62
|
+
// once a request is QUEUED. If that client-silent window exceeds the client's
|
|
63
|
+
// own idle timeout (Claude Code aborts "Stream idle timeout - no chunks
|
|
64
|
+
// received", observed as low as ~23s), the client gives up on a request maxpool
|
|
65
|
+
// is still patiently serving. If the upstream hasn't delivered bytes within this
|
|
66
|
+
// grace, commit the SSE stream + start the heartbeat so the client never idles
|
|
67
|
+
// out. Kept above a normal fast TTFB (1-5s → common case never early-commits,
|
|
68
|
+
// behavior unchanged) and well below the client idle floor. Env-overridable.
|
|
69
|
+
streamForwardGraceMs: Math.max(1000, Number(process.env.MAXPOOL_STREAM_FORWARD_GRACE_MS) || 10000),
|
|
59
70
|
// count_tokens is cheap non-streaming metadata — cap its queue wait VERY low so it
|
|
60
71
|
// fast-fails with a retryable 429 instead of hanging silently past the client's idle
|
|
61
72
|
// window. Kept well under any plausible client idle timeout (observed errors as low
|
|
@@ -492,6 +503,28 @@ async function forwardRequest(
|
|
|
492
503
|
UPSTREAM_TTFB_MS,
|
|
493
504
|
);
|
|
494
505
|
ttfbTimer.unref?.();
|
|
506
|
+
// Proactive streaming keep-alive. UPSTREAM_TTFB_MS (120s) is FAR above the
|
|
507
|
+
// client's own idle timeout (~23-60s), and the queue heartbeat only starts once
|
|
508
|
+
// a request is QUEUED — so a streaming request whose selected upstream is slow to
|
|
509
|
+
// first byte, or that burns the client's timeout cycling failover, sends the
|
|
510
|
+
// client ZERO bytes and is aborted ("Stream idle timeout - no chunks received")
|
|
511
|
+
// on a request maxpool is still serving. If bytes haven't arrived within the
|
|
512
|
+
// grace, commit the SSE stream + heartbeat so the client stays alive. The
|
|
513
|
+
// deadline is anchored ONCE per request (??=) so it also bounds CUMULATIVE
|
|
514
|
+
// failover time across re-forwards, not each attempt independently.
|
|
515
|
+
const streamForwardGraceMs = queueConfig?.streamForwardGraceMs == null
|
|
516
|
+
? 10000
|
|
517
|
+
: Math.max(0, Number(queueConfig.streamForwardGraceMs) || 0);
|
|
518
|
+
let streamGraceTimer = null;
|
|
519
|
+
if (requestInfo.stream && !res.headersSent) {
|
|
520
|
+
requestInfo.streamGraceDeadline ??= Date.now() + streamForwardGraceMs;
|
|
521
|
+
const graceDelay = Math.max(0, requestInfo.streamGraceDeadline - Date.now());
|
|
522
|
+
streamGraceTimer = setTimeout(
|
|
523
|
+
() => commitStreamGraceHeartbeat(res, requestInfo, queueConfig, accountManager),
|
|
524
|
+
graceDelay,
|
|
525
|
+
);
|
|
526
|
+
streamGraceTimer.unref?.();
|
|
527
|
+
}
|
|
495
528
|
try {
|
|
496
529
|
let upstreamRes;
|
|
497
530
|
try {
|
|
@@ -504,6 +537,9 @@ async function forwardRequest(
|
|
|
504
537
|
});
|
|
505
538
|
} finally {
|
|
506
539
|
clearTimeout(ttfbTimer);
|
|
540
|
+
// Stop the grace timer for THIS attempt — but LEAVE streamGraceDeadline set so
|
|
541
|
+
// a re-forward (failover) re-arms for the REMAINING time to the shared deadline.
|
|
542
|
+
if (streamGraceTimer) clearTimeout(streamGraceTimer);
|
|
507
543
|
}
|
|
508
544
|
// Response arrived — the pre-response leak window is over. Stop guarding for
|
|
509
545
|
// client-disconnect via abort (streamResponse handles mid-stream disconnects).
|
|
@@ -1153,7 +1189,7 @@ function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRe
|
|
|
1153
1189
|
return `All ${n} accounts exhausted. Retry in ${retryAfter}s.`;
|
|
1154
1190
|
}
|
|
1155
1191
|
|
|
1156
|
-
export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, describeRequest, classifyRateLimit, detectTranscriptOrigin, isAnthropicIncompatBody, streamResponse, startIdleRequestReaper };
|
|
1192
|
+
export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, commitStreamGraceHeartbeat, describeRequest, classifyRateLimit, detectTranscriptOrigin, isAnthropicIncompatBody, streamResponse, startIdleRequestReaper };
|
|
1157
1193
|
|
|
1158
1194
|
async function readErrorBody(upstreamRes, limitBytes = 64 * 1024) {
|
|
1159
1195
|
if (!upstreamRes.body) return '';
|
|
@@ -1507,26 +1543,56 @@ async function queueAndRetry(
|
|
|
1507
1543
|
).then(() => true);
|
|
1508
1544
|
}
|
|
1509
1545
|
|
|
1546
|
+
// The stream-forward grace-timer callback (extracted so its logic is unit-testable
|
|
1547
|
+
// in isolation). Fires when a STREAMING request's upstream is slow to first byte:
|
|
1548
|
+
// commits the SSE stream + heartbeat so the client never idle-times-out. Client-
|
|
1549
|
+
// abort-safe — an async timer has NO synchronous liveness precondition (unlike the
|
|
1550
|
+
// queue caller), so if the client vanished during the forward window (or the stream
|
|
1551
|
+
// is already committed) it reaps the queue slot and bails WITHOUT touching the dead
|
|
1552
|
+
// socket. ensureQueueHeartbeat is itself hardened against a throwing write, but this
|
|
1553
|
+
// guard is the cheaper first line of defense against the worker-bounce race.
|
|
1554
|
+
function commitStreamGraceHeartbeat(res, requestInfo, queueConfig, accountManager) {
|
|
1555
|
+
if (res.destroyed || res.writableEnded || res.headersSent) {
|
|
1556
|
+
clearQueueHeartbeat(requestInfo);
|
|
1557
|
+
accountManager.removeQueuedRequest?.(requestInfo);
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
ensureQueueHeartbeat(res, requestInfo, queueConfig, accountManager);
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1510
1563
|
function ensureQueueHeartbeat(res, requestInfo, queueConfig, accountManager) {
|
|
1511
1564
|
if (!requestInfo.stream || requestInfo.queueHeartbeatActive || res.headersSent) return;
|
|
1512
1565
|
const heartbeatMs = Math.max(1000, Number(queueConfig.heartbeatMs) || 10_000);
|
|
1513
|
-
res.writeHead(200, {
|
|
1514
|
-
'Content-Type': 'text/event-stream',
|
|
1515
|
-
'Cache-Control': 'no-cache',
|
|
1516
|
-
Connection: 'keep-alive',
|
|
1517
|
-
'X-Accel-Buffering': 'no',
|
|
1518
|
-
});
|
|
1519
|
-
res.flushHeaders?.();
|
|
1520
|
-
res.write(': maxpool queued\n\n');
|
|
1521
|
-
requestInfo.queueHeartbeatActive = true;
|
|
1522
1566
|
// The heartbeat is the liveness probe: if the client is gone (socket
|
|
1523
|
-
// destroyed/ended, or
|
|
1567
|
+
// destroyed/ended, or a write throws EPIPE/ERR_STREAM_DESTROYED), release
|
|
1524
1568
|
// the queue slot + bytes IMMEDIATELY rather than letting a dead ticket occupy
|
|
1525
1569
|
// the queue until its (up to 7d) deadline — the ghost-leak guard.
|
|
1526
1570
|
const reapDead = () => {
|
|
1527
1571
|
clearQueueHeartbeat(requestInfo);
|
|
1528
1572
|
accountManager?.removeQueuedRequest?.(requestInfo);
|
|
1529
1573
|
};
|
|
1574
|
+
// The INITIAL commit can throw if the socket died between the caller's last
|
|
1575
|
+
// liveness check and here. The synchronous queue caller (queueAndRetry) bails
|
|
1576
|
+
// on res.destroyed right before calling, but the ASYNC stream-forward grace
|
|
1577
|
+
// timer has no such precondition — the client can vanish mid-window. An
|
|
1578
|
+
// unguarded throw here has NO 'error' listener → uncaughtException → the worker
|
|
1579
|
+
// process.exits and bounces EVERY in-flight stream. Guard the initial write
|
|
1580
|
+
// exactly like the interval callback below so ensureQueueHeartbeat is safe from
|
|
1581
|
+
// ANY caller, sync or async.
|
|
1582
|
+
try {
|
|
1583
|
+
res.writeHead(200, {
|
|
1584
|
+
'Content-Type': 'text/event-stream',
|
|
1585
|
+
'Cache-Control': 'no-cache',
|
|
1586
|
+
Connection: 'keep-alive',
|
|
1587
|
+
'X-Accel-Buffering': 'no',
|
|
1588
|
+
});
|
|
1589
|
+
res.flushHeaders?.();
|
|
1590
|
+
res.write(': maxpool queued\n\n');
|
|
1591
|
+
} catch {
|
|
1592
|
+
reapDead();
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
requestInfo.queueHeartbeatActive = true;
|
|
1530
1596
|
requestInfo.queueHeartbeatTimer = setInterval(() => {
|
|
1531
1597
|
if (res.destroyed || res.writableEnded) { reapDead(); return; }
|
|
1532
1598
|
try {
|
package/src/tui.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline';
|
|
2
2
|
import { fetchProfile, loginOAuth, tokenFingerprint } from './oauth.js';
|
|
3
|
-
import { appendEventLog } from './event-log.js';
|
|
3
|
+
import { appendEventLog, setConsoleStdoutSuppressed } from './event-log.js';
|
|
4
4
|
|
|
5
5
|
// ── ANSI helpers ─────────────────────────────────────────────
|
|
6
6
|
|
|
@@ -26,14 +26,27 @@ const vw = s => strip(s).length;
|
|
|
26
26
|
|
|
27
27
|
// ── Accounts-table columns ───────────────────────────────────
|
|
28
28
|
// Fixed column widths shared by the header row (acctHeader) AND every data row, so
|
|
29
|
-
// the header labels stay aligned with the columns they name. The Account/
|
|
30
|
-
// Status/Quota start offsets (4/
|
|
29
|
+
// the header labels stay aligned with the columns they name. The Account/Provider/
|
|
30
|
+
// Status/Quota start offsets (4/21/31/45) are pure functions of these widths + the
|
|
31
31
|
// 4-col row prefix, independent of the quota-bar width.
|
|
32
|
-
const NAME_W =
|
|
33
|
-
const
|
|
32
|
+
const NAME_W = 16; // a.name.slice(0, NAME_W).padEnd(NAME_W) — wide enough for a full email
|
|
33
|
+
const PROVIDER_W = 9; // providerLabel(a).padEnd(PROVIDER_W) — fits "Anthropic"
|
|
34
34
|
const STATUS_W = 13; // rpad(status, STATUS_W) — fits "throttled 59s"
|
|
35
35
|
const ROW_PREFIX = ' '; // ' ' + sel(1) + cur(1) + ' ' — 4 cols before the name
|
|
36
36
|
|
|
37
|
+
// Human provider name for the accounts-table "Provider" column. account.provider
|
|
38
|
+
// is 'anthropic' for oauth/apikey accounts (they ARE Anthropic — the oauth-vs-key
|
|
39
|
+
// billing split is still shown by the Ses/Wk vs Tok/Req quota-bar labels, not here),
|
|
40
|
+
// and 'zai'/'kimi' for the GLM/Kimi fallback providers. Explicit map + a graceful
|
|
41
|
+
// Titlecase default so a future provider (Codex/Grok) renders sanely; truncated to
|
|
42
|
+
// the column width so a long name can never misalign the row.
|
|
43
|
+
const PROVIDER_LABELS = { anthropic: 'Anthropic', zai: 'z.ai', kimi: 'Moonshot', openai: 'OpenAI', codex: 'Codex', grok: 'Grok', xai: 'Grok' };
|
|
44
|
+
function providerLabel(a) {
|
|
45
|
+
const key = a.provider || (a.type === 'provider' ? 'provider' : 'anthropic');
|
|
46
|
+
const label = PROVIDER_LABELS[key] || (key.charAt(0).toUpperCase() + key.slice(1));
|
|
47
|
+
return label.slice(0, PROVIDER_W);
|
|
48
|
+
}
|
|
49
|
+
|
|
37
50
|
/**
|
|
38
51
|
* Aligned column header for the accounts table. Names the three columns that carry
|
|
39
52
|
* NO inline label (Account / Type / Status) plus a group label over the two quota
|
|
@@ -45,7 +58,7 @@ function acctHeader(W) {
|
|
|
45
58
|
const quota = W >= 88 ? 'Quota (used% · resets-in)' : 'Quota';
|
|
46
59
|
return ROW_PREFIX
|
|
47
60
|
+ 'Account'.padEnd(NAME_W) + ' '
|
|
48
|
-
+ '
|
|
61
|
+
+ 'Provider'.padEnd(PROVIDER_W) + ' '
|
|
49
62
|
+ 'Status'.padEnd(STATUS_W) + ' '
|
|
50
63
|
+ quota;
|
|
51
64
|
}
|
|
@@ -221,7 +234,7 @@ function emptyBar(label, w = 10) {
|
|
|
221
234
|
return `${ESC}100m${' '.repeat(lp)}${text}${' '.repeat(rp)}${RESET}`;
|
|
222
235
|
}
|
|
223
236
|
|
|
224
|
-
export const __tuiTest = { formatReset, quotaLabel, bar, emptyBar, strip, loadText, countdown, acctHeader, fitLine };
|
|
237
|
+
export const __tuiTest = { formatReset, quotaLabel, bar, emptyBar, strip, loadText, countdown, acctHeader, fitLine, providerLabel };
|
|
225
238
|
|
|
226
239
|
function timestamp() {
|
|
227
240
|
return new Date().toLocaleTimeString('en-US', { hour12: false });
|
|
@@ -422,11 +435,9 @@ export class TUI {
|
|
|
422
435
|
'Reload account credentials and newly added accounts from the config file.',
|
|
423
436
|
() => this._doSync(),
|
|
424
437
|
);
|
|
425
|
-
} else if (k === 't' && this.am.accounts.length > 0) {
|
|
426
|
-
// Manual on/off toggle straight from the top level (also under a → Accounts).
|
|
427
|
-
// select → confirm → returns to 'normal', so this never strands the user.
|
|
428
|
-
this._startSelection('toggle');
|
|
429
438
|
}
|
|
439
|
+
// Enable/disable lives ONLY under [a] Accounts now (with rename/delete/login) —
|
|
440
|
+
// one home for every account mutation, instead of a duplicate top-level toggle.
|
|
430
441
|
}
|
|
431
442
|
|
|
432
443
|
_keyAccounts(k) {
|
|
@@ -469,9 +480,9 @@ export class TUI {
|
|
|
469
480
|
);
|
|
470
481
|
} else if (k === 'p' && this.am.accounts.some(account => account.type !== 'provider')) {
|
|
471
482
|
this._startSelection('prefer');
|
|
472
|
-
} else if (k === 'f') {
|
|
483
|
+
} else if (k === 'f' || k === 'F') {
|
|
473
484
|
// Cycle the cross-provider fallback policy in place — reversible + non-destructive,
|
|
474
|
-
// so no confirm dialog (unlike restart/delete).
|
|
485
|
+
// so no confirm dialog (unlike restart/delete). Accept F too (Shift-f muscle memory).
|
|
475
486
|
this._cycleCrossProviderPolicy();
|
|
476
487
|
} else if (k === 'esc' || k === 'q') {
|
|
477
488
|
this.mode = 'normal';
|
|
@@ -554,9 +565,25 @@ export class TUI {
|
|
|
554
565
|
this.selIdx = selectable.includes(this.am.currentIndex) ? this.am.currentIndex : selectable[0];
|
|
555
566
|
}
|
|
556
567
|
|
|
568
|
+
// Real am.accounts indices in DISPLAY order: non-provider (Claude/OAuth/apikey)
|
|
569
|
+
// accounts first, providers (GLM/Kimi fallback) last, stable within each group.
|
|
570
|
+
// The single source of truth for row order — the render loop AND selection nav
|
|
571
|
+
// both iterate it, so the highlight can never desync from the visible list. The
|
|
572
|
+
// canonical am.accounts array order is never mutated (routing/index actions safe).
|
|
573
|
+
_displayOrder() {
|
|
574
|
+
const nonProv = [];
|
|
575
|
+
const prov = [];
|
|
576
|
+
this.am.accounts.forEach((a, i) => (a.type === 'provider' ? prov : nonProv).push(i));
|
|
577
|
+
return [...nonProv, ...prov];
|
|
578
|
+
}
|
|
579
|
+
|
|
557
580
|
_selectableIndexes(action) {
|
|
558
|
-
|
|
559
|
-
|
|
581
|
+
// Map over _displayOrder() BEFORE filtering so the nav array is in DISPLAY order
|
|
582
|
+
// with the existing selectability filter intact — _keySelect steps this array, so
|
|
583
|
+
// visual order == nav order automatically and it can never land on a provider
|
|
584
|
+
// (prefer) or a non-configurable runtime account.
|
|
585
|
+
return this._displayOrder()
|
|
586
|
+
.map(index => ({ account: this.am.accounts[index], index }))
|
|
560
587
|
.filter(({ account }) => {
|
|
561
588
|
if (action === 'prefer') return account.type !== 'provider' && account.enabled;
|
|
562
589
|
return this._configAccountIndex(account) >= 0;
|
|
@@ -627,6 +654,12 @@ export class TUI {
|
|
|
627
654
|
async _doLogin() {
|
|
628
655
|
const wasRunning = this.running;
|
|
629
656
|
if (wasRunning) this.stop();
|
|
657
|
+
// Mute the console-log MIRROR to stdout during the interactive prompt so routing
|
|
658
|
+
// logs (console.log, e.g. the glm-fallback 429 churn) don't flood over the readline
|
|
659
|
+
// "Name this account" prompt. The prompts + errors use process.stdout.write, which
|
|
660
|
+
// BYPASSES the mirror, so they stay visible. Login runs only on the terminal-owning
|
|
661
|
+
// primary (mirror baseline false), so the finally restores it to false.
|
|
662
|
+
setConsoleStdoutSuppressed(true);
|
|
630
663
|
try {
|
|
631
664
|
process.stdout.write('\nOpening browser to log into Claude…\n');
|
|
632
665
|
const creds = await loginOAuth();
|
|
@@ -644,6 +677,9 @@ export class TUI {
|
|
|
644
677
|
rl.close();
|
|
645
678
|
name = String(answer || '').trim() || suggested;
|
|
646
679
|
}
|
|
680
|
+
// Lift the mute BEFORE the upsert so its persist-confirmation (a console.log,
|
|
681
|
+
// deliberately visible during the stopped-TUI login) reaches the user.
|
|
682
|
+
setConsoleStdoutSuppressed(false);
|
|
647
683
|
// Message honors the ACTUAL result: if a manually-typed name collided with a
|
|
648
684
|
// different existing account, upsert updates in place → "Re-authenticated", never
|
|
649
685
|
// a false "Added new account".
|
|
@@ -654,6 +690,7 @@ export class TUI {
|
|
|
654
690
|
} catch (e) {
|
|
655
691
|
process.stdout.write(`\nLogin failed: ${e.message}\n`);
|
|
656
692
|
} finally {
|
|
693
|
+
setConsoleStdoutSuppressed(false); // restore on every path (incl. the catch)
|
|
657
694
|
if (wasRunning) this.start();
|
|
658
695
|
}
|
|
659
696
|
}
|
|
@@ -933,14 +970,34 @@ export class TUI {
|
|
|
933
970
|
// ── Header
|
|
934
971
|
const v = this.am.versionInfo;
|
|
935
972
|
const verStr = v?.current ? ` ${dim('v' + v.current)}` : '';
|
|
936
|
-
|
|
937
|
-
// the check hasn't resolved yet (so the header never falsely implies up-to-date).
|
|
938
|
-
const updStr = (v?.hasUpdate && v?.latest) ? ` ${yellow('↑ v' + v.latest)}` : '';
|
|
939
|
-
const left = bold(' Maxpool') + verStr + updStr;
|
|
973
|
+
const left = bold(' Maxpool') + verStr;
|
|
940
974
|
const port = this.config.proxy?.port || 3456;
|
|
941
975
|
const right = `Port ${port} ${green('▲')} `;
|
|
942
976
|
lines.push(left + ' '.repeat(Math.max(1, W - vw(left) - vw(right))) + right);
|
|
943
977
|
lines.push(' ' + dim('─'.repeat(W - 2)));
|
|
978
|
+
// Restart/reload in progress: admission is paused while active requests drain, then
|
|
979
|
+
// the process swaps. Without live feedback the screen looks FROZEN (identical
|
|
980
|
+
// dashboard, nothing moving) from R→Yes until the swap — the reported bug. Show an
|
|
981
|
+
// animated draining indicator. It clears when the swap completes (fresh worker
|
|
982
|
+
// renders) or a rollback resumes admission (admissionPaused → false).
|
|
983
|
+
if (this.am.admissionPaused) {
|
|
984
|
+
// Total TUI in-flight — an honest, user-meaningful "your work is finishing" count.
|
|
985
|
+
// (The reload gates its drain on UPSTREAM requests only, so this can read slightly
|
|
986
|
+
// higher than the controller's gating count; queued/idle ones reconnect, not lost.)
|
|
987
|
+
const inflight = this.active.size;
|
|
988
|
+
const detail = inflight > 0
|
|
989
|
+
? `draining ${inflight} active request${inflight === 1 ? '' : 's'}…`
|
|
990
|
+
: 'finishing up…';
|
|
991
|
+
lines.push(` ${cyan(SPINNER[this.frame])} ${yellow('Restarting')} ${dim(detail)}`);
|
|
992
|
+
}
|
|
993
|
+
// Update reminder: a prominent, actionable banner when a newer npm version is
|
|
994
|
+
// published — nothing when on latest or the check hasn't resolved (no permanent
|
|
995
|
+
// blank line). The persistent banner IS the reminder; a long-lived session's
|
|
996
|
+
// periodic re-check keeps it current (index.js updateTimer refreshes versionInfo).
|
|
997
|
+
if (v?.hasUpdate && v?.latest) {
|
|
998
|
+
lines.push(' ' + yellow(`↑ Update available: v${v.current} → v${v.latest}`)
|
|
999
|
+
+ dim(" · run 'npm i -g maxpool', then press r"));
|
|
1000
|
+
}
|
|
944
1001
|
const routing = this.am.routingMode === 'preferred'
|
|
945
1002
|
? `Manual preference: ${this.am.preferredAccountName} (automatic failover)`
|
|
946
1003
|
: 'Automatic load balancing';
|
|
@@ -991,11 +1048,17 @@ export class TUI {
|
|
|
991
1048
|
// misaligned second header).
|
|
992
1049
|
lines.push(dimUnderline(acctHeader(W)));
|
|
993
1050
|
const showBoth = W >= 70;
|
|
1051
|
+
// 61/50 = the fixed pre-bar column span (prefix + Account + Provider + Status +
|
|
1052
|
+
// gaps); grew by +4 with the wider 16-col Account column (was 57/46 at NAME_W 12).
|
|
994
1053
|
const bw = showBoth
|
|
995
|
-
? Math.max(5, Math.min(20, Math.floor((W -
|
|
996
|
-
: Math.max(5, Math.min(20, W -
|
|
997
|
-
|
|
998
|
-
|
|
1054
|
+
? Math.max(5, Math.min(20, Math.floor((W - 61) / 2)))
|
|
1055
|
+
: Math.max(5, Math.min(20, W - 50));
|
|
1056
|
+
|
|
1057
|
+
// Claude/OAuth accounts first, providers (GLM/Kimi fallback) last — a stable
|
|
1058
|
+
// display order over the canonical am.accounts array (which stays untouched so
|
|
1059
|
+
// routing/index-keyed actions are unaffected). Selection navigation shares the
|
|
1060
|
+
// SAME order via _selectableIndexes → _displayOrder.
|
|
1061
|
+
for (const i of this._displayOrder()) {
|
|
999
1062
|
lines.push(this._renderAcct(i, bw, showBoth));
|
|
1000
1063
|
}
|
|
1001
1064
|
// Glossary FOOTER (expands the abbreviations the header + inline labels can't
|
|
@@ -1074,12 +1137,16 @@ export class TUI {
|
|
|
1074
1137
|
|
|
1075
1138
|
// Name (bold if selected)
|
|
1076
1139
|
const rawName = a.name.slice(0, NAME_W).padEnd(NAME_W);
|
|
1077
|
-
|
|
1140
|
+
// A disabled account reads as "off" — dim the whole name. Applied as a SINGLE SGR
|
|
1141
|
+
// (never dim(bold(...)) — bold()'s trailing RESET would cancel the dim mid-string),
|
|
1142
|
+
// so a disabled row is never also bold-selected.
|
|
1143
|
+
const name = a.enabled === false ? dim(rawName) : (isSel ? bold(rawName) : rawName);
|
|
1078
1144
|
|
|
1079
|
-
//
|
|
1080
|
-
//
|
|
1081
|
-
// with the
|
|
1082
|
-
|
|
1145
|
+
// Provider column — the real vendor (Anthropic / z.ai / Moonshot), padded to
|
|
1146
|
+
// PROVIDER_W so "Anthropic" (9 chars) never overflows and shifts the row (incl.
|
|
1147
|
+
// its quota bars) out of alignment with the shorter provider labels. This single
|
|
1148
|
+
// cell is reused by both the oauth/apikey row below and _renderProviderAcct.
|
|
1149
|
+
const type = gray(providerLabel(a).padEnd(PROVIDER_W));
|
|
1083
1150
|
|
|
1084
1151
|
// Status
|
|
1085
1152
|
let status;
|
|
@@ -1111,7 +1178,7 @@ export class TUI {
|
|
|
1111
1178
|
case 'probing': status = green('probing'); break;
|
|
1112
1179
|
case 'waiting': status = yellow('waiting'); break;
|
|
1113
1180
|
case 'paused': status = yellow('paused'); break;
|
|
1114
|
-
case 'disabled': status =
|
|
1181
|
+
case 'disabled': status = red('✕ disabled'); break;
|
|
1115
1182
|
case 'throttled': {
|
|
1116
1183
|
// A transient auto-recovering cooldown — show the remaining time (from
|
|
1117
1184
|
// rateLimitedUntil) so it reads as "recovering in Ns", not stuck.
|
|
@@ -1274,11 +1341,16 @@ export class TUI {
|
|
|
1274
1341
|
_renderFooter() {
|
|
1275
1342
|
switch (this.mode) {
|
|
1276
1343
|
case 'normal':
|
|
1277
|
-
return ` ${bold('a')} Accounts ${bold('
|
|
1344
|
+
return ` ${bold('a')} Accounts ${bold('m')} Routing ${bold('s')} Sync ${bold('r')} Restart ${bold('q')} Stop`;
|
|
1278
1345
|
case 'accounts':
|
|
1279
1346
|
return ` ${bold('l')} Login/re-auth (browser) ${bold('k')} API key ${bold('n')} Rename ${bold('t')} Enable/disable ${bold('d')} Delete ${bold('Esc')} Back`;
|
|
1280
|
-
case 'routing':
|
|
1281
|
-
|
|
1347
|
+
case 'routing': {
|
|
1348
|
+
// Show the CURRENT cross-provider policy inline so pressing f visibly changes it
|
|
1349
|
+
// right here at the footer (the policy also renders in the header, far from the
|
|
1350
|
+
// keypress — the "f does nothing" report).
|
|
1351
|
+
const xp = this.am._crossProviderFallbackPolicy?.() || 'when-exhausted';
|
|
1352
|
+
return ` ${bold('a')} Automatic ${bold('p')} Manual preference ${bold('f')} Cross-provider: ${cyan(xp)} ↻ ${bold('Esc')} Back`;
|
|
1353
|
+
}
|
|
1282
1354
|
case 'select': {
|
|
1283
1355
|
const act = this.selAction === 'prefer'
|
|
1284
1356
|
? 'prefer'
|