maxpool 1.24.8 → 1.24.10

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.24.8",
3
+ "version": "1.24.10",
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",
@@ -219,6 +219,15 @@ const DEFAULT_SCHEDULER = {
219
219
  // undiscounted, as do the hard gates (safetyMaxActivePerAccount, usage cap,
220
220
  // cooldowns) — those are the anti-dogpile backstops, not price signals.
221
221
  fastRefillDiscount: 0.6, // 0 = off (full cost), 0.6 = discount up to 60% of the two balancing terms
222
+ // RESET-AWARE PREFERENCE (2026-10-08, owner): an account holding a usable, unexpired
223
+ // limit reset (Claude cedar_ember grant / z.ai card) has capacity its siblings lack —
224
+ // when it reaches its wall the prober redeems the reset and it refills (reset-policy
225
+ // R1). So its BALANCING costs (pace, utilization, spread) are discounted: it soaks
226
+ // more traffic and spends the reset instead of letting it expire. The SAFETY terms
227
+ // (capPenalty, reserve/critical, ramp, failure) and every hard gate stay undiscounted,
228
+ // so it is never dogpiled and never preferred over an unhealthy-looking pick.
229
+ // 0 = off. 0.35 ≈ a 1.5x share at otherwise-equal health.
230
+ resetBoostDiscount: 0.35,
222
231
  fastRefillFadeUtil: 0.65, // discount reaches 0 at this session utilization (weeklySoftThreshold)
223
232
  recoveryRampWeight: 4, // decaying penalty applied to a just-recovered account
224
233
  recoveryRampMs: 5 * 60_000, // how long the post-recovery ramp lasts
@@ -2770,6 +2779,31 @@ export class AccountManager {
2770
2779
  * - ramp: ease a just-recovered account back in instead of slamming it
2771
2780
  * - failures: direct per-account backoff after errors
2772
2781
  */
2782
+ /**
2783
+ * Does this account hold a banked reset that can actually lift its BINDING limit?
2784
+ * Claude grants: usable, not paused, unexpired. z.ai cards: unexpired five-hour cards
2785
+ * always count; weekly cards count only when the account HAS a weekly limit (a legacy
2786
+ * plan has none — z.ai support, 2026-10-03 — so a weekly card there lifts nothing).
2787
+ * Disabled accounts never count (owner gate R0). Display state only otherwise.
2788
+ */
2789
+ _holdsUsableReset(account, now = Date.now()) {
2790
+ if (!account || account.enabled === false) return false;
2791
+ const grants = account.resetGrants?.grants || [];
2792
+ if (grants.some(g => g && g.usableNow !== false && !g.paused && (g.endsAt == null || g.endsAt > now))) return true;
2793
+ const cards = account.resetCards;
2794
+ if (!cards) return false;
2795
+ const live = c => c && !c.expired && (c.expiresAt == null || c.expiresAt > now);
2796
+ if ((cards.fiveHour || []).some(live)) return true;
2797
+ const hasWeeklyLimit = account.quota?.providerWk != null || account.quota?.providerWkReset != null;
2798
+ return hasWeeklyLimit && (cards.weekly || []).some(live);
2799
+ }
2800
+
2801
+ _resetMultiplier(account, now = Date.now()) {
2802
+ const d = Number(this.scheduler.resetBoostDiscount) || 0;
2803
+ if (d <= 0) return 1;
2804
+ return this._holdsUsableReset(account, now) ? 1 - Math.min(d, 0.9) : 1;
2805
+ }
2806
+
2773
2807
  _scoreAccount(account, requestInfo = {}, ctx = null, criticalUnlock = null) {
2774
2808
  const now = ctx?.now ?? Date.now();
2775
2809
  const reqWeight = Math.max(1, requestInfo.weight || 1);
@@ -2826,7 +2860,9 @@ export class AccountManager {
2826
2860
  // FAST-REFILL DISCOUNT: applied to the pace and utilization terms for an
2827
2861
  // account whose only cap is a fast-refilling session window — see the
2828
2862
  // DEFAULT_SCHEDULER block for the full rationale (refillMult computed above).
2829
- const paceCost = this._accountScarcity(account, now) * this.scheduler.paceCostWeight * refillMult;
2863
+ // RESET-AWARE: discounts the three BALANCING terms only (see resetBoostDiscount).
2864
+ const resetMult = this._resetMultiplier(account, now);
2865
+ const paceCost = this._accountScarcity(account, now) * this.scheduler.paceCostWeight * refillMult * resetMult;
2830
2866
 
2831
2867
  // RAW utilization cost — direct, not pace-adjusted. The pace cost above discounts
2832
2868
  // by how far into the window you are, so an account at 80% with 2h left is only
@@ -2834,7 +2870,7 @@ export class AccountManager {
2834
2870
  // benching, but wrong for load balancing: an account at 80% should be clearly less
2835
2871
  // attractive than one at 10% even if both are "on pace". Measured 2026-08-10: cc at
2836
2872
  // 80% scored 52.30 vs glm at 10% at 52.15 — a 0.15 gap drowned by round-robin.
2837
- const utilizationCost = this._rawUtilization(account) * this.scheduler.utilizationWeight * refillMult;
2873
+ const utilizationCost = this._rawUtilization(account) * this.scheduler.utilizationWeight * refillMult * resetMult;
2838
2874
 
2839
2875
  // Per-model weekly de-preference: an account whose scoped weekly for THIS
2840
2876
  // request's model (e.g. Fable) is high-but-not-exhausted is a poor pick for
@@ -2860,7 +2896,7 @@ export class AccountManager {
2860
2896
  // 2.3x early in its window, fading to parity at the same ses 0.65 the multiplier
2861
2897
  // already uses. A multiplier of 1 makes every discounted term byte-identical to
2862
2898
  // pre-2026-08-25 behaviour; reserve/critical/ramp/failure are never discounted.
2863
- const spread = share * this.scheduler.spreadShareWeight * refillMult;
2899
+ const spread = share * this.scheduler.spreadShareWeight * refillMult * resetMult;
2864
2900
 
2865
2901
  const ramp = this._recoveryRamp(account, now);
2866
2902
  const reserveCost = this._reserveCost(account, now, weeklyState);
@@ -3344,7 +3380,13 @@ export class AccountManager {
3344
3380
  }
3345
3381
 
3346
3382
  q.lastProbeOkAt = Date.now();
3347
- // A successful probe clears any recorded failure — freshness confirmed.
3383
+ // A successful probe clears any recorded failure — freshness confirmed — including
3384
+ // the org-403 streak (a healthy probe proves the org still accepts OAuth).
3385
+ q.consecutiveOrgGone = 0;
3386
+ if (account.subscriptionGone) {
3387
+ account.subscriptionGone = false;
3388
+ console.log(`[Maxpool] "${account.name}" subscription check passed — un-benched (the org-403 was transient)`);
3389
+ }
3348
3390
  q.lastProbeError = null;
3349
3391
  q.lastProbeErrorAt = null;
3350
3392
  q.lastProbeErrorStatus = null;
@@ -3381,6 +3423,20 @@ export class AccountManager {
3381
3423
  // worth having; the diagnosis that motivated it was wrong.)
3382
3424
  q.consecutiveProbeFailures = (q.consecutiveProbeFailures || 0) + 1;
3383
3425
  const n = q.consecutiveProbeFailures;
3426
+ // ORG-403 LATCH REQUIRES ITS OWN STRIKES (2026-10-08, false "no sub" on
3427
+ // max@gomokka.com). `n` mixes EVERY probe failure — network timeouts first among
3428
+ // them — so after a flapping-network morning one genuine org-403 can arrive with
3429
+ // n already >= 3 and bench a LIVE subscription (measured: latch fired at "x6"
3430
+ // with exactly one org-403 in the whole week's log; 104 successful requests the
3431
+ // same day). The subscription verdict must count only consecutive org-403s.
3432
+ const isOrgGone = status === 403
3433
+ && /not allowed for this organization|disabled.*subscription|subscription.*disabled|organization has disabled/i.test(String(message));
3434
+ if (isOrgGone) {
3435
+ q.consecutiveOrgGone = (q.consecutiveOrgGone || 0) + 1;
3436
+ } else {
3437
+ q.consecutiveOrgGone = 0; // any OTHER outcome breaks the org-gone streak
3438
+ }
3439
+ const orgN = q.consecutiveOrgGone;
3384
3440
  // A SUSTAINED ORG-403 is the subscription being gone (canceled Max plan lapsing
3385
3441
  // server-side — measured 2026-09-18/20: "OAuth authentication is currently not
3386
3442
  // allowed for this organization" on 2solarmax@ and privacy@, both canceled Sep 16).
@@ -3388,11 +3444,10 @@ export class AccountManager {
3388
3444
  // pure waste. Latch subscriptionGone (3 strikes, like the 401 rule): the prober
3389
3445
  // skips the account, the TUI says why, and routing treats it as unavailable. A
3390
3446
  // successful re-auth clears it (updateAccountTokens).
3391
- if (status === 403
3392
- && /not allowed for this organization|disabled.*subscription|subscription.*disabled|organization has disabled/i.test(String(message))
3393
- && n >= 3 && !account.subscriptionGone) {
3447
+ if (isOrgGone && orgN >= 3 && !account.subscriptionGone) {
3394
3448
  account.subscriptionGone = true;
3395
- console.error(`[Maxpool] "${account.name}" subscription disabled at the organization (HTTP 403 x${n}) — benching it. Re-enable after re-subscribing, or remove the account (a → d).`);
3449
+ account._subRecheckAt = Date.now(); // first recheck one full window after latching
3450
+ console.error(`[Maxpool] "${account.name}" subscription disabled at the organization (HTTP 403 x${orgN}) — benching it. Re-enable after re-subscribing, or remove the account (a → d).`);
3396
3451
  }
3397
3452
  // A SUSTAINED 401 is dead credentials, not a blip. Latch refreshDead so (a) the
3398
3453
  // prober stops re-POSTing a rejected token every 60s forever — measured 2026-08-10:
@@ -3438,7 +3493,7 @@ export class AccountManager {
3438
3493
  }
3439
3494
 
3440
3495
  /** Store the latest reset-card listing for an account (prober piggyback).
3441
- * Cards are display/decision state only — routing never reads them. */
3496
+ * Read by redeem policy AND by _scoreAccount's reset-aware preference. */
3442
3497
  applyResetCards(accountIndex, cards) {
3443
3498
  const account = this.accounts[accountIndex];
3444
3499
  if (!account || !cards) return;
@@ -4009,7 +4064,7 @@ export class AccountManager {
4009
4064
  // A dead refresh token (invalid_grant) is PERMANENT until browser re-auth —
4010
4065
  // never auto-retry it. Without this the prober re-POSTs the rejected token every
4011
4066
  // ~60s forever (hammering Anthropic's OAuth endpoint). Cleared on re-login.
4012
- if (account.refreshDead || account.subscriptionGone) return false;
4067
+ if (account.refreshDead || (account.subscriptionGone && !account._subRecheck)) return false;
4013
4068
 
4014
4069
  // A DISABLED account never spends its single-use refresh token. The prober still
4015
4070
  // READS its quota by design (you disable an exhausted account and still want to
package/src/prober.js CHANGED
@@ -12,6 +12,10 @@ import { listResetCards, redeemResetCard } from './zai-reset-cards.js';
12
12
  import { listResetGrants, claimResetGrant } from './claude-reset-grants.js';
13
13
  import { decideZai, decideClaude } from './reset-policy.js';
14
14
 
15
+ // How often a subscription-latched account is rechecked (one probe + at most one
16
+ // token refresh per window — cheap, and bounded so a truly canceled plan isn't hammered).
17
+ export const SUB_RECHECK_MS = 30 * 60_000;
18
+
15
19
  export class Prober {
16
20
  constructor(accountManager, { intervalMs = 0, probeFn = fetchUsage, providerProbeFn = fetchProviderUsage, grantsFn = listResetGrants, timeoutMs = 10_000, log = console.log, usageGapMs = null } = {}) {
17
21
  this.am = accountManager;
@@ -245,7 +249,19 @@ export class Prober {
245
249
  // endpoint 403s before answering anything, so probing is pure waste until the org
246
250
  // accepts OAuth again. Skipping also stops the every-60s hammer that ran 700+ times
247
251
  // on 2solarmax@ between 2026-09-18 and 09-20.
248
- if (account.subscriptionGone) return { ok: false, status: 403 };
252
+ // SELF-CLEAR RECHECK (2026-10-08): the latch used to be terminal — the account was
253
+ // skipped here AND by ensureTokenFresh, so a single transient org-403 benched a live
254
+ // subscription until manual re-auth. Recheck it at a low cadence: a 200 clears the
255
+ // latch (applyUsageData), another org-403 keeps it benched.
256
+ if (account.subscriptionGone) {
257
+ // A latch set outside recordProbeError (state restore, tests, older builds) has no
258
+ // stamp: treat NOW as the latch moment so the first recheck waits a full window.
259
+ if (!account._subRecheckAt) account._subRecheckAt = Date.now();
260
+ const since = Date.now() - account._subRecheckAt;
261
+ if (since < SUB_RECHECK_MS) return { ok: false, status: 403 };
262
+ account._subRecheckAt = Date.now();
263
+ account._subRecheck = true;
264
+ }
249
265
  try {
250
266
  // Claude banked resets (cedar_ember) piggyback on the oauth poll — same
251
267
  // OWNER GATE as z.ai cards: a DISABLED account is never listed or claimed.
@@ -288,6 +304,8 @@ export class Prober {
288
304
  } catch (e) { // best-effort; never let a probe throw
289
305
  this.am.recordProbeError?.(account.index, e?.message || String(e), null);
290
306
  return { ok: false, status: null };
307
+ } finally {
308
+ account._subRecheck = false;
291
309
  }
292
310
  }
293
311
 
@@ -9,6 +9,7 @@
9
9
  * broken provider degrades to "disabled" instead of crashing the proxy.
10
10
  */
11
11
  import { execFile } from 'node:child_process';
12
+ import https from 'node:https';
12
13
  import { promisify } from 'node:util';
13
14
  import { readFileSync } from 'node:fs';
14
15
  import { homedir } from 'node:os';
@@ -57,12 +58,90 @@ function fromCache(secretName) {
57
58
  return (typeof v === 'string' && v.trim()) ? v.trim() : null;
58
59
  }
59
60
 
61
+ // REST PATH FIRST (2026-10-08). `gcloud secrets versions access` hung on this machine
62
+ // (>60s, 21 piled-up gcloud processes) while a plain Secret Manager REST call with the
63
+ // same user's token answered in 0.75s. Every startup resolve AND every 5-minute
64
+ // re-resolve went through the hung CLI, so all z.ai providers stayed
65
+ // "secret-unresolved" until a manual restart happened to land on a good moment.
66
+ // The token itself comes from `gcloud auth print-access-token` (fast — it reads the
67
+ // stored credential, no Secret Manager round-trip) and is reused for ~45 min.
68
+ let tokenMemo = { value: null, at: 0 };
69
+ const TOKEN_TTL_MS = 45 * 60_000;
70
+
71
+ async function accessToken(timeoutMs) {
72
+ if (tokenMemo.value && Date.now() - tokenMemo.at < TOKEN_TTL_MS) return tokenMemo.value;
73
+ const { stdout } = await execFileAsync('gcloud', ['auth', 'print-access-token'],
74
+ { timeout: Math.min(timeoutMs, 20_000), maxBuffer: 64 * 1024 });
75
+ const t = stdout.trim();
76
+ if (!t) throw new Error('empty token');
77
+ tokenMemo = { value: t, at: Date.now() };
78
+ return t;
79
+ }
80
+
81
+ // IPv4-PINNED GET (2026-10-08). On this network IPv6 to Google is black-holed:
82
+ // secretmanager.googleapis.com advertises AAAA records, an IPv6 connect hangs until
83
+ // timeout, and IPv4 answers fine (measured: `curl -6` 8s timeout, `curl -4` OK). gcloud's
84
+ // own HTTP stack tries IPv6 first and HANGS — that is why every CLI resolve timed out.
85
+ // `family: 4` makes this path immune; the CLI fallback below stays as a last resort.
86
+ function ipv4Get(url, { headers, signal }) {
87
+ return new Promise((resolve, reject) => {
88
+ const req = https.get(url, { headers, family: 4, signal }, res => {
89
+ const chunks = [];
90
+ res.on('data', c => chunks.push(c));
91
+ res.on('end', () => {
92
+ const body = Buffer.concat(chunks).toString('utf8');
93
+ resolve({ status: res.statusCode, ok: res.statusCode >= 200 && res.statusCode < 300, json: async () => JSON.parse(body) });
94
+ });
95
+ res.on('error', reject);
96
+ });
97
+ req.on('error', reject);
98
+ });
99
+ }
100
+
101
+ /** Test seam: inject a fetch + token source. */
102
+ let restDeps = { fetch: ipv4Get, token: accessToken };
103
+ export function __setRestDeps(d) { restDeps = { ...restDeps, ...d }; }
104
+ export function __resetRestDeps() { restDeps = { fetch: ipv4Get, token: accessToken }; tokenMemo = { value: null, at: 0 }; }
105
+
106
+ async function viaRest(secretName, project, timeoutMs) {
107
+ const tok = await restDeps.token(timeoutMs);
108
+ const url = `https://secretmanager.googleapis.com/v1/projects/${encodeURIComponent(project)}`
109
+ + `/secrets/${encodeURIComponent(secretName)}/versions/latest:access`;
110
+ const ctl = new AbortController();
111
+ const t = setTimeout(() => ctl.abort(), Math.min(timeoutMs, 15_000));
112
+ try {
113
+ const res = await restDeps.fetch(url, { headers: { Authorization: `Bearer ${tok}` }, signal: ctl.signal });
114
+ if (res.status === 401) { tokenMemo = { value: null, at: 0 }; return null; } // stale token → CLI fallback
115
+ // 403/404: the server answered definitively — the CLI would get the same answer.
116
+ if (res.status === 403 || res.status === 404) return { value: null, definitive: true };
117
+ if (!res.ok) return null;
118
+ const j = await res.json();
119
+ const b64 = j?.payload?.data;
120
+ if (!b64) return { value: null, definitive: true };
121
+ const v = Buffer.from(b64, 'base64').toString('utf8').trim();
122
+ return { value: v || null, definitive: true };
123
+ } finally { clearTimeout(t); }
124
+ }
125
+
60
126
  export async function resolveSecret(secretName, { project = DEFAULT_PROJECT, timeoutMs = 45_000, useCache = true } = {}) {
61
127
  if (!secretName || typeof secretName !== 'string') return null;
62
128
  if (useCache) {
63
129
  const cached = fromCache(secretName);
64
130
  if (cached) return cached;
65
131
  }
132
+ // ONE time budget for the whole lookup: REST first, then the CLI with whatever is
133
+ // left. A definitive REST answer (the secret does not exist / no access) skips the
134
+ // CLI entirely — the CLI would only re-ask the same server, and on an IPv6-broken
135
+ // network it hangs for the full timeout anyway.
136
+ const deadline = Date.now() + timeoutMs;
137
+ try {
138
+ const r = await viaRest(secretName, project, timeoutMs);
139
+ if (r?.value) return r.value;
140
+ if (r?.definitive) return null;
141
+ } catch { /* transport failure — fall through to the CLI path */ }
142
+ const remaining = deadline - Date.now();
143
+ if (remaining < 1000) return null;
144
+ timeoutMs = remaining;
66
145
  try {
67
146
  const { stdout } = await execFileAsync(
68
147
  'gcloud',