maxpool 1.5.37 → 1.5.38
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/server.js +77 -11
- package/src/tui.js +51 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "maxpool",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.38",
|
|
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/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
|
@@ -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/17/
|
|
29
|
+
// the header labels stay aligned with the columns they name. The Account/Provider/
|
|
30
|
+
// Status/Quota start offsets (4/17/27/41) are pure functions of these widths + the
|
|
31
31
|
// 4-col row prefix, independent of the quota-bar width.
|
|
32
32
|
const NAME_W = 12; // a.name.slice(0, NAME_W).padEnd(NAME_W)
|
|
33
|
-
const
|
|
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 });
|
|
@@ -554,9 +567,25 @@ export class TUI {
|
|
|
554
567
|
this.selIdx = selectable.includes(this.am.currentIndex) ? this.am.currentIndex : selectable[0];
|
|
555
568
|
}
|
|
556
569
|
|
|
570
|
+
// Real am.accounts indices in DISPLAY order: non-provider (Claude/OAuth/apikey)
|
|
571
|
+
// accounts first, providers (GLM/Kimi fallback) last, stable within each group.
|
|
572
|
+
// The single source of truth for row order — the render loop AND selection nav
|
|
573
|
+
// both iterate it, so the highlight can never desync from the visible list. The
|
|
574
|
+
// canonical am.accounts array order is never mutated (routing/index actions safe).
|
|
575
|
+
_displayOrder() {
|
|
576
|
+
const nonProv = [];
|
|
577
|
+
const prov = [];
|
|
578
|
+
this.am.accounts.forEach((a, i) => (a.type === 'provider' ? prov : nonProv).push(i));
|
|
579
|
+
return [...nonProv, ...prov];
|
|
580
|
+
}
|
|
581
|
+
|
|
557
582
|
_selectableIndexes(action) {
|
|
558
|
-
|
|
559
|
-
|
|
583
|
+
// Map over _displayOrder() BEFORE filtering so the nav array is in DISPLAY order
|
|
584
|
+
// with the existing selectability filter intact — _keySelect steps this array, so
|
|
585
|
+
// visual order == nav order automatically and it can never land on a provider
|
|
586
|
+
// (prefer) or a non-configurable runtime account.
|
|
587
|
+
return this._displayOrder()
|
|
588
|
+
.map(index => ({ account: this.am.accounts[index], index }))
|
|
560
589
|
.filter(({ account }) => {
|
|
561
590
|
if (action === 'prefer') return account.type !== 'provider' && account.enabled;
|
|
562
591
|
return this._configAccountIndex(account) >= 0;
|
|
@@ -991,11 +1020,17 @@ export class TUI {
|
|
|
991
1020
|
// misaligned second header).
|
|
992
1021
|
lines.push(dimUnderline(acctHeader(W)));
|
|
993
1022
|
const showBoth = W >= 70;
|
|
1023
|
+
// 57/46 = the fixed pre-bar column span (prefix + Account + Provider + Status +
|
|
1024
|
+
// gaps); grew by 1 with the Provider column (was 56/45 under the 8-wide Type col).
|
|
994
1025
|
const bw = showBoth
|
|
995
|
-
? Math.max(5, Math.min(20, Math.floor((W -
|
|
996
|
-
: Math.max(5, Math.min(20, W -
|
|
997
|
-
|
|
998
|
-
|
|
1026
|
+
? Math.max(5, Math.min(20, Math.floor((W - 57) / 2)))
|
|
1027
|
+
: Math.max(5, Math.min(20, W - 46));
|
|
1028
|
+
|
|
1029
|
+
// Claude/OAuth accounts first, providers (GLM/Kimi fallback) last — a stable
|
|
1030
|
+
// display order over the canonical am.accounts array (which stays untouched so
|
|
1031
|
+
// routing/index-keyed actions are unaffected). Selection navigation shares the
|
|
1032
|
+
// SAME order via _selectableIndexes → _displayOrder.
|
|
1033
|
+
for (const i of this._displayOrder()) {
|
|
999
1034
|
lines.push(this._renderAcct(i, bw, showBoth));
|
|
1000
1035
|
}
|
|
1001
1036
|
// Glossary FOOTER (expands the abbreviations the header + inline labels can't
|
|
@@ -1076,10 +1111,11 @@ export class TUI {
|
|
|
1076
1111
|
const rawName = a.name.slice(0, NAME_W).padEnd(NAME_W);
|
|
1077
1112
|
const name = isSel ? bold(rawName) : rawName;
|
|
1078
1113
|
|
|
1079
|
-
//
|
|
1080
|
-
//
|
|
1081
|
-
// with the
|
|
1082
|
-
|
|
1114
|
+
// Provider column — the real vendor (Anthropic / z.ai / Moonshot), padded to
|
|
1115
|
+
// PROVIDER_W so "Anthropic" (9 chars) never overflows and shifts the row (incl.
|
|
1116
|
+
// its quota bars) out of alignment with the shorter provider labels. This single
|
|
1117
|
+
// cell is reused by both the oauth/apikey row below and _renderProviderAcct.
|
|
1118
|
+
const type = gray(providerLabel(a).padEnd(PROVIDER_W));
|
|
1083
1119
|
|
|
1084
1120
|
// Status
|
|
1085
1121
|
let status;
|