maxpool 1.5.19 → 1.5.21

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.5.19",
3
+ "version": "1.5.21",
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",
@@ -974,16 +974,52 @@ export class AccountManager {
974
974
  */
975
975
  refreshExpiredQuotas() {
976
976
  let changed = false;
977
+ const now = Date.now();
977
978
  const sessionReset = [];
978
979
  for (const account of this.accounts) {
979
980
  const r = this._clearExpiredQuotas(account);
980
981
  if (r.changed) changed = true;
981
982
  if (r.session) sessionReset.push(account);
983
+ this._clearRecoveredNetworkError(account, now);
982
984
  }
983
985
  if (sessionReset.length) this._switchOnSessionReset(sessionReset);
984
986
  return changed;
985
987
  }
986
988
 
989
+ /**
990
+ * Clear a fully-healed transient error so a long-gone blip stops showing as a
991
+ * phantom "Err" in the TUI. A network blip (markTransientFailure network:true)
992
+ * and an expired upstream_throttled window both set `lastError` WITHOUT bumping
993
+ * `consecutiveFailures`; once the account is active again with no live backoff
994
+ * window, that error is history. Runs for EVERY account on the display/request
995
+ * refresh — required (not just `_isAvailable`, which never evaluates an idle
996
+ * fallback provider that Claude-health keeps out of the candidate set, so its
997
+ * blip lingered ~100 min until restart). Mirrors the rate_limited /
998
+ * upstream_throttled recovery clears in `_isAvailable`.
999
+ *
1000
+ * The `consecutiveFailures > 0` guard is load-bearing: a genuinely-flaky account
1001
+ * (markResult failure — windowless, status active, counter bumped) must KEEP its
1002
+ * error visible. Only the fleet-wide-blip paths leave the counter at 0.
1003
+ */
1004
+ _clearRecoveredNetworkError(account, now = Date.now()) {
1005
+ if (!account.lastError) return;
1006
+ if (account.status !== 'active') return; // throttled / error / exhausted keep their error
1007
+ if (account.consecutiveFailures > 0) return; // an ongoing per-account fault stays visible
1008
+ const blocked = Math.max(
1009
+ account.cooldownUntil || 0,
1010
+ account.rateLimitedUntil || 0,
1011
+ account.provisionalUpstreamUntil || 0,
1012
+ );
1013
+ if (blocked && now < blocked) return; // still in a backoff window → keep showing why
1014
+ account.lastError = null;
1015
+ account.lastErrorAt = null;
1016
+ // Mirror _isAvailable's recovery: drop the now-past backoff windows + fingerprint
1017
+ // so getStatus() doesn't emit a stale past cooldown and no fingerprint dangles.
1018
+ account.cooldownUntil = null;
1019
+ account.provisionalUpstreamUntil = null;
1020
+ account.provisionalUpstreamFingerprint = null;
1021
+ }
1022
+
987
1023
  /**
988
1024
  * Given accounts whose session quota just reset, switch to the one whose
989
1025
  * weekly limit expires soonest — but only if that is sooner than the current
@@ -2402,6 +2438,47 @@ export class AccountManager {
2402
2438
  return idx;
2403
2439
  }
2404
2440
 
2441
+ /**
2442
+ * Serialize runtime (client-supplied) PROVIDER accounts so they survive a
2443
+ * restart. They're created lazily from `cc all` request headers
2444
+ * (prepareRuntimeProviders), NOT from config — so without persistence a cold boot
2445
+ * / reload shows only the config OAuth accounts until the next `cc all` request
2446
+ * re-sends the tokens. Persisted to state.json (0600, same protection as the
2447
+ * OAuth-token-bearing config) alongside quota.
2448
+ */
2449
+ exportRuntimeProviders() {
2450
+ return this.accounts
2451
+ .filter(a => a.runtime && a.type === 'provider' && a.credential)
2452
+ .map(a => ({
2453
+ name: a.name,
2454
+ type: a.type,
2455
+ provider: a.provider,
2456
+ authToken: a.credential,
2457
+ upstream: a.upstream,
2458
+ authHeader: a.authHeader,
2459
+ profiles: a.profiles,
2460
+ priority: a.priority,
2461
+ model: a.model,
2462
+ modelMap: a.modelMap,
2463
+ stripBetaHeaders: a.stripBetaHeaders,
2464
+ }));
2465
+ }
2466
+
2467
+ /**
2468
+ * Restore persisted runtime providers on boot, via the SAME upsert the header
2469
+ * path uses (so a restored provider is byte-identical to a header-created one). A
2470
+ * live `cc all` request refreshes the token before routing (prepareRuntimeProviders
2471
+ * runs ahead of account selection), so a stale restored token never serves a
2472
+ * request. Idempotent — upsertRuntimeAccount matches by name.
2473
+ */
2474
+ restoreRuntimeProviders(list) {
2475
+ if (!Array.isArray(list)) return;
2476
+ for (const p of list) {
2477
+ if (!p || !p.name || !(p.authToken || p.credential)) continue;
2478
+ this.upsertRuntimeAccount({ ...p, authToken: p.authToken || p.credential });
2479
+ }
2480
+ }
2481
+
2405
2482
  /**
2406
2483
  * Remove an account by index.
2407
2484
  */
package/src/index.js CHANGED
@@ -528,6 +528,15 @@ async function serverWorkerCommand() {
528
528
  // lease holder); a cold/direct worker reads the on-disk state file.
529
529
  const savedState = await loadState();
530
530
  if (savedState?.quota) accountManager.restoreQuotaState(savedState.quota);
531
+ // Runtime fallback providers (glm-fallback/kimi-fallback) are created lazily from
532
+ // `cc all` request headers, not config — so without restoring them here a restart
533
+ // shows only the config OAuth accounts until the next `cc all` request re-sends the
534
+ // tokens. NOTE: a SEAMLESS reload worker reads state at spawn (before the outgoing
535
+ // worker's final flush), so a provider created <60s before that reload (not yet
536
+ // interval-flushed) is missed until the next `cc all` request — the same
537
+ // self-healing window as an abrupt SIGKILL. A cold restart reads the clean-shutdown
538
+ // final flush and restores fully (the reported zero-request case).
539
+ if (savedState?.runtimeProviders) accountManager.restoreRuntimeProviders(savedState.runtimeProviders);
531
540
  // Track the state-file generation we last observed so a stale flush is refused.
532
541
  let stateGeneration = Number(savedState?._generation) || 0;
533
542
 
@@ -550,7 +559,15 @@ async function serverWorkerCommand() {
550
559
  // flush REFUSED (dropping the final quota snapshot). The guard only exists to
551
560
  // serialize CROSS-worker writes; the final flush is provably intra-worker.
552
561
  const expectedGeneration = force ? null : stateGeneration;
553
- return saveState({ quota: accountManager.exportQuotaState() }, { expectedGeneration })
562
+ return saveState(
563
+ {
564
+ quota: accountManager.exportQuotaState(),
565
+ // Runtime providers so glm-fallback/kimi-fallback survive a restart (they're
566
+ // header-derived, not in config). Empty [] when none — harmless.
567
+ runtimeProviders: accountManager.exportRuntimeProviders(),
568
+ },
569
+ { expectedGeneration },
570
+ )
554
571
  .then(written => { if (written != null) stateGeneration = written; })
555
572
  .catch(() => {});
556
573
  };