maxpool 1.24.31 → 1.24.33

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.31",
3
+ "version": "1.24.33",
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",
@@ -1,4 +1,5 @@
1
1
  import { refreshAccessToken, isTokenExpiringSoon, modelFamily, tokenFingerprint } from './oauth.js';
2
+ import { hourFloor } from './outside-usage.js';
2
3
  import { CapacityLedger, TANK_MIN_UTIL } from './capacity-ledger.js';
3
4
 
4
5
  // Nominal window length per kind — mirrors capacity-ledger's WINDOW_MS (kept here as a
@@ -464,6 +465,7 @@ export class AccountManager {
464
465
  else this.scheduler.routingMode = 'sticky';
465
466
  }
466
467
  this._refreshAccessToken = dependencies.refreshAccessToken || refreshAccessToken;
468
+ this.poolUpSince = Date.now();
467
469
  this.accounts = accounts.map((acct, index) => ({
468
470
  index,
469
471
  name: acct.name,
@@ -785,6 +787,19 @@ export class AccountManager {
785
787
  }));
786
788
  }
787
789
 
790
+ /** OUTSIDE-USAGE (2026-10-27): count every request the pool sends on a provider
791
+ * account, per UTC hour — the pool half of the outside-usage comparison. The
792
+ * map is bounded (last 25 hours) and in-memory only. */
793
+ notePoolSend(account, at = Date.now()) {
794
+ if (!account || account.type !== 'provider') return;
795
+ account.poolHourly ||= new Map();
796
+ const h = hourFloor(at);
797
+ account.poolHourly.set(h, (account.poolHourly.get(h) || 0) + 1);
798
+ if (account.poolHourly.size > 25) {
799
+ for (const k of [...account.poolHourly.keys()].sort().slice(0, account.poolHourly.size - 25)) account.poolHourly.delete(k);
800
+ }
801
+ }
802
+
788
803
  acquireAccount(requestInfo = {}, excludedIndexes = new Set()) {
789
804
  this._noteRequestPolicy(requestInfo);
790
805
  // Capture the session's prior account BEFORE selection, so the caller can tell
@@ -806,6 +821,7 @@ export class AccountManager {
806
821
  account.inFlight++;
807
822
  account.activeWeight += weight;
808
823
  account.lastUsedAt = Date.now();
824
+ this.notePoolSend(account);
809
825
  // Non-null only when this acquire moved the session off its prior account.
810
826
  const migratedFromName = (prevCurrentName && account.name !== prevCurrentName) ? prevCurrentName : null;
811
827
  return { account, weight, startedAt: Date.now(), upstreamThrottleProbe, migratedFromName };
@@ -4499,7 +4515,12 @@ export class AccountManager {
4499
4515
  return this.accounts.map(a => {
4500
4516
  const quota = {};
4501
4517
  for (const f of PERSISTED_QUOTA_FIELDS) quota[f] = a.quota[f];
4502
- return { accountUuid: a.accountUuid, name: a.name, quota };
4518
+ const out = { accountUuid: a.accountUuid, name: a.name, quota };
4519
+ // OUTSIDE-USAGE flag survives restart (red-team R1.2): a restart is exactly
4520
+ // when a leak's cards are most at risk — remediation rotates keys and
4521
+ // restarts the pool, and the in-memory flag would vanish mid-remediation.
4522
+ if (a.outsideUsage?.outside) out.outsideUsage = { ...a.outsideUsage };
4523
+ return out;
4503
4524
  });
4504
4525
  }
4505
4526
 
@@ -4533,6 +4554,12 @@ export class AccountManager {
4533
4554
  }
4534
4555
  // We already know this account's weekly window, so it isn't "probing".
4535
4556
  if (account.quota.unified7dReset != null) account.probing = false;
4557
+ // OUTSIDE-USAGE restored: keep holding R7 and force the next sweep to
4558
+ // re-verify immediately (fresh poolHourly can only re-prove the leak).
4559
+ if (match.outsideUsage?.outside) {
4560
+ account.outsideUsage = { ...match.outsideUsage, stale: true };
4561
+ account._outsideCheckedAt = 0;
4562
+ }
4536
4563
  }
4537
4564
  }
4538
4565
 
@@ -4592,6 +4619,7 @@ export class AccountManager {
4592
4619
  ? new Date(Math.max(a.cooldownUntil || 0, a.provisionalUpstreamUntil || 0)).toISOString()
4593
4620
  : null,
4594
4621
  quota: { ...a.quota },
4622
+ outsideUsage: a.outsideUsage ? { ...a.outsideUsage } : null,
4595
4623
  weekly: {
4596
4624
  state: this._weeklyState(a),
4597
4625
  rawState: this._weeklyRawState(a),
@@ -0,0 +1,88 @@
1
+ // OUTSIDE-USAGE DETECTOR (2026-10-27).
2
+ //
3
+ // The pool knows every request IT sends per account; z.ai counts EVERY request
4
+ // against the account, including ones sent by anyone else holding the same API
5
+ // key. From 2026-08-10 to 2026-10-26 an outside holder of max+1's key burned its
6
+ // weekly quota and all three reset cards (final week: 45,155 z.ai calls vs 1,682
7
+ // pool sends) and nothing in the pool could see it — the owner spotted it by
8
+ // accident. This module compares, per account, z.ai's hourly call counts (monitor
9
+ // API, zero-spend) with the pool's own hourly sends, over the complete hours the
10
+ // pool has been running, and flags when the outside share dominates.
11
+ //
12
+ // Why REQUESTS per HOUR, not tokens per day: the pool's token ledger excludes
13
+ // cached input that z.ai bills, so even a clean account reads ~200x apart on
14
+ // tokens (measured: glm1 3.43B z.ai vs 15.6M ledger). Request counts on a clean
15
+ // account agree to ~1.1x (glm1, 2026-10-23..27). Counting only hours the pool was
16
+ // up means a restart or an update cutover can never manufacture a false alarm.
17
+ //
18
+ // z.ai's monitor labels hours in UTC+8 ("2026-10-24 12:00" = 04:00Z).
19
+ //
20
+ // Operating limits (red-team 2026-10-27, accepted):
21
+ // - ONE pool per key. A second maxpool sending on the same key IS outside traffic
22
+ // to this one and would trip the flag on both — share keys by giving each
23
+ // person their own account (the registry rule), not by running two pools.
24
+ // - z.ai only. Kimi exposes no per-hour call monitor; its keys are single-holder.
25
+ // - Detection, not prevention: a leak under 3x the pool's own volume, or one that
26
+ // started before the pool booted and has not yet produced 50 calls in complete
27
+ // hours since, is not flagged. The key-literal guard test and per-person keys
28
+ // are the prevention.
29
+ // - Failure is fail-SAFE: an unreadable/malformed monitor reply never clears a
30
+ // flag (it marks the reading stale, R7 keeps holding, the next sweep retries);
31
+ // the flag persists across restarts. Only a well-formed reading clears it.
32
+
33
+ export const OUTSIDE_FACTOR = 3; // z.ai > 3x pool over the window = outside traffic
34
+ export const OUTSIDE_MIN_CALLS = 50; // below this the window is noise
35
+ export const OUTSIDE_WINDOW_HOURS = 24; // compare at most the last 24 complete hours
36
+ export const OUTSIDE_CHECK_MS = 60 * 60_000; // re-check each account hourly
37
+ const HOUR = 3600_000;
38
+ const ZAI_TZ_OFFSET_MS = 8 * HOUR;
39
+
40
+ export const hourFloor = (ms) => Math.floor(ms / HOUR) * HOUR;
41
+
42
+ /** A z.ai model-usage payload we can trust: parallel non-empty arrays. A
43
+ * malformed/partial payload is treated as a FAILED read (never as "zero calls"),
44
+ * while a well-formed all-zero payload is real data — that is what lets a flag
45
+ * clear after the key is rotated and the outside traffic stops. */
46
+ export function isUsableZaiUsage(data) {
47
+ const xs = data?.x_time, cs = data?.modelCallCount;
48
+ return Array.isArray(xs) && Array.isArray(cs) && xs.length > 0 && xs.length === cs.length;
49
+ }
50
+
51
+ /** z.ai model-usage `data` ({x_time:["YYYY-MM-DD HH:mm"...], modelCallCount:[...]})
52
+ * → Map(utcHourStartMs → calls). Unparseable rows are skipped. */
53
+ export function zaiHourlyCalls(data) {
54
+ const out = new Map();
55
+ const xs = data?.x_time, cs = data?.modelCallCount;
56
+ if (!Array.isArray(xs) || !Array.isArray(cs)) return out;
57
+ for (let i = 0; i < xs.length; i++) {
58
+ const m = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/.exec(String(xs[i]));
59
+ if (!m) continue;
60
+ const labelAsUtc = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]);
61
+ // Floor to the hour: z.ai labels are hour-aligned today, but folding a finer
62
+ // bucket (":30", a summary row) into its hour is correct either way, while
63
+ // keying on the raw label would silently DROP it (hourFloor lookups miss) and
64
+ // read a leaking account as 0.
65
+ const utc = Math.floor((labelAsUtc - ZAI_TZ_OFFSET_MS) / HOUR) * HOUR;
66
+ const c = Number(cs[i]) || 0;
67
+ out.set(utc, (out.get(utc) || 0) + c);
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /** Compare z.ai's and the pool's hourly counts over the complete hours in
73
+ * [max(poolUpSince ceiled to the next hour, now - window), floor(now)).
74
+ * Returns {hours, zaiCalls, poolCalls, outside, from, to}; hours=0 when no
75
+ * complete hour is comparable yet (then outside is always false). */
76
+ export function compareOutsideUsage(zaiHours, poolHours, { now, poolUpSince, factor = OUTSIDE_FACTOR, minCalls = OUTSIDE_MIN_CALLS, windowHours = OUTSIDE_WINDOW_HOURS } = {}) {
77
+ const to = hourFloor(now);
78
+ const upFrom = Math.ceil(poolUpSince / HOUR) * HOUR; // first hour the pool saw in full
79
+ const from = Math.max(upFrom, to - windowHours * HOUR);
80
+ let zaiCalls = 0, poolCalls = 0, hours = 0;
81
+ for (let h = from; h < to; h += HOUR) {
82
+ hours++;
83
+ zaiCalls += zaiHours?.get(h) || 0;
84
+ poolCalls += poolHours?.get(h) || 0;
85
+ }
86
+ const outside = hours > 0 && zaiCalls >= minCalls && zaiCalls > poolCalls * factor;
87
+ return { hours, zaiCalls, poolCalls, outside, from, to };
88
+ }
package/src/prober.js CHANGED
@@ -8,16 +8,17 @@
8
8
  // This is the one sanctioned active-upstream feature; the proxy is otherwise passive.
9
9
 
10
10
  import { fetchUsage, fetchProviderUsage } from './oauth.js';
11
- import { listResetCards, redeemResetCard } from './zai-reset-cards.js';
11
+ import { listResetCards, redeemResetCard, fetchZaiModelUsage } 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
+ import { zaiHourlyCalls, compareOutsideUsage, isUsableZaiUsage, OUTSIDE_CHECK_MS } from './outside-usage.js';
14
15
 
15
16
  // How often a subscription-latched account is rechecked (one probe + at most one
16
17
  // token refresh per window — cheap, and bounded so a truly canceled plan isn't hammered).
17
18
  export const SUB_RECHECK_MS = 30 * 60_000;
18
19
 
19
20
  export class Prober {
20
- constructor(accountManager, { intervalMs = 0, probeFn = fetchUsage, providerProbeFn = fetchProviderUsage, grantsFn = listResetGrants, timeoutMs = 10_000, log = console.log, usageGapMs = null } = {}) {
21
+ constructor(accountManager, { intervalMs = 0, probeFn = fetchUsage, providerProbeFn = fetchProviderUsage, grantsFn = listResetGrants, timeoutMs = 10_000, log = console.log, usageGapMs = null, zaiUsageFn = fetchZaiModelUsage, cardsFn = listResetCards, now = Date.now } = {}) {
21
22
  this.am = accountManager;
22
23
  // Injectable like probeFn: the default hits api.anthropic.com, so a harness that
23
24
  // doesn't stub it makes a REAL network call per oauth sweep (a test then passes
@@ -38,6 +39,9 @@ export class Prober {
38
39
  this._stopping = false;
39
40
  this._pendingSleep = null; // canceller for an in-flight pacing sleep
40
41
  this._sweepStart = 0; // rotates the per-sweep start account (fairness)
42
+ this.zaiUsageFn = zaiUsageFn; // z.ai hourly model-usage fetch (outside-usage detector)
43
+ this.cardsFn = cardsFn; // reset-card listing, injectable like the others
44
+ this._now = now; // clock (tests inject; defaults to the real one)
41
45
  }
42
46
 
43
47
  start() {
@@ -190,6 +194,7 @@ export class Prober {
190
194
  const usage = await this._withTimeout(this.providerProbeFn(account));
191
195
  if (!usage) return; // timed out — try again next cycle
192
196
  this.am.applyProviderUsage(account.index, usage);
197
+ await this._checkOutsideUsage(account);
193
198
  // Reset cards piggyback on the same poll cycle (z.ai only; zero-spend read).
194
199
  // Failures are swallowed on purpose: cards are an enhancement, never a probe
195
200
  // health signal — a card-list 401 must not turn the quota bars stale.
@@ -199,7 +204,7 @@ export class Prober {
199
204
  if (account.enabled !== false && account.credential) {
200
205
  if (account.provider === 'zai' && this.am.applyResetCards) {
201
206
  try {
202
- const cards = await this._withTimeout(listResetCards(account.credential));
207
+ const cards = await this._withTimeout(this.cardsFn(account.credential));
203
208
  if (cards) {
204
209
  this.am.applyResetCards(account.index, cards);
205
210
  const act = decideZai(account, cards);
@@ -211,6 +216,40 @@ export class Prober {
211
216
  } catch { /* best-effort; never let a probe throw */ }
212
217
  }
213
218
 
219
+ /** OUTSIDE-USAGE DETECTOR (2026-10-27), hourly per account: fetch z.ai's own
220
+ * hourly model-call counts for the account and compare with what the pool sent
221
+ * in the same complete hours. A divergent account (key used by someone else —
222
+ * measured 2026-08-10→10-26: 45,155 outside calls on max+1) is recorded on the
223
+ * account (reset-card policy R7 refuses to feed it) and logged ONCE per
224
+ * transition, never per sweep. Disabled accounts are never checked: someone
225
+ * else legitimately using the key is WHY the account is benched. */
226
+ async _checkOutsideUsage(account, now = this._now()) {
227
+ try {
228
+ if (account.provider !== 'zai' || !account.credential || account.enabled === false) return;
229
+ const last = account._outsideCheckedAt || 0;
230
+ if (now - last < OUTSIDE_CHECK_MS) return;
231
+ const data = await this._withTimeout(this.zaiUsageFn(account.credential, { now }));
232
+ if (!isUsableZaiUsage(data)) {
233
+ // FAILED or malformed read. Never downgrade a true flag on missing data (red-team R1.1:
234
+ // one 5xx un-protected a known leak and the SAME sweep redeemed a card into
235
+ // it). Keep the reading, mark it stale (R7 still holds), and RETRY next
236
+ // sweep — the hourly slot is only consumed by a successful read.
237
+ const prev = account.outsideUsage;
238
+ if (prev) account.outsideUsage = { ...prev, stale: true, checkedAt: now };
239
+ return;
240
+ }
241
+ account._outsideCheckedAt = now; // stamp AFTER success (R1.5)
242
+ const zai = zaiHourlyCalls(data);
243
+ const res = compareOutsideUsage(zai, account.poolHourly, { now, poolUpSince: this.am.poolUpSince });
244
+ const prev = account.outsideUsage;
245
+ const wasOutside = Boolean(prev?.outside);
246
+ account.outsideUsage = { ...res, checkedAt: now };
247
+ if (res.outside && !wasOutside) {
248
+ this.log(`[outside-usage] ${account.name}: z.ai counted ${res.zaiCalls} calls in the last ${res.hours}h but the pool sent ${res.poolCalls} — someone else holds this key; reset cards held`);
249
+ }
250
+ } catch { /* detector is best-effort; never block the probe */ }
251
+ }
252
+
214
253
  /** Execute a z.ai card redeem decided by the policy. Logs before/after.
215
254
  * A FAILED redeem backs off exponentially per card (15m, 30m, 1h, cap 6h):
216
255
  * 2026-10-22/23, a z.ai API change made every redeem fail "unavailable" and the
@@ -58,6 +58,7 @@ function msLeft(t, now) { return t == null ? Infinity : t - now; }
58
58
  export function decideZai(account, cards, now = Date.now()) {
59
59
  if (account?.enabled === false) return null; // R0
60
60
  if (!cards || cards.error) return null;
61
+ if (account?.outsideUsage?.outside) return null; // R7: key used by someone else — a card would feed the leak
61
62
  const q = account.quota || {};
62
63
  const ses = q.providerSes ?? null;
63
64
  const wk = q.weeklyAbsent ? null : (q.providerWk ?? null);
@@ -71,6 +72,7 @@ export function decideZai(account, cards, now = Date.now()) {
71
72
 
72
73
  const weeklyCard = weeklyCards[0] || null;
73
74
  const sesCard = sesCards[0] || null;
75
+ let weeklyHeld = false; // R6: weekly card HELD for the free refill (5h branch may still fire)
74
76
 
75
77
  // --- weekly card: clears BOTH windows (z.ai docs; measured: glm1 100%->0% both)
76
78
  if (weeklyCard) {
@@ -81,20 +83,24 @@ export function decideZai(account, cards, now = Date.now()) {
81
83
  if (atWall || dying) {
82
84
  // R6: a wall redeem waits when the FREE weekly refill is imminent and the
83
85
  // account isn't critically full (dying-value redeems stay exempt above).
86
+ // A HELD weekly card is spendable-redundant, not absent: the 5h branch below
87
+ // may still fire (red-team: holding must not starve a 5h wall — the 5h card
88
+ // sits redundant behind the free refill anyway, so spending it is pure gain).
84
89
  if (atWall && !dying && q.providerWkReset != null
85
90
  && msLeft(q.providerWkReset, now) < POLICY.freeRefillWaitMs
86
91
  && wkUtil < POLICY.freeRefillImmediate) {
87
- return null; // hold — the free refill lands before the card would matter
92
+ weeklyHeld = true; // fall through — do NOT return; the 5h branch checks this
93
+ } else {
94
+ return { kind: 'card', card: weeklyCard, resetType: 'WEEK',
95
+ reason: atWall ? 'weekly at wall' : 'dying value' };
88
96
  }
89
- return { kind: 'card', card: weeklyCard, resetType: 'WEEK',
90
- reason: atWall ? 'weekly at wall' : 'dying value' };
91
97
  }
92
98
  }
93
99
 
94
100
  // --- 5h card: clears only the session window
95
101
  if (sesCard) {
96
102
  const atWall = sesUtil != null && sesUtil >= POLICY.wallSes
97
- && (weeklyCard == null); // R4: a weekly card can do this job AND more
103
+ && (weeklyCard == null || weeklyHeld); // R4: a weekly card can do this job AND more — except when R6 HELD it for the free refill
98
104
  const dying = msLeft(sesCard.expiresAt, now) < POLICY.dyingWindowMs
99
105
  && (sesUtil ?? 0) >= POLICY.dyingMinUtilSes;
100
106
  if (atWall || dying) {
package/src/tui.js CHANGED
@@ -2388,6 +2388,10 @@ export class TUI {
2388
2388
  wkCell = q.providerWk != null ? bar(q.providerWk, bw, q.providerWkReset)
2389
2389
  : emptyBar(q.weeklyAbsent ? 'none' : '—', bw);
2390
2390
  note = this._probeHealthNote(a);
2391
+ // OUTSIDE-USAGE tag (2026-10-27): someone else is spending this account's quota
2392
+ // with the same key. Red and first, because it explains every other symptom on
2393
+ // the row (wall hit too fast, cards vanishing) and the fix is a key rotation.
2394
+ if (a.outsideUsage?.outside) note += ` ${red(`key used outside pool ×${Math.max(1, Math.round(a.outsideUsage.zaiCalls / Math.max(1, a.outsideUsage.poolCalls)))}`)}`;
2391
2395
  // PEAK row tag (SC9): mirrors the router's own predicates — hard-barred = off,
2392
2396
  // tier 2 = over cap, tier 1 = peak (or "cap n/a" when the weekly is unreadable,
2393
2397
  // so the soft cap is inert). The label can never disagree with routing because
@@ -131,3 +131,27 @@ export async function redeemResetCard(token, recordId, resetType, { grantType, a
131
131
  return { ok: false, error: err.message || String(err) };
132
132
  }
133
133
  }
134
+
135
+ // z.ai's own hourly per-account model-call counts (zero-spend monitor read). The
136
+ // labels are UTC+8 ("YYYY-MM-DD HH:mm"); outside-usage.js converts. Consumed by
137
+ // the outside-usage detector (2026-10-27) — never by routing.
138
+ const MODEL_USAGE_URL = 'https://api.z.ai/api/monitor/usage/model-usage';
139
+ const zaiStamp = (ms) => new Date(ms + 8 * 3600_000).toISOString().slice(0, 19).replace('T', ' ');
140
+
141
+ /** Fetch the last `hours` (default 26) of z.ai hourly model-call counts for a key.
142
+ * Returns the raw `data` ({x_time, modelCallCount, ...}) or null on any failure. */
143
+ export async function fetchZaiModelUsage(token, { hours = 26, now = Date.now(), signal, fetchImpl = fetch } = {}) {
144
+ if (!token) return null;
145
+ try {
146
+ const q = new URLSearchParams({ startTime: zaiStamp(now - hours * 3600_000), endTime: zaiStamp(now) });
147
+ const res = await fetchImpl(`${MODEL_USAGE_URL}?${q}`, {
148
+ headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json', 'Accept-Language': 'en-US' },
149
+ signal,
150
+ });
151
+ if (!res.ok) return null;
152
+ const data = await res.json();
153
+ return data?.code === 200 && data?.data ? data.data : null;
154
+ } catch {
155
+ return null;
156
+ }
157
+ }