maxpool 1.3.1 → 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/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
|
}
|