maxpool 1.5.20 → 1.5.22

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.20",
3
+ "version": "1.5.22",
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",
@@ -2438,6 +2438,47 @@ export class AccountManager {
2438
2438
  return idx;
2439
2439
  }
2440
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
+
2441
2482
  /**
2442
2483
  * Remove an account by index.
2443
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
  };
package/src/tui.js CHANGED
@@ -183,7 +183,18 @@ function bar(ratio, w = 10, resetTs) {
183
183
  return out;
184
184
  }
185
185
 
186
- export const __tuiTest = { formatReset, quotaLabel, bar, strip, loadText, countdown };
186
+ // A gray placeholder the EXACT visible width of a bar(), for a quota that's absent
187
+ // or unpollable (label "—" / "n/a" / "probing"). Mirrors bar()'s no-data branch so
188
+ // provider rows stay column-aligned with OAuth rows.
189
+ function emptyBar(label, w = 10) {
190
+ const text = String(label).slice(0, w);
191
+ const pad = Math.max(0, w - text.length);
192
+ const lp = Math.floor(pad / 2);
193
+ const rp = pad - lp;
194
+ return `${ESC}100m${' '.repeat(lp)}${text}${' '.repeat(rp)}${RESET}`;
195
+ }
196
+
197
+ export const __tuiTest = { formatReset, quotaLabel, bar, emptyBar, strip, loadText, countdown };
187
198
 
188
199
  function timestamp() {
189
200
  return new Date().toLocaleTimeString('en-US', { hour12: false });
@@ -940,8 +951,10 @@ export class TUI {
940
951
  const rawName = a.name.slice(0, 12).padEnd(12);
941
952
  const name = isSel ? bold(rawName) : rawName;
942
953
 
943
- // Type
944
- const type = gray(a.type.padEnd(7));
954
+ // Type — pad to 8 so "provider" (8 chars) doesn't overflow a 7-wide column and
955
+ // shift the whole provider row (incl. its quota bars) 1 char out of alignment
956
+ // with the "oauth"/"apikey" rows.
957
+ const type = gray(a.type.padEnd(8));
945
958
 
946
959
  // Status
947
960
  let status;
@@ -1039,35 +1052,44 @@ export class TUI {
1039
1052
  }
1040
1053
 
1041
1054
  _renderProviderAcct(sel, cur, name, type, status, a, bw = 11, showBoth = true) {
1042
- const completed = a.completedRequests || 0;
1043
- const failed = a.failedRequests || 0;
1044
- const active = a.inFlight || 0;
1045
- const last = a.lastStatus ? `${statusColor(a.lastStatus)} ${formatMs(a.lastResponseMs)}` : '-';
1046
1055
  const q = a.quota || {};
1047
1056
 
1048
- // Quota segment. z.ai has a pollable monitor endpoint → real Ses/Wk token bars,
1049
- // same rendering as OAuth accounts. Kimi (Moonshot coding key) has NO pollable
1050
- // quota (web console only) → an honest label, never a fake bar. Fields are the
1051
- // SEPARATE provider* set, so a provider reading never leaks into an OAuth bar.
1052
- let quotaSeg = '';
1057
+ // Ses/Wk bars in the SAME columns as OAuth rows (right after status) so the
1058
+ // whole table's bars line up. z.ai exposes a 5-hour token window (Ses) and —
1059
+ // when its monitor endpoint includes it — a weekly (Wk, intermittently absent →
1060
+ // "—"). Kimi's coding key has NO pollable quota (web console only) → "n/a" both
1061
+ // + a "console-only" note; not-yet-probed → "probing". Fields are the SEPARATE
1062
+ // provider* set, so a provider reading never leaks into an OAuth bar.
1063
+ let sesCell, wkCell, note = '';
1053
1064
  if (q.providerSes != null || q.providerWk != null) {
1054
- quotaSeg = ` Ses ${bar(q.providerSes, bw, q.providerSesReset)}`;
1055
- if (showBoth && q.providerWk != null) quotaSeg += ` Wk ${bar(q.providerWk, bw, q.providerWkReset)}`;
1056
- if (this.am._quotaProbeStale?.(a)) quotaSeg += ` ${dim('stale')}`;
1065
+ sesCell = q.providerSes != null ? bar(q.providerSes, bw, q.providerSesReset) : emptyBar('—', bw);
1066
+ wkCell = q.providerWk != null ? bar(q.providerWk, bw, q.providerWkReset) : emptyBar('—', bw);
1067
+ if (this.am._quotaProbeStale?.(a)) note = ` ${dim('stale')}`;
1057
1068
  } else if (q.providerQuotaSource === 'console-only') {
1058
- quotaSeg = ` ${dim('Quota console-only')}`;
1059
- } else if (q.providerQuotaSource === 'zai') {
1060
- quotaSeg = ` ${dim('Ses/Wk probing')}`;
1069
+ sesCell = emptyBar('n/a', bw);
1070
+ wkCell = emptyBar('n/a', bw);
1071
+ note = ` ${dim('console-only')}`;
1072
+ } else {
1073
+ sesCell = emptyBar('probing', bw);
1074
+ wkCell = emptyBar('probing', bw);
1061
1075
  }
1062
1076
 
1063
- let limit = '';
1077
+ let line = ` ${sel}${cur} ${name} ${type} ${status} Ses ${sesCell}`;
1078
+ if (showBoth) line += ` Wk ${wkCell}`;
1079
+ line += note;
1080
+ // Same recent-load columns as OAuth (providers track inFlight + load events),
1081
+ // plus a compact Last <status> <ms> — the one signal that matters for a
1082
+ // rarely-hit fallback (loadText reads 0r when idle).
1083
+ line += ` ${dim(loadText(this._accountLoad(a)))}`;
1084
+ if (a.lastStatus) line += ` ${dim('Last')} ${statusColor(a.lastStatus)} ${dim(formatMs(a.lastResponseMs))}`;
1085
+ // Header-derived generic rate-limit (distinct from the monitor-endpoint quota),
1086
+ // if the provider upstream returns x-ratelimit-* headers — only when present.
1064
1087
  if (q.genericLimit != null && q.genericRemaining != null) {
1065
- const used = q.genericLimit - q.genericRemaining;
1066
1088
  const reset = formatReset(q.genericReset);
1067
- limit = ` Lim ${used}/${q.genericLimit}${reset ? ` ${reset}` : ''}`;
1089
+ line += ` ${dim(`Lim ${q.genericLimit - q.genericRemaining}/${q.genericLimit}${reset ? ' ' + reset : ''}`)}`;
1068
1090
  }
1069
- const err = a.lastError ? ` Err ${String(a.lastError).slice(0, 18)}` : '';
1070
- return ` ${sel}${cur} ${name} ${type} ${status} Act ${String(active).padStart(2)} OK ${String(completed).padStart(3)} Fail ${String(failed).padStart(2)} Last ${last}${quotaSeg} ${dim(loadText(this._accountLoad(a)))}${limit}${err}`;
1091
+ if (a.lastError) line += ` ${red('Err ' + String(a.lastError).slice(0, 18))}`;
1092
+ return line;
1071
1093
  }
1072
1094
 
1073
1095
  _accountLoad(account) {