maxpool 1.3.0 → 1.4.0
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/account-manager.js +83 -1
- package/src/server.js +6 -1
- package/src/tui.js +33 -5
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -9,6 +9,16 @@ import { refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
|
|
|
9
9
|
// an Infinity session-kill / error-fast.
|
|
10
10
|
const BOUNDED_REPOLL_HOLD_MS = 60_000;
|
|
11
11
|
|
|
12
|
+
// Session rebalancing (issue #1): a bound session may migrate OFF a hot account
|
|
13
|
+
// onto a much-healthier one, but ONLY on a thinking-safe request (see
|
|
14
|
+
// _migrationSafeForRequest). These margins force a CLEAR, flap-stable win so a
|
|
15
|
+
// session can never ping-pong between two similarly-loaded accounts.
|
|
16
|
+
const REBALANCE_SCORE_MARGIN = 0.5; // candidate score must be ≤ 50% of the bound account's
|
|
17
|
+
const REBALANCE_MIN_ABS_GAP = 0.5; // …with a small absolute floor so near-zero scores don't micro-churn
|
|
18
|
+
// Weekly-pressure tiers, healthiest first. A fresh (unknown) account is the best
|
|
19
|
+
// migration target; migration requires the candidate be a STRICTLY healthier tier.
|
|
20
|
+
const WEEKLY_TIER = { unknown: 0, normal: 0, soft: 1, reserve: 2, critical: 3, exhausted: 4 };
|
|
21
|
+
|
|
12
22
|
function emptyQuota() {
|
|
13
23
|
return {
|
|
14
24
|
// Standard API rate limits (API key accounts)
|
|
@@ -860,6 +870,62 @@ export class AccountManager {
|
|
|
860
870
|
|| ['reserve', 'critical', 'exhausted'].includes(this._weeklyRawState(account));
|
|
861
871
|
}
|
|
862
872
|
|
|
873
|
+
// ── Session rebalancing (issue #1) ────────────────────────────────────────
|
|
874
|
+
// A bound session normally sticks to its account (continuity + Anthropic signed-
|
|
875
|
+
// thinking signature validity). These let it migrate OFF a hot account onto fresh
|
|
876
|
+
// capacity, but only when it's provably safe and clearly worth it.
|
|
877
|
+
|
|
878
|
+
/** Per-request safety gate: a request is safe to migrate to a DIFFERENT account
|
|
879
|
+
* iff its body carries NO signed thinking (replaying a signed thinking block to
|
|
880
|
+
* another account → non-retryable "invalid signature"). Uses the PER-REQUEST
|
|
881
|
+
* body signal only — never the session-sticky policy — and fails CLOSED on any
|
|
882
|
+
* body we couldn't fully scan (non-JSON / parse error → bodyThinkingScanned unset). */
|
|
883
|
+
_migrationSafeForRequest(requestInfo = {}) {
|
|
884
|
+
return requestInfo.bodyThinkingScanned === true
|
|
885
|
+
&& requestInfo.requiresAnthropicThinkingIntegrity !== true;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** Flap-stable "hot": weekly reserve/critical or 5h-cap pressure — signals that
|
|
889
|
+
* do NOT flip the instant a request migrates (unlike live in-flight, left to the
|
|
890
|
+
* score loop). A healthy bound account is never hot, so it never migrates → no
|
|
891
|
+
* ping-pong. */
|
|
892
|
+
_isBoundAccountHot(account) {
|
|
893
|
+
return this._isNearQuota(account);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/** Decide whether a bound session should leave its (hot) account THIS request.
|
|
897
|
+
* All gates must hold: thinking-safe + not mid queue-admission + bound is hot +
|
|
898
|
+
* a genuinely-healthy alternative that is BOTH much cheaper AND a strictly
|
|
899
|
+
* healthier weekly tier (so concurrency jitter alone can never trigger a move). */
|
|
900
|
+
_shouldRebalanceBoundSession(bound, profile, excludedIndexes, requestInfo, scoringCtx) {
|
|
901
|
+
if (!this._migrationSafeForRequest(requestInfo)) return false;
|
|
902
|
+
if (requestInfo.queueTicket || requestInfo.queueAdmitted) return false;
|
|
903
|
+
if (!this._isBoundAccountHot(bound)) return false;
|
|
904
|
+
|
|
905
|
+
const boundScore = this._scoreAccount(bound, requestInfo, scoringCtx);
|
|
906
|
+
const boundTier = WEEKLY_TIER[this._weeklyRawState(bound)] ?? 0;
|
|
907
|
+
|
|
908
|
+
let bestScore = Infinity;
|
|
909
|
+
let bestTier = Infinity;
|
|
910
|
+
for (const account of this.accounts) {
|
|
911
|
+
if (account.index === bound.index) continue;
|
|
912
|
+
if (excludedIndexes.has(account.index)) continue;
|
|
913
|
+
if (!this._matchesRequest(account, profile, requestInfo)) continue;
|
|
914
|
+
// Genuinely-healthy alternatives only (normal/soft/unknown weekly).
|
|
915
|
+
if (!this._isAvailable(account, { allowWeeklyReserve: false, allowWeeklyCritical: false })) continue;
|
|
916
|
+
const score = this._scoreAccount(account, requestInfo, scoringCtx);
|
|
917
|
+
if (score < bestScore) {
|
|
918
|
+
bestScore = score;
|
|
919
|
+
bestTier = WEEKLY_TIER[this._weeklyRawState(account)] ?? 0;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
if (!Number.isFinite(bestScore)) return false; // no healthy alternative
|
|
923
|
+
|
|
924
|
+
return bestScore <= boundScore * REBALANCE_SCORE_MARGIN
|
|
925
|
+
&& (boundScore - bestScore) >= REBALANCE_MIN_ABS_GAP
|
|
926
|
+
&& bestTier < boundTier;
|
|
927
|
+
}
|
|
928
|
+
|
|
863
929
|
_retryInfo(account) {
|
|
864
930
|
const now = Date.now();
|
|
865
931
|
const q = account.quota || {};
|
|
@@ -989,7 +1055,13 @@ export class AccountManager {
|
|
|
989
1055
|
}
|
|
990
1056
|
}
|
|
991
1057
|
const bound = this._boundAccount(requestInfo.sessionKey, profile, excludedIndexes, requestInfo);
|
|
992
|
-
if (bound
|
|
1058
|
+
if (bound
|
|
1059
|
+
&& !this._hasHigherPriorityAvailable(bound, profile, excludedIndexes, requestInfo)
|
|
1060
|
+
&& !this._shouldRebalanceBoundSession(bound, profile, excludedIndexes, requestInfo, scoringCtx)) {
|
|
1061
|
+
return bound;
|
|
1062
|
+
}
|
|
1063
|
+
// Else fall through to the candidate score loop, which re-homes the session
|
|
1064
|
+
// onto the best healthy account via _bindSession on acquire.
|
|
993
1065
|
|
|
994
1066
|
const weeklyPasses = hasBinding
|
|
995
1067
|
? [
|
|
@@ -1106,6 +1178,16 @@ export class AccountManager {
|
|
|
1106
1178
|
if (!binding.homeName || priority < binding.homePriority) {
|
|
1107
1179
|
binding.homeName = account.name;
|
|
1108
1180
|
binding.homePriority = priority;
|
|
1181
|
+
} else if (priority === binding.homePriority && account.name !== binding.homeName) {
|
|
1182
|
+
// Same-priority move. If the previous home is still AVAILABLE we left it by
|
|
1183
|
+
// CHOICE (session rebalancing off a hot-but-usable account) → re-home, so
|
|
1184
|
+
// _boundAccount (which prefers homeName) doesn't snap the session straight
|
|
1185
|
+
// back to the hot home next request. If the old home is UNAVAILABLE this is a
|
|
1186
|
+
// FAILOVER → keep homeName so the session returns to it once it recovers.
|
|
1187
|
+
const oldHome = this.accounts.find(a => a.name === binding.homeName);
|
|
1188
|
+
if (oldHome && this._isAvailable(oldHome, { allowWeeklyReserve: true })) {
|
|
1189
|
+
binding.homeName = account.name;
|
|
1190
|
+
}
|
|
1109
1191
|
}
|
|
1110
1192
|
binding.currentName = account.name;
|
|
1111
1193
|
this.sessionBindings.set(sessionKey, binding);
|
package/src/server.js
CHANGED
|
@@ -930,7 +930,7 @@ function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRe
|
|
|
930
930
|
return `All ${n} accounts exhausted. Retry in ${retryAfter}s.`;
|
|
931
931
|
}
|
|
932
932
|
|
|
933
|
-
export const __serverTest = { unavailableMessage, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat };
|
|
933
|
+
export const __serverTest = { unavailableMessage, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, describeRequest };
|
|
934
934
|
|
|
935
935
|
async function readErrorBody(upstreamRes, limitBytes = 64 * 1024) {
|
|
936
936
|
if (!upstreamRes.body) return '';
|
|
@@ -1375,6 +1375,11 @@ function describeRequest(req, body) {
|
|
|
1375
1375
|
if (requiresAnthropicThinkingIntegrity(json)) {
|
|
1376
1376
|
info.requiresAnthropicThinkingIntegrity = true;
|
|
1377
1377
|
}
|
|
1378
|
+
// We fully scanned this body for signed-thinking content. Only a successfully
|
|
1379
|
+
// scanned, thinking-free body is safe to migrate to another account (session
|
|
1380
|
+
// rebalancing); an unparsed body leaves this false → treated as NOT safe
|
|
1381
|
+
// (fail-closed) so we never replay a signed thinking block to a new account.
|
|
1382
|
+
info.bodyThinkingScanned = true;
|
|
1378
1383
|
} catch {
|
|
1379
1384
|
// Non-JSON requests are rare; body size still gives a useful load signal.
|
|
1380
1385
|
}
|
package/src/tui.js
CHANGED
|
@@ -93,16 +93,37 @@ function statusColor(status) {
|
|
|
93
93
|
return String(status);
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/** Short live countdown to a timestamp, SINGLE-unit so it stays ≤3 chars
|
|
97
|
+
* ("41s"/"5m"/"23h"/"2d") and never overflows the status column: seconds under a
|
|
98
|
+
* minute (the common throttle cooldown), then minute/hour/day. '' once elapsed.
|
|
99
|
+
* Accepts a numeric ms timestamp (live account field) or an ISO string (snapshot). */
|
|
100
|
+
function countdown(ts) {
|
|
101
|
+
if (!ts) return '';
|
|
102
|
+
const target = typeof ts === 'string' ? Date.parse(ts) : ts;
|
|
103
|
+
const ms = target - Date.now();
|
|
104
|
+
if (!Number.isFinite(ms) || ms <= 0) return '';
|
|
105
|
+
if (ms < 60_000) return `${Math.ceil(ms / 1000)}s`;
|
|
106
|
+
if (ms < 3_600_000) return `${Math.ceil(ms / 60_000)}m`;
|
|
107
|
+
if (ms < 86_400_000) return `${Math.ceil(ms / 3_600_000)}h`;
|
|
108
|
+
return `${Math.ceil(ms / 86_400_000)}d`;
|
|
109
|
+
}
|
|
110
|
+
|
|
96
111
|
function loadText(load) {
|
|
97
112
|
const cur = load?.current || {};
|
|
98
113
|
const m15 = load?.last15m || {};
|
|
99
114
|
const h1 = load?.last1h || {};
|
|
100
|
-
|
|
115
|
+
// "Now" = what this account is handling right THIS moment: N in-flight requests
|
|
116
|
+
// (and their combined weight ~ payload size, the scheduler's load input). Kept
|
|
117
|
+
// distinct from the "15m"/"1h" THROUGHPUT counts that follow, which the old
|
|
118
|
+
// "Load X/Y" label collided with.
|
|
119
|
+
const inflight = cur.inFlight || 0;
|
|
120
|
+
const weight = cur.activeWeight || 0;
|
|
121
|
+
const now = weight > 0 ? `Now ${inflight} (${weight}w)` : `Now ${inflight}`;
|
|
101
122
|
const recent = `${m15.requests || 0}r`;
|
|
102
123
|
const recentAvg = m15.avgMs != null ? ` ${formatMs(m15.avgMs)}` : '';
|
|
103
124
|
const hour = `${h1.requests || 0}r`;
|
|
104
125
|
const fails = (m15.failed || 0) > 0 ? ` ${red(`${m15.failed}f`)}` : '';
|
|
105
|
-
return
|
|
126
|
+
return `${now} 15m ${recent}${recentAvg}${fails} 1h ${hour}`;
|
|
106
127
|
}
|
|
107
128
|
|
|
108
129
|
function weeklyPolicyText(am, account) {
|
|
@@ -161,7 +182,7 @@ function bar(ratio, w = 10, resetTs) {
|
|
|
161
182
|
return out;
|
|
162
183
|
}
|
|
163
184
|
|
|
164
|
-
export const __tuiTest = { formatReset, quotaLabel, bar, strip };
|
|
185
|
+
export const __tuiTest = { formatReset, quotaLabel, bar, strip, loadText, countdown };
|
|
165
186
|
|
|
166
187
|
function timestamp() {
|
|
167
188
|
return new Date().toLocaleTimeString('en-US', { hour12: false });
|
|
@@ -906,12 +927,19 @@ export class TUI {
|
|
|
906
927
|
case 'waiting': status = yellow('waiting'); break;
|
|
907
928
|
case 'paused': status = yellow('paused'); break;
|
|
908
929
|
case 'disabled': status = gray('disabled'); break;
|
|
909
|
-
case 'throttled':
|
|
930
|
+
case 'throttled': {
|
|
931
|
+
// A transient auto-recovering cooldown — show the remaining time (from
|
|
932
|
+
// rateLimitedUntil) so it reads as "recovering in Ns", not stuck.
|
|
933
|
+
const cd = countdown(a.rateLimitedUntil);
|
|
934
|
+
status = yellow(cd ? `throttled ${cd}` : 'throttled');
|
|
935
|
+
break;
|
|
936
|
+
}
|
|
910
937
|
case 'exhausted': status = red('exhausted'); break;
|
|
911
938
|
case 'error': status = red('error'); break;
|
|
912
939
|
default: status = a.status || 'ready';
|
|
913
940
|
}
|
|
914
|
-
|
|
941
|
+
// Widened from 10 to fit "throttled 59s" so the quota bars stay column-aligned.
|
|
942
|
+
status = rpad(status, 13);
|
|
915
943
|
|
|
916
944
|
if (a.type === 'provider') {
|
|
917
945
|
return this._renderProviderAcct(sel, cur, name, type, status, a);
|