maxpool 1.5.9 → 1.5.11
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 +44 -5
- package/src/config.js +1 -1
- package/src/index.js +15 -0
- package/src/server.js +60 -28
- package/src/tui.js +11 -0
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -75,10 +75,12 @@ const DEFAULT_SCHEDULER = {
|
|
|
75
75
|
// (never a provider — GLM/Kimi can't validate an Anthropic signature). Anthropic
|
|
76
76
|
// thinking-block signatures are content/model integrity, NOT account-bound —
|
|
77
77
|
// verified empirically 2026-07-02 (a `partnerships`-signed block replayed under
|
|
78
|
-
// `personal` returned 200).
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
78
|
+
// `personal` returned 200). ON by default: a heavy thinking session can now spread
|
|
79
|
+
// its later load onto fresh accounts instead of stranding one. The revert-to-issuer
|
|
80
|
+
// fail-safe (server.js, on `invalid_thinking_signature` for a migrated request)
|
|
81
|
+
// makes this safe even if Anthropic ever account-binds signatures — a rejected
|
|
82
|
+
// replay self-heals to the issuer instead of poisoning the session.
|
|
83
|
+
crossAccountThinkingMigration: true,
|
|
82
84
|
};
|
|
83
85
|
const LOAD_EVENT_MAX_AGE_MS = 60 * 60 * 1000;
|
|
84
86
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
@@ -438,6 +440,12 @@ export class AccountManager {
|
|
|
438
440
|
|
|
439
441
|
acquireAccount(requestInfo = {}, excludedIndexes = new Set()) {
|
|
440
442
|
this._noteRequestPolicy(requestInfo);
|
|
443
|
+
// Capture the session's prior account BEFORE selection, so the caller can tell
|
|
444
|
+
// whether this acquire MOVED the session (the thinking-signature fail-safe needs
|
|
445
|
+
// the pre-migration issuing account to revert to).
|
|
446
|
+
const prevCurrentName = requestInfo.sessionKey
|
|
447
|
+
? this._sessionBinding(requestInfo.sessionKey)?.currentName
|
|
448
|
+
: null;
|
|
441
449
|
const account = this.getActiveAccount(requestInfo, excludedIndexes);
|
|
442
450
|
if (!account) return null;
|
|
443
451
|
|
|
@@ -449,7 +457,22 @@ export class AccountManager {
|
|
|
449
457
|
account.inFlight++;
|
|
450
458
|
account.activeWeight += weight;
|
|
451
459
|
account.lastUsedAt = Date.now();
|
|
452
|
-
|
|
460
|
+
// Non-null only when this acquire moved the session off its prior account.
|
|
461
|
+
const migratedFromName = (prevCurrentName && account.name !== prevCurrentName) ? prevCurrentName : null;
|
|
462
|
+
return { account, weight, startedAt: Date.now(), upstreamThrottleProbe, migratedFromName };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Fail-safe: snap a session's binding back to the pre-migration issuing account
|
|
466
|
+
* after a rejected cross-account thinking replay, so the retry (and future
|
|
467
|
+
* requests) route to the account that actually generated the thinking blocks.
|
|
468
|
+
* Defensive — signatures are portable in practice (verified 2026-07-02); this
|
|
469
|
+
* only fires if Anthropic ever account-binds them. */
|
|
470
|
+
revertSessionBinding(sessionKey, name) {
|
|
471
|
+
if (!sessionKey || !name) return;
|
|
472
|
+
const binding = this._sessionBinding(sessionKey);
|
|
473
|
+
if (!binding) return;
|
|
474
|
+
binding.currentName = name;
|
|
475
|
+
this.sessionBindings.set(sessionKey, binding);
|
|
453
476
|
}
|
|
454
477
|
|
|
455
478
|
releaseAccount(lease, outcome = {}) {
|
|
@@ -1136,6 +1159,22 @@ export class AccountManager {
|
|
|
1136
1159
|
const profile = requestInfo.profile || 'claude';
|
|
1137
1160
|
const scoringCtx = this._scoringContext();
|
|
1138
1161
|
|
|
1162
|
+
// Fail-safe retry pin: steer this request's remaining retry/queue chain onto a
|
|
1163
|
+
// specific account (the pre-migration issuer, after a cross-account thinking
|
|
1164
|
+
// replay was rejected). Honored ahead of everything else, but FALLS THROUGH to
|
|
1165
|
+
// normal selection whenever that account is excluded/unavailable — a down issuer
|
|
1166
|
+
// never strands the request (the retry then re-migrates and terminates via
|
|
1167
|
+
// excludedIndexes + maxAttempts). See the thinking-signature fail-safe in server.js.
|
|
1168
|
+
if (requestInfo.pinnedAccountName) {
|
|
1169
|
+
const pinned = this.accounts.find(a => a.name === requestInfo.pinnedAccountName);
|
|
1170
|
+
if (pinned && !excludedIndexes.has(pinned.index)
|
|
1171
|
+
&& this._matchesRequest(pinned, profile, requestInfo)
|
|
1172
|
+
&& this._isAvailable(pinned, { allowWeeklyReserve: true, allowWeeklyCritical: true })) {
|
|
1173
|
+
this.currentIndex = pinned.index;
|
|
1174
|
+
return pinned;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1139
1178
|
const hasBinding = Boolean(requestInfo.sessionKey && this.sessionBindings.has(requestInfo.sessionKey));
|
|
1140
1179
|
const preferred = this._preferredAccount(profile, excludedIndexes, requestInfo);
|
|
1141
1180
|
if (preferred) {
|
package/src/config.js
CHANGED
|
@@ -111,7 +111,7 @@ export function createDefaultConfig() {
|
|
|
111
111
|
enabled: true,
|
|
112
112
|
maxWaitMs: 24 * 60 * 60 * 1000, // hard ceiling for non-streaming/capacity holds; streaming uses streamHoldMaxMs
|
|
113
113
|
autoMaxWaitMs: null, // 5h/session-cap hold (null = maxWaitMs)
|
|
114
|
-
capacityMaxWaitMs: 15 * 60 * 1000, //
|
|
114
|
+
capacityMaxWaitMs: 15 * 60 * 1000, // short cap for NON-streaming capacity holds + the concurrency-cap clamp. A STREAMING capacity/throttle hold uses maxWaitMs (held on the heartbeat until capacity frees), NOT this.
|
|
115
115
|
weeklyMaxWaitMs: 24 * 60 * 60 * 1000, // legacy bound; streaming holds use streamHoldMaxMs
|
|
116
116
|
// Streaming hold ceiling: how long a streaming session is held ALIVE on the
|
|
117
117
|
// heartbeat waiting for any account to free up. 7d so a session is never
|
package/src/index.js
CHANGED
|
@@ -139,6 +139,10 @@ async function serverCommand() {
|
|
|
139
139
|
|
|
140
140
|
async function supervisorCommand() {
|
|
141
141
|
const { createServer } = await import('node:net');
|
|
142
|
+
// Test-only: the restart integration test delivers a GROUP SIGUSR2 to drive the
|
|
143
|
+
// worker's restart path; ignore it on the supervisor so the group signal doesn't
|
|
144
|
+
// kill it (default SIGUSR2 action is terminate). Gated — never active normally.
|
|
145
|
+
if (process.env.MAXPOOL_TEST_RESTART_SIGNAL === '1') process.on('SIGUSR2', () => {});
|
|
142
146
|
const config = await loadOrCreateConfig();
|
|
143
147
|
initEventLog(config, { manageRotation: true }); // supervisor is the single rotation owner
|
|
144
148
|
const port = config.proxy.port;
|
|
@@ -733,8 +737,19 @@ async function serverWorkerCommand() {
|
|
|
733
737
|
restartController = new RestartController({
|
|
734
738
|
pauseAdmission: () => accountManager.setAdmissionPaused(true),
|
|
735
739
|
restartNow: requestReload,
|
|
740
|
+
// Configurable so ops can tune the bounded pre-restart drain, and so the
|
|
741
|
+
// integration test can exercise the force-restart path without a 10s wait.
|
|
742
|
+
...(Number.isFinite(config.restartDrainTimeoutMs) ? { drainTimeoutMs: config.restartDrainTimeoutMs } : {}),
|
|
736
743
|
});
|
|
737
744
|
|
|
745
|
+
// Test-only: drive the real interactive restart path (the TUI `r` key) headless,
|
|
746
|
+
// so the end-to-end "restart while requests are in flight completes bounded and
|
|
747
|
+
// the server comes back" is provable without allocating a pty. Gated — never
|
|
748
|
+
// active in normal runs.
|
|
749
|
+
if (process.env.MAXPOOL_TEST_RESTART_SIGNAL === '1') {
|
|
750
|
+
process.on('SIGUSR2', () => restartController?.requestRestart());
|
|
751
|
+
}
|
|
752
|
+
|
|
738
753
|
const shutdownGracefully = (reason, options = {}) => {
|
|
739
754
|
// A terminal-close shutdown must NEVER exit non-zero: the supervisor reads a
|
|
740
755
|
// fast non-zero exit as a crash and respawns a fresh worker — onto the now-dead
|
package/src/server.js
CHANGED
|
@@ -746,6 +746,24 @@ async function forwardRequest(
|
|
|
746
746
|
}
|
|
747
747
|
if (errorType === 'invalid_thinking_signature') {
|
|
748
748
|
console.log(`[Maxpool] Non-retryable Anthropic thinking signature error on "${account.name}"`);
|
|
749
|
+
// Fail-safe: if this request had been MIGRATED to a different Claude account
|
|
750
|
+
// this turn (cross-account thinking rebalance), the rejected block was issued
|
|
751
|
+
// by the PRE-MIGRATION account. Revert the session there, exclude the failed
|
|
752
|
+
// target, and retry PINNED to the issuer — so a rejected cross-account replay
|
|
753
|
+
// self-heals instead of poisoning the session into a 400 loop. Signatures are
|
|
754
|
+
// portable in practice (verified 2026-07-02); this only fires if Anthropic
|
|
755
|
+
// ever account-binds them.
|
|
756
|
+
if (lease.migratedFromName && requestInfo.sessionKey
|
|
757
|
+
&& canRetryBufferedBody && retryCount + 1 < maxAttempts && !res.headersSent) {
|
|
758
|
+
accountManager.revertSessionBinding(requestInfo.sessionKey, lease.migratedFromName);
|
|
759
|
+
excludedIndexes.add(account.index);
|
|
760
|
+
console.log(`[Maxpool] Thinking-signature fail-safe: reverting session to issuer "${lease.migratedFromName}" and retrying`);
|
|
761
|
+
return forwardRequest(
|
|
762
|
+
req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir,
|
|
763
|
+
retryConfig, queueConfig, { ...requestInfo, pinnedAccountName: lease.migratedFromName },
|
|
764
|
+
canRetryBufferedBody, canQueueBufferedBody, excludedIndexes,
|
|
765
|
+
);
|
|
766
|
+
}
|
|
749
767
|
}
|
|
750
768
|
|
|
751
769
|
ctx.status = upstreamRes.status;
|
|
@@ -934,6 +952,38 @@ function formatRetryDuration(seconds) {
|
|
|
934
952
|
return `${s}s`;
|
|
935
953
|
}
|
|
936
954
|
|
|
955
|
+
/**
|
|
956
|
+
* Hold-window ceiling for a queued request. The soonest-recovery oracle
|
|
957
|
+
* (nextRetryForRequest) is the real governor — this is the backstop ceiling that
|
|
958
|
+
* bounds how long a request may WAIT for that recovery.
|
|
959
|
+
*
|
|
960
|
+
* non-streaming (no heartbeat) → short cap (would die on the client's own timeout)
|
|
961
|
+
* streaming + capacity/429/throttle → maxWaitMs (24h): HELD on the SSE heartbeat
|
|
962
|
+
* until an account frees. This is the "hold, don't fail" fix — a throttle clears
|
|
963
|
+
* in seconds and a 5h session-cap in hours, both far under 24h; only a multi-day
|
|
964
|
+
* weekly reset exceeds it and error-fasts with the honest "add an account"
|
|
965
|
+
* message. Previously capped at the short capacityMaxWaitMs (15m), which failed
|
|
966
|
+
* the MOST-clearly-temporary throttle sooner than an ordinary per-account 429.
|
|
967
|
+
* streaming + other (quota) → streamHoldMaxMs (7d)
|
|
968
|
+
* retryPlanCause==='concurrency_cap' → clamped to the short capacity window (a LOCAL
|
|
969
|
+
* transient: a slot frees in seconds; never spin a multi-day hold on it).
|
|
970
|
+
*/
|
|
971
|
+
function computeQueueWindowMs({
|
|
972
|
+
cause, stream, retryPlanCause,
|
|
973
|
+
maxWaitMs, capacityMaxWaitMs, nonStreamMaxWaitMs, streamHoldMaxMs,
|
|
974
|
+
}) {
|
|
975
|
+
let windowMs;
|
|
976
|
+
if (!stream) {
|
|
977
|
+
windowMs = cause === 'capacity' ? Math.min(nonStreamMaxWaitMs, capacityMaxWaitMs) : nonStreamMaxWaitMs;
|
|
978
|
+
} else if (cause === 'capacity') {
|
|
979
|
+
windowMs = maxWaitMs;
|
|
980
|
+
} else {
|
|
981
|
+
windowMs = streamHoldMaxMs;
|
|
982
|
+
}
|
|
983
|
+
if (retryPlanCause === 'concurrency_cap') windowMs = Math.min(windowMs, capacityMaxWaitMs);
|
|
984
|
+
return windowMs;
|
|
985
|
+
}
|
|
986
|
+
|
|
937
987
|
function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRecoverSoon = true) {
|
|
938
988
|
const thinking = requestInfo.requiresAnthropicThinkingIntegrity
|
|
939
989
|
|| accountManager._requiresAnthropicThinkingIntegrity?.(requestInfo);
|
|
@@ -958,7 +1008,7 @@ function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRe
|
|
|
958
1008
|
return `All ${n} accounts exhausted. Retry in ${retryAfter}s.`;
|
|
959
1009
|
}
|
|
960
1010
|
|
|
961
|
-
export const __serverTest = { unavailableMessage, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, describeRequest };
|
|
1011
|
+
export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, describeRequest };
|
|
962
1012
|
|
|
963
1013
|
async function readErrorBody(upstreamRes, limitBytes = 64 * 1024) {
|
|
964
1014
|
if (!upstreamRes.body) return '';
|
|
@@ -1213,33 +1263,15 @@ async function queueAndRetry(
|
|
|
1213
1263
|
const streamHoldMaxMs = queueConfig.streamHoldMaxMs == null
|
|
1214
1264
|
? 7 * 24 * 60 * 60 * 1000
|
|
1215
1265
|
: Math.max(0, Number(queueConfig.streamHoldMaxMs) || 0);
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
}
|
|
1225
|
-
queueWindowMs = nonStreamMaxWaitMs;
|
|
1226
|
-
} else {
|
|
1227
|
-
queueWindowMs = streamHoldMaxMs;
|
|
1228
|
-
}
|
|
1229
|
-
// A non-streaming request has no heartbeat regardless of cause, so it must
|
|
1230
|
-
// never outlast nonStreamMaxWaitMs even under capacity (it would occupy a
|
|
1231
|
-
// slot 3x its documented cap with nothing to reap it).
|
|
1232
|
-
if (!requestInfo.stream) queueWindowMs = Math.min(queueWindowMs, nonStreamMaxWaitMs);
|
|
1233
|
-
|
|
1234
|
-
// A pure concurrency-cap block (every account healthy but all in-flight/global
|
|
1235
|
-
// slots busy — NOT a quota/rate-limit reset) is a LOCAL capacity transient. Bound
|
|
1236
|
-
// it by the short capacity window, never the multi-day streaming hold: a slot
|
|
1237
|
-
// frees as active requests finish (seconds–minutes), and if the fleet stays
|
|
1238
|
-
// saturated past the window the request sheds load (error-fast) instead of
|
|
1239
|
-
// spinning a queue slot for up to streamHoldMaxMs (7d) — the soft-deadlock guard.
|
|
1240
|
-
if (retryPlan.cause === 'concurrency_cap') {
|
|
1241
|
-
queueWindowMs = Math.min(queueWindowMs, capacityMaxWaitMs);
|
|
1242
|
-
}
|
|
1266
|
+
const queueWindowMs = computeQueueWindowMs({
|
|
1267
|
+
cause,
|
|
1268
|
+
stream: Boolean(requestInfo.stream),
|
|
1269
|
+
retryPlanCause: retryPlan.cause,
|
|
1270
|
+
maxWaitMs,
|
|
1271
|
+
capacityMaxWaitMs,
|
|
1272
|
+
nonStreamMaxWaitMs,
|
|
1273
|
+
streamHoldMaxMs,
|
|
1274
|
+
});
|
|
1243
1275
|
|
|
1244
1276
|
if (queueWindowMs <= 0) return finishQueuedStreamIfNeeded(res, requestInfo, honestMessage);
|
|
1245
1277
|
|
package/src/tui.js
CHANGED
|
@@ -925,8 +925,19 @@ export class TUI {
|
|
|
925
925
|
if (a.enabled !== false && upstreamBlocking && a.status === 'active') {
|
|
926
926
|
effectiveStatus = a.inFlight > 0 ? 'probing' : 'waiting';
|
|
927
927
|
}
|
|
928
|
+
// Anthropic is actively REJECTING this account right now (e.g. a per-model weekly
|
|
929
|
+
// sub-limit the general utilization % doesn't expose). It's unusable — surface
|
|
930
|
+
// that instead of a benign green "active", so a low weekly % (the bar keeps its
|
|
931
|
+
// true value) is never misread as available headroom.
|
|
932
|
+
// Keys on a.status (not effectiveStatus) so a rejected account reads 'blocked'
|
|
933
|
+
// even inside an upstream-throttle window (where it would otherwise show
|
|
934
|
+
// probing/waiting) — a rejected account is unusable, not part of the recovery.
|
|
935
|
+
if (a.enabled !== false && a.quota?.unifiedStatus === 'rejected' && a.status === 'active') {
|
|
936
|
+
effectiveStatus = 'blocked';
|
|
937
|
+
}
|
|
928
938
|
switch (effectiveStatus) {
|
|
929
939
|
case 'active': status = isCur ? green('active') : 'active'; break;
|
|
940
|
+
case 'blocked': status = red('blocked'); break;
|
|
930
941
|
case 'probing': status = green('probing'); break;
|
|
931
942
|
case 'waiting': status = yellow('waiting'); break;
|
|
932
943
|
case 'paused': status = yellow('paused'); break;
|