maxpool 1.14.0 → 1.15.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
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",
@@ -325,6 +325,20 @@ function parseResetHeader(value) {
325
325
  return Number.isNaN(asDate) ? null : asDate;
326
326
  }
327
327
 
328
+ /**
329
+ * A capUtilization is valid only as a real number strictly inside (0,1). Everything
330
+ * else — strings, NaN, negative, 0, >=1 — means "no cap"; an invalid EXPLICIT value
331
+ * also logs once so a hand-edited config doesn't silently fail open (a NaN cap makes
332
+ * every `util >= cap` comparison false = uncapped, with no error anywhere).
333
+ */
334
+ function _sanitizeCap(value, name) {
335
+ if (value == null) return null;
336
+ const n = Number(value);
337
+ if (Number.isFinite(n) && n > 0 && n < 1) return n;
338
+ console.log(`[Maxpool] Ignoring invalid capUtilization ${JSON.stringify(value)} for "${name}" — expected 0-1`);
339
+ return null;
340
+ }
341
+
328
342
  export class AccountManager {
329
343
  constructor(accounts, switchThreshold = 0.90, schedulerOptions = {}, dependencies = {}) {
330
344
  this.scheduler = { ...DEFAULT_SCHEDULER, ...schedulerOptions };
@@ -354,6 +368,13 @@ export class AccountManager {
354
368
  authHeader: acct.authHeader || null,
355
369
  profiles: acct.profiles || (acct.type === 'provider' ? ['all'] : ['claude', 'all']),
356
370
  priority: Number.isFinite(acct.priority) ? acct.priority : 0,
371
+ // USAGE CAP (owner-directed 2026-08-26): reserve capacity on this account —
372
+ // the proxy benches it at capUtilization of BOTH the 5h and 7d windows, keeping
373
+ // the rest for out-of-band use. null/undefined = fully utilized (the default;
374
+ // no behavior change). SANITIZED at parse: non-finite/out-of-range values drop
375
+ // to null HERE, visibly (below), so a hand-edited "50" or "abc" in the config
376
+ // can never fail the >= comparisons open as NaN.
377
+ capUtilization: _sanitizeCap(acct.capUtilization, acct.name),
357
378
  model: acct.model || null,
358
379
  modelMap: acct.modelMap || null,
359
380
  stripBetaHeaders: Boolean(acct.stripBetaHeaders),
@@ -837,7 +858,7 @@ export class AccountManager {
837
858
  // headroom (e.g. 69% used, resets in days) must stay in the healthy-spread
838
859
  // pool even if it's burning fast. Pace is a soft SCORE cost, never a bench.
839
860
  const weeklyState = this._weeklyRawState(account);
840
- if (weeklyState === 'exhausted') return false;
861
+ if (weeklyState === 'exhausted' || weeklyState === 'capped') return false;
841
862
  if (weeklyState === 'critical' && !options.allowWeeklyCritical) return false;
842
863
  if (weeklyState === 'reserve' && !options.allowWeeklyReserve) return false;
843
864
 
@@ -1266,28 +1287,47 @@ export class AccountManager {
1266
1287
  }
1267
1288
  }
1268
1289
 
1290
+ /**
1291
+ * The session-bench threshold for THIS account: the global switchThreshold, lowered
1292
+ * to its usage cap when one is set. Shared by _isSessionQuotaUnavailable (the bench)
1293
+ * and _shortTermRetry (the retry oracle) — one helper, both call sites, so the
1294
+ * oracle can never desync from the bench (a capped-benched account MUST report a
1295
+ * finite retry time or a live session holding on it gets error-fasted).
1296
+ */
1297
+ _sessionBenchThreshold(account) {
1298
+ const cap = account?.capUtilization;
1299
+ return (cap != null && cap < this.switchThreshold) ? cap : this.switchThreshold;
1300
+ }
1301
+
1302
+ /** True when the account's usage cap has it benched on the given window reading. */
1303
+ _capped(account, utilization) {
1304
+ const cap = account?.capUtilization;
1305
+ return cap != null && utilization != null && utilization >= cap;
1306
+ }
1307
+
1269
1308
  _isSessionQuotaUnavailable(account) {
1270
1309
  const q = account.quota;
1271
1310
  this._clearExpiredQuotas(account);
1311
+ const bench = this._sessionBenchThreshold(account);
1272
1312
 
1273
1313
  // Unified 5h quota is immediate availability. Weekly quota is handled
1274
1314
  // separately as long-horizon admission control.
1275
- if (q.unified5h != null && q.unified5h >= this.switchThreshold) return true;
1315
+ if (q.unified5h != null && q.unified5h >= bench) return true;
1276
1316
 
1277
1317
  // Standard quotas (API key accounts)
1278
1318
  if (q.tokensLimit != null && q.tokensRemaining != null) {
1279
1319
  const used = 1 - (q.tokensRemaining / q.tokensLimit);
1280
- if (used >= this.switchThreshold) return true;
1320
+ if (used >= bench) return true;
1281
1321
  }
1282
1322
 
1283
1323
  if (q.requestsLimit != null && q.requestsRemaining != null) {
1284
1324
  const used = 1 - (q.requestsRemaining / q.requestsLimit);
1285
- if (used >= this.switchThreshold) return true;
1325
+ if (used >= bench) return true;
1286
1326
  }
1287
1327
 
1288
1328
  // Provider (z.ai/Kimi) session quota — the 5h token window. Without this a
1289
1329
  // provider at 95% of its 5h cap reads as fully available.
1290
- if (q.providerSes != null && q.providerSes >= this.switchThreshold) return true;
1330
+ if (q.providerSes != null && q.providerSes >= bench) return true;
1291
1331
 
1292
1332
  return false;
1293
1333
  }
@@ -1578,9 +1618,10 @@ export class AccountManager {
1578
1618
  queueable: true,
1579
1619
  };
1580
1620
  }
1581
- if (weeklyState === 'exhausted') {
1621
+ if (weeklyState === 'exhausted' || weeklyState === 'capped') {
1582
1622
  // Hard block: only a weekly reset unblocks it — a sooner short-term clear
1583
- // does not help — so key the hold on the weekly reset.
1623
+ // does not help — so key the hold on the weekly reset. 'capped' (usage cap)
1624
+ // shares this arm: the cap's recovery time IS the window reset.
1584
1625
  //
1585
1626
  // Read the PROVIDER reset too. `unified7dReset` is an Anthropic-only field;
1586
1627
  // a GLM/Kimi account stores its weekly reset in `providerWkReset`
@@ -1672,6 +1713,11 @@ export class AccountManager {
1672
1713
  // queueable} the retry oracle can hold on. Kept separate from the weekly state
1673
1714
  // so weekly-critical accounts surface their real near-term recovery time.
1674
1715
  _shortTermRetry(account, now, q) {
1716
+ // USAGE CAP: the oracle reads the SAME per-account bench threshold as
1717
+ // _isSessionQuotaUnavailable (_sessionBenchThreshold) — a capped account benched
1718
+ // at 50% MUST report a finite retryAt here or a live session holding on it gets
1719
+ // error-fasted instead of waiting out the window (red-team blocker 2).
1720
+ const bench = this._sessionBenchThreshold(account);
1675
1721
  if (account.status === 'throttled' && account.rateLimitedUntil && now < account.rateLimitedUntil) {
1676
1722
  return { cause: 'rate_limited', retryAt: account.rateLimitedUntil, queueable: true };
1677
1723
  }
@@ -1684,13 +1730,13 @@ export class AccountManager {
1684
1730
  return { cause: 'upstream_failure', retryAt: account.provisionalUpstreamUntil, queueable: true };
1685
1731
  }
1686
1732
 
1687
- if (q.unified5h != null && q.unified5h >= this.switchThreshold) {
1733
+ if (q.unified5h != null && q.unified5h >= bench) {
1688
1734
  return { cause: 'session_limit', retryAt: q.unified5hReset || null, queueable: Boolean(q.unified5hReset) };
1689
1735
  }
1690
1736
 
1691
1737
  if (q.tokensLimit != null && q.tokensRemaining != null && q.tokensLimit > 0) {
1692
1738
  const used = 1 - q.tokensRemaining / q.tokensLimit;
1693
- if (used >= this.switchThreshold) {
1739
+ if (used >= bench) {
1694
1740
  const retryAt = q.resetsAt ? new Date(q.resetsAt).getTime() : null;
1695
1741
  return { cause: 'token_limit', retryAt, queueable: Boolean(retryAt) };
1696
1742
  }
@@ -1698,7 +1744,7 @@ export class AccountManager {
1698
1744
 
1699
1745
  if (q.requestsLimit != null && q.requestsRemaining != null && q.requestsLimit > 0) {
1700
1746
  const used = 1 - q.requestsRemaining / q.requestsLimit;
1701
- if (used >= this.switchThreshold) {
1747
+ if (used >= bench) {
1702
1748
  const retryAt = q.resetsAt ? new Date(q.resetsAt).getTime() : null;
1703
1749
  return { cause: 'request_limit', retryAt, queueable: Boolean(retryAt) };
1704
1750
  }
@@ -2819,7 +2865,7 @@ export class AccountManager {
2819
2865
 
2820
2866
  _weeklyState(account) {
2821
2867
  const rawState = this._weeklyRawState(account);
2822
- if (rawState === 'unknown' || rawState === 'exhausted') return rawState;
2868
+ if (rawState === 'unknown' || rawState === 'exhausted' || rawState === 'capped') return rawState;
2823
2869
 
2824
2870
  const pressure = Math.max(clamp01(account.quota.unified7d ?? 0), this._effectiveWeeklyUsage(account));
2825
2871
  if (pressure >= this.scheduler.weeklyCriticalThreshold) return 'critical';
@@ -2858,6 +2904,11 @@ export class AccountManager {
2858
2904
  const sesUsed = q.providerSes != null ? clamp01(q.providerSes) : null;
2859
2905
  const wkUsed = q.providerWk != null ? clamp01(q.providerWk) : null;
2860
2906
  const used = Math.max(sesUsed ?? 0, wkUsed ?? 0);
2907
+ // USAGE CAP — checked FIRST, before every tier: a reservation is owner intent
2908
+ // and outranks both the tier ladder and the upstream verdict. There is no
2909
+ // upstreamAllows carve-out for providers anyway, but the ordering documents
2910
+ // that a cap can never be talked out of by the vendor's "allowed".
2911
+ if (this._capped(account, used)) return 'capped';
2861
2912
  if (used >= this.scheduler.weeklyExhaustedThreshold) return 'exhausted';
2862
2913
  if (used >= this.scheduler.weeklyCriticalThreshold) return 'critical';
2863
2914
  if (used >= this.scheduler.weeklyReserveThreshold) return 'reserve';
@@ -2878,6 +2929,12 @@ export class AccountManager {
2878
2929
  // was waiting on, withheld because a threshold outranked the upstream's own answer.
2879
2930
  // 'exhausted' is the only state that removes an account from routing, so the override
2880
2931
  // is scoped to it — critical/reserve still apply their soft costs unchanged.
2932
+ // USAGE CAP — before the upstreamAllows carve-out BY DESIGN (red-team blocker 1):
2933
+ // a capped account is below its REAL limit, so upstream keeps saying "allowed"
2934
+ // right through the cap — the override exists for genuine over-limit-but-allowed
2935
+ // states and would otherwise make the cap a no-op on exactly the account it is
2936
+ // for (measured: this exact shape sat at unified7d=1.00 'allowed_warning').
2937
+ if (this._capped(account, used)) return 'capped';
2881
2938
  const upstreamAllows = typeof q.unifiedStatus === 'string' && q.unifiedStatus.startsWith('allowed');
2882
2939
  if (used >= this.scheduler.weeklyExhaustedThreshold && !upstreamAllows) return 'exhausted';
2883
2940
  if (used >= this.scheduler.weeklyCriticalThreshold) return 'critical';
@@ -3748,6 +3805,7 @@ export class AccountManager {
3748
3805
  runtime: Boolean(acctData.runtime),
3749
3806
  configSourced: Boolean(acctData.configSourced),
3750
3807
  secretName: acctData.secretName || null,
3808
+ capUtilization: _sanitizeCap(acctData.capUtilization, acctData.name),
3751
3809
  enabled: acctData.enabled !== false,
3752
3810
  refreshToken: acctData.refreshToken || null,
3753
3811
  expiresAt: acctData.expiresAt || null,
@@ -3808,6 +3866,12 @@ export class AccountManager {
3808
3866
  // all` header path (prepareRuntimeProviders) omits enabled, so a re-sent token
3809
3867
  // NEVER silently re-enables a provider the user benched in the TUI.
3810
3868
  if (acctData.enabled !== undefined) account.enabled = acctData.enabled !== false;
3869
+ // Same guard for the usage cap: the restore path carries an explicit persisted
3870
+ // value; the `cc all` header path omits it, so a re-sent token never clears a
3871
+ // cap the user set in the TUI.
3872
+ if (acctData.capUtilization !== undefined) {
3873
+ account.capUtilization = _sanitizeCap(acctData.capUtilization, account.name);
3874
+ }
3811
3875
  if (account.status === 'error' && changed) {
3812
3876
  account.status = 'active';
3813
3877
  account.lastError = null;
@@ -3845,6 +3909,9 @@ export class AccountManager {
3845
3909
  // benched across a restart — without this an intentionally-disabled GLM/Kimi
3846
3910
  // silently comes back enabled on the next boot (restore defaults enabled:true).
3847
3911
  enabled: a.enabled,
3912
+ // And the usage cap, same reasoning: a TUI-set reservation must survive both
3913
+ // the restart AND the next `cc all` header re-send (the upsert guard).
3914
+ capUtilization: a.capUtilization ?? null,
3848
3915
  }));
3849
3916
  }
3850
3917
 
@@ -4057,6 +4124,7 @@ export class AccountManager {
4057
4124
  upstream: a.upstream,
4058
4125
  profiles: a.profiles,
4059
4126
  priority: a.priority,
4127
+ capUtilization: a.capUtilization ?? null,
4060
4128
  runtime: a.runtime,
4061
4129
  status: a.status,
4062
4130
  refreshDead: Boolean(a.refreshDead),
package/src/index.js CHANGED
@@ -2099,6 +2099,14 @@ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
2099
2099
  accountManager.setAccountEnabled(mgr.index, enabled);
2100
2100
  console.log(`[Maxpool] ${enabled ? 'Enabled' : 'Disabled'} account "${mgr.name}" from config`);
2101
2101
  }
2102
+ // Propagate a hand-edited usage cap the same way as enabled (red-team: without
2103
+ // this a config-edit cap is stale until the next full reload).
2104
+ const diskCap = Number.isFinite(diskAcct.capUtilization) && diskAcct.capUtilization > 0 && diskAcct.capUtilization < 1
2105
+ ? diskAcct.capUtilization : null;
2106
+ if (mgr.capUtilization !== diskCap) {
2107
+ mgr.capUtilization = diskCap;
2108
+ console.log(`[Maxpool] Usage cap for "${mgr.name}" ${diskCap ? `set to ${Math.round(diskCap * 100)}%` : 'removed'} from config`);
2109
+ }
2102
2110
  memConfig.accounts[memIdx] = { ...memConfig.accounts[memIdx], ...diskAcct };
2103
2111
 
2104
2112
  if (freshCred.accessToken) {
package/src/tui.js CHANGED
@@ -183,6 +183,12 @@ function loadText(load) {
183
183
  export function weeklyPolicyText(am, account) {
184
184
  if (!am?._weeklyState || !account || account.type === 'provider') return '';
185
185
  const state = am._weeklyState(account);
186
+ // USAGE CAP: the reservation outranks every tier label — a capped account is NOT
187
+ // exhausted, it's deliberately held, and labelling it "Wk exhausted 50%" (red team)
188
+ // while the bar shows half-full misstates the owner's own setting.
189
+ if (state === 'capped') {
190
+ return yellow(`Cap ${Math.round((account.capUtilization || 0) * 100)}%`);
191
+ }
186
192
  if (!state || state === 'unknown' || state === 'normal') return '';
187
193
  const rawState = am._weeklyRawState?.(account) || state;
188
194
  const used = Number(account.quota?.unified7d);
@@ -647,6 +653,11 @@ export class TUI {
647
653
  this._startSelection('rename');
648
654
  } else if (k === 't' && this.am.accounts.length > 0) {
649
655
  this._startSelection('toggle');
656
+ } else if (k === 'u' && this.am.accounts.length > 0) {
657
+ // USAGE CAP (owner-directed 2026-08-26): reserve capacity — the proxy benches
658
+ // this account at the chosen % of both the 5h and weekly windows, keeping the
659
+ // rest for personal use. Enter 1-99 to set; enter 0 to remove (fully utilized).
660
+ this._startSelection('cap');
650
661
  } else if (k === 'd' && this.am.accounts.length > 0) {
651
662
  this._startSelection('delete');
652
663
  } else if (k === 'esc' || k === 'q') {
@@ -1025,6 +1036,15 @@ export class TUI {
1025
1036
  this.inputBuf = '';
1026
1037
  this.inputSensitive = false;
1027
1038
  this.inputCb = value => this._doRename(targetIdx, String(value || '').trim());
1039
+ } else if (this.selAction === 'cap') {
1040
+ const targetIdx = this.selIdx;
1041
+ const current = account.name;
1042
+ const existing = this.am.accounts[targetIdx]?.capUtilization;
1043
+ this.mode = 'input';
1044
+ this.inputPrompt = `Usage cap % for "${current}" (1-99, 0 = off, now ${existing ? Math.round(existing * 100) + '%' : 'off'})`;
1045
+ this.inputBuf = '';
1046
+ this.inputSensitive = false;
1047
+ this.inputCb = value => this._doSetCap(targetIdx, String(value || '').trim());
1028
1048
  }
1029
1049
  }
1030
1050
  else if (k === 'esc' || k === 'q') { this.mode = 'normal'; }
@@ -1196,6 +1216,49 @@ export class TUI {
1196
1216
  }
1197
1217
 
1198
1218
  // Rename an account in config and in the running manager.
1219
+ /**
1220
+ * USAGE CAP setter — persists to config (rollback on write failure) and applies
1221
+ * live. `0` / `off` / `100` REMOVE the cap (fully utilized); 1-99 set it. Runtime
1222
+ * providers (not in config) keep the cap in memory like their `enabled` flag —
1223
+ * it rides state.json across restarts and must also survive `cc all` header
1224
+ * re-upserts (the upsert guard in account-manager).
1225
+ */
1226
+ async _doSetCap(idx, raw) {
1227
+ const account = this.am.accounts[idx];
1228
+ if (!account) { this._addLog('Account no longer exists'); return; }
1229
+ const v = String(raw || '').trim().toLowerCase();
1230
+ const off = v === '' || v === '0' || v === 'off' || v === '100';
1231
+ let pct = null;
1232
+ if (!off) {
1233
+ pct = parseInt(v, 10);
1234
+ if (!Number.isInteger(pct) || pct < 1 || pct > 99) {
1235
+ this._addLog(`Usage cap must be 1-99 (or 0 to remove) — got "${raw}"`);
1236
+ return;
1237
+ }
1238
+ }
1239
+ const cap = off ? null : pct / 100;
1240
+
1241
+ const loc = this._configLocation(account);
1242
+ if (loc) {
1243
+ const prev = this.config[loc.array][loc.index].capUtilization ?? null;
1244
+ if (cap == null) delete this.config[loc.array][loc.index].capUtilization;
1245
+ else this.config[loc.array][loc.index].capUtilization = cap;
1246
+ try {
1247
+ await this.saveConfig(this.config);
1248
+ } catch (error) {
1249
+ // rollback both config and (below) skip the live apply
1250
+ if (prev == null) delete this.config[loc.array][loc.index].capUtilization;
1251
+ else this.config[loc.array][loc.index].capUtilization = prev;
1252
+ throw error;
1253
+ }
1254
+ }
1255
+ // No loc: a runtime provider — in-memory + state.json persistence (same as enabled).
1256
+ account.capUtilization = cap;
1257
+ this._addLog(cap == null
1258
+ ? `Usage cap removed for "${account.name}" — fully utilized`
1259
+ : `Usage cap ${pct}% set for "${account.name}" — the proxy stops routing to it at ${pct}% of the 5h and weekly windows`);
1260
+ }
1261
+
1199
1262
  async _doRename(idx, newName) {
1200
1263
  const account = this.am.accounts[idx];
1201
1264
  if (!account) { this._addLog('Account no longer exists'); return; }
@@ -1968,6 +2031,7 @@ export class TUI {
1968
2031
  const m = this.am._fastRefillMultiplier(a);
1969
2032
  if (m < 1) note += ` ${cyan(`fast·refill ×${m.toFixed(2)}`)}`;
1970
2033
  }
2034
+ if (a.capUtilization != null) note += ` ${yellow(`cap ${Math.round(a.capUtilization * 100)}%`)}`;
1971
2035
  } else if (q.providerQuotaSource === 'console-only') {
1972
2036
  sesCell = emptyBar('n/a', bw);
1973
2037
  wkCell = emptyBar('n/a', bw);
@@ -2066,7 +2130,7 @@ export class TUI {
2066
2130
  if (win === 'wk' && a.type === 'provider' && a.quota?.weeklyAbsent) {
2067
2131
  const sesTank = this.am.capacityTank?.(i, 'ses');
2068
2132
  const approx = sesTank && sesTank.n > 0
2069
- ? `≈${formatTokens(Math.round(sesTank.avg * (7 * 24) / 5))}` : '--';
2133
+ ? formatTokens(Math.round(sesTank.avg * (7 * 24) / 5)) : '--';
2070
2134
  const cellsByName = { Current: cyan(approx), Prev: '--', Avg: '--', N: sesTank?.n ? String(sesTank.n) : '--' };
2071
2135
  out.push(' ' + name + ' ' + prov + ' '
2072
2136
  + COLS.map(c => cellsByName[c].padStart(CW)).join('')
@@ -2085,11 +2149,11 @@ export class TUI {
2085
2149
  // vendor says it is. `~` estimate-from-open-window, `≥` lower bound (joined late).
2086
2150
  let cur = '--';
2087
2151
  if (tank && !(tank.source === 'live' && unprovenLive)) {
2088
- cur = (tank.lowerBound ? '≥' : tank.source === 'live' ? '~' : '') + formatTokens(tank.avg);
2152
+ cur = formatTokens(tank.avg);
2089
2153
  } else if (nowOpen?.tokensSoFar > 0 && util > 0) {
2090
- cur = '~' + formatTokens(Math.round(nowOpen.tokensSoFar / util));
2154
+ cur = formatTokens(Math.round(nowOpen.tokensSoFar / util));
2091
2155
  }
2092
- const curPct = util != null && cur !== '--' ? dim(`${Math.round(util * 100)}%`) : '';
2156
+ const curPct = util != null && cur !== '--' ? dim(`@${Math.round(util * 100)}%`) : '';
2093
2157
  const st = ledger.windowStats(a.name, win);
2094
2158
  const cellsByName = {
2095
2159
  Current: cur.padStart(CW - (curPct ? curPct.length + 1 : 0)),
@@ -2104,7 +2168,7 @@ export class TUI {
2104
2168
 
2105
2169
  out.push('');
2106
2170
  out.push(' ' + dim('Capacity = tokens ÷ how full the provider said the window was, at close.'));
2107
- out.push(' ' + dim('~ = running estimate · ≥ = at least (maxpool joined late) · ≈ = avg 5h × 33.6.'));
2171
+ out.push(' ' + dim('Current @% = live window, % full now · no-weekly weekly = avg 5h × 33.6.'));
2108
2172
  return out;
2109
2173
  }
2110
2174