maxpool 1.22.4 → 1.24.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 +1 -1
- package/src/account-manager.js +70 -6
- package/src/claude-reset-grants.js +146 -0
- package/src/prober.js +62 -0
- package/src/reset-policy.js +121 -0
- package/src/tui.js +40 -4
- package/src/zai-reset-cards.js +118 -0
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -156,6 +156,16 @@ const DEFAULT_SCHEDULER = {
|
|
|
156
156
|
// half of every window, then opens it up over the second half — so the reserve is
|
|
157
157
|
// protected while there is still time to use it, and spent when there is not.
|
|
158
158
|
capRampStart: 0.5,
|
|
159
|
+
// SOAK MODE (owner-directed 2026-09-26): an account with NO weekly limit (z.ai
|
|
160
|
+
// no-weekly plans, quota.weeklyAbsent) should soak up as much traffic as its session
|
|
161
|
+
// window allows — its capacity expires unused every ~5h, while weekly-limited
|
|
162
|
+
// siblings pay a whole week for the same requests. Escalates fast-refill from
|
|
163
|
+
// "a preference that fades" to "the default route, bounded by the session window
|
|
164
|
+
// and the hard anti-dogpile gates". soakDiscount 0.9 ≈ 10x early-window preference;
|
|
165
|
+
// soakFadeUtil 0.9 keeps the preference until the session window is nearly full
|
|
166
|
+
// (parity only in the last stretch, where capacity really is running out).
|
|
167
|
+
soakDiscount: 0.9,
|
|
168
|
+
soakFadeUtil: 0.9,
|
|
159
169
|
// Routing-cost tuning (lower cost = preferred). The goal is to AVOID
|
|
160
170
|
// short-term (rate/concurrency) throttling by spreading load across healthy
|
|
161
171
|
// accounts. So in-flight concurrency is the DOMINANT term, with a steep
|
|
@@ -2798,8 +2808,18 @@ export class AccountManager {
|
|
|
2798
2808
|
// per unit, still >2x the largest balancing term at the max discount) and
|
|
2799
2809
|
// the hard per-account request gate (safetyMaxActivePerAccount), cooldowns
|
|
2800
2810
|
// and failurePenalty remain undiscounted backstops.
|
|
2811
|
+
// SAFETY TERM — carries fastRefill's discount but NOT soak's (2026-09-26). The
|
|
2812
|
+
// past-D floor is what forces load to fan out before an account is dogpiled toward
|
|
2813
|
+
// a 429. Under soakDiscount 0.9 a discounted floor is 10x weaker, and the
|
|
2814
|
+
// production-shaped simulation (RTT 20s, weight 50) showed the consequence: the
|
|
2815
|
+
// unlimited account wins EVERY pick until the weekly-limited sibling is fully
|
|
2816
|
+
// starved — the account then rides at extreme in-flight depth and eats 429s, the
|
|
2817
|
+
// exact outcome the floor exists to prevent. A soak preference should change WHERE
|
|
2818
|
+
// traffic goes at equal depth, not how deep one account is allowed to stack.
|
|
2819
|
+
const safetyMult = this.scheduler.soakDiscount < 1
|
|
2820
|
+
? this._fastRefillMultiplier(account, { capFloor: true }) : refillMult;
|
|
2801
2821
|
const capPenalty = this.scheduler.capPenaltyWeight
|
|
2802
|
-
* Math.max(0, inflight - concTarget) *
|
|
2822
|
+
* Math.max(0, inflight - concTarget) * safetyMult;
|
|
2803
2823
|
|
|
2804
2824
|
// Burn-pace COST only (demoted from the old dominant scarcity×6 term): a
|
|
2805
2825
|
// soft de-preference of accounts burning ahead of an even pace. Never a bench.
|
|
@@ -3092,11 +3112,22 @@ export class AccountManager {
|
|
|
3092
3112
|
* non-negative band structure reserveFloorCost/criticalPressureCost were calibrated
|
|
3093
3113
|
* against), and never on safety terms (concurrency, capPenalty, reserve, critical).
|
|
3094
3114
|
*/
|
|
3095
|
-
_fastRefillMultiplier(account) {
|
|
3096
|
-
|
|
3115
|
+
_fastRefillMultiplier(account, { capFloor = false } = {}) {
|
|
3116
|
+
// SOAK MODE (2026-09-26): the multiplier call-site is shared, so the escalation is
|
|
3117
|
+
// a knob swap here — the caller's invariants (balancing terms only, hard gates
|
|
3118
|
+
// untouched, fades to 1) all hold unchanged. `capFloor: true` clamps the discount
|
|
3119
|
+
// at fastRefill's historical 0.6 — used ONLY by the past-D safety floor so a soak
|
|
3120
|
+
// preference cannot weaken anti-dogpile protection (see the capPenalty site).
|
|
3121
|
+
const soak = account?.type === 'provider' && account.quota?.weeklyAbsent;
|
|
3122
|
+
let disc = soak ? this.scheduler.soakDiscount : this.scheduler.fastRefillDiscount;
|
|
3123
|
+
if (capFloor) disc = Math.min(disc, this.scheduler.fastRefillDiscount);
|
|
3097
3124
|
if (!(disc > 0)) return 1; // feature off → multiplier 1
|
|
3098
|
-
if (!(
|
|
3099
|
-
|
|
3125
|
+
if (!soak) return 1; // (non-weeklyAbsent never reaches here with soak)
|
|
3126
|
+
// The FADE is clamped under capFloor as well: clamping only the discount left
|
|
3127
|
+
// soak's wider fade (0.90) applying a residual ~0.98 multiplier at ses 0.87 —
|
|
3128
|
+
// inside the reserve band, which the floor is supposed to protect absolutely.
|
|
3129
|
+
let fade = soak ? this.scheduler.soakFadeUtil : this.scheduler.fastRefillFadeUtil;
|
|
3130
|
+
if (capFloor) fade = Math.min(fade, this.scheduler.fastRefillFadeUtil);
|
|
3100
3131
|
const ses = clamp01(account.quota.providerSes ?? 0);
|
|
3101
3132
|
if (ses >= fade) return 1;
|
|
3102
3133
|
// 1 at ses=0 → 1-disc at ses=0; linear to 1 at ses=fade
|
|
@@ -3392,6 +3423,33 @@ export class AccountManager {
|
|
|
3392
3423
|
* (never the unified or scopedWeekly fields) so a provider reading can't reach
|
|
3393
3424
|
* the OAuth quota gates.
|
|
3394
3425
|
*/
|
|
3426
|
+
/** Store the latest Claude reset-grant listing (prober piggyback). */
|
|
3427
|
+
applyResetGrants(accountIndex, listing) {
|
|
3428
|
+
const account = this.accounts[accountIndex];
|
|
3429
|
+
if (!account || !listing) return;
|
|
3430
|
+
if (listing.error) { account.resetGrants = null; return; }
|
|
3431
|
+
account.resetGrants = {
|
|
3432
|
+
grants: listing.grants || [],
|
|
3433
|
+
nextGrantId: listing.nextGrantId ?? null,
|
|
3434
|
+
cooldownUntil: listing.cooldownUntil ?? null,
|
|
3435
|
+
eligible: listing.eligible !== false,
|
|
3436
|
+
checkedAt: Date.now(),
|
|
3437
|
+
};
|
|
3438
|
+
}
|
|
3439
|
+
|
|
3440
|
+
/** Store the latest reset-card listing for an account (prober piggyback).
|
|
3441
|
+
* Cards are display/decision state only — routing never reads them. */
|
|
3442
|
+
applyResetCards(accountIndex, cards) {
|
|
3443
|
+
const account = this.accounts[accountIndex];
|
|
3444
|
+
if (!account || !cards) return;
|
|
3445
|
+
if (cards.error) { account.resetCards = null; return; } // never half-write
|
|
3446
|
+
account.resetCards = {
|
|
3447
|
+
fiveHour: cards.fiveHour || [],
|
|
3448
|
+
weekly: cards.weekly || [],
|
|
3449
|
+
checkedAt: Date.now(),
|
|
3450
|
+
};
|
|
3451
|
+
}
|
|
3452
|
+
|
|
3395
3453
|
applyProviderUsage(accountIndex, usage) {
|
|
3396
3454
|
const account = this.accounts[accountIndex];
|
|
3397
3455
|
if (!account || !usage) return;
|
|
@@ -4470,7 +4528,13 @@ export class AccountManager {
|
|
|
4470
4528
|
// actually being applied — a flag that can never show "inert" is not a
|
|
4471
4529
|
// monitorable feature. Mirrors the peak block's shape.
|
|
4472
4530
|
fastRefill: {
|
|
4473
|
-
|
|
4531
|
+
// Reports BOTH regimes: `discount`/`fadeUtil` are fast-refill's, and `soak`
|
|
4532
|
+
// names what a weeklyAbsent account actually runs on — the per-account
|
|
4533
|
+
// `multiplier` rows below are computed from the soak knobs for those
|
|
4534
|
+
// accounts, so advertising only the fast-refill numbers made the block
|
|
4535
|
+
// describe settings that were not in force (caught 2026-09-26).
|
|
4536
|
+
enabled: this.scheduler.fastRefillDiscount > 0 || this.scheduler.soakDiscount > 0,
|
|
4537
|
+
soak: { discount: this.scheduler.soakDiscount, fadeUtil: this.scheduler.soakFadeUtil },
|
|
4474
4538
|
discount: this.scheduler.fastRefillDiscount,
|
|
4475
4539
|
fadeUtil: this.scheduler.fastRefillFadeUtil,
|
|
4476
4540
|
accounts: this.accounts
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude banked usage-limit resets (internal program `cedar_ember`; the
|
|
3
|
+
* session-only 1/week program is `juniper_tide` — deliberately NOT auto-claimed
|
|
4
|
+
* here: it costs a scarce weekly slot for the least valuable window).
|
|
5
|
+
*
|
|
6
|
+
* Endpoints (same shape Claude Code itself uses; verified against the 2.1.285
|
|
7
|
+
* binary and third-party implementations, 2026-09-30):
|
|
8
|
+
* GET /api/oauth/usage?cedar_ember=1&skip_spend=1
|
|
9
|
+
* -> cedar_ember: { eligible, ineligible_reason, grants: [...],
|
|
10
|
+
* next_grant_id, cooldown_until, weekly_resets_at }
|
|
11
|
+
* Grant: { id, resets_total, resets_left, starts_at, ends_at, usable_now,
|
|
12
|
+
* paused, use_requires_limit }
|
|
13
|
+
* GET /api/oauth/profile -> organization uuids (organizations[].uuid)
|
|
14
|
+
* POST /api/organizations/{org}/reset_rate_limits
|
|
15
|
+
* { program:'cedar_ember', grant_id, request_id }
|
|
16
|
+
*
|
|
17
|
+
* CRITICAL: without a Claude Code User-Agent the usage endpoint answers
|
|
18
|
+
* eligible:false, ineligible_reason:"surface" for EVERY token — the grants exist
|
|
19
|
+
* but are invisible. Mirror the CLI's UA exactly.
|
|
20
|
+
*
|
|
21
|
+
* Envelope: claim returns HTTP 200 with success:false on failure; `already_used`
|
|
22
|
+
* counts as success (idempotent) — request_id is the client's idempotency key.
|
|
23
|
+
*/
|
|
24
|
+
import { randomUUID } from 'node:crypto';
|
|
25
|
+
|
|
26
|
+
const USAGE_BASE = 'https://api.anthropic.com/api/oauth/usage';
|
|
27
|
+
const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile';
|
|
28
|
+
const CLAIM_PATH = '/api/organizations';
|
|
29
|
+
|
|
30
|
+
function cliUserAgent() {
|
|
31
|
+
// Match the installed CLI's version so server-side cli_version gating treats us
|
|
32
|
+
// like the real client. Falls back to a known-good recent version.
|
|
33
|
+
const v = process.env.MAXPOOL_CLAUDE_CLI_VERSION || '2.1.285';
|
|
34
|
+
return `claude-cli/${v} (external, cli)`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function headers(accessToken) {
|
|
38
|
+
return {
|
|
39
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
40
|
+
'User-Agent': cliUserAgent(),
|
|
41
|
+
'Accept': 'application/json',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Parse an ISO/epoch-ish timestamp into ms, or null. */
|
|
46
|
+
function toMs(v) {
|
|
47
|
+
if (v == null) return null;
|
|
48
|
+
if (typeof v === 'number') return v > 1e12 ? v : v * 1e3; // s vs ms heuristics
|
|
49
|
+
const t = Date.parse(v);
|
|
50
|
+
return Number.isFinite(t) ? t : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Normalize a cedar_ember grant. Drops malformed entries (the CLI does the same:
|
|
54
|
+
* "ignoring a malformed grant"). Keeps expired ones OUT unless resets remain and
|
|
55
|
+
* ends_at is still ahead — expired grants are display-only noise. */
|
|
56
|
+
export function normalizeGrant(raw, now = Date.now()) {
|
|
57
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
58
|
+
const g = raw.id != null ? raw : { ...raw, id: raw }; // tolerate id-less shapes
|
|
59
|
+
const resetsTotal = Number(g.resets_total ?? g.resetsTotal);
|
|
60
|
+
const resetsLeft = Number(g.resets_left ?? g.resetsLeft);
|
|
61
|
+
if (!Number.isFinite(resetsTotal) || resetsTotal < 1) return null;
|
|
62
|
+
// resets_left:0 = SPENT (live 2026-09-30: the opus55-launch grant on
|
|
63
|
+
// max@gomokka.com shows resets_total:1, resets_left:0, usable_now:false — already
|
|
64
|
+
// consumed at the Opus 5.5 launch). Keep it OUT of the claimable list; the LIST
|
|
65
|
+
// still surfaces it so the TUI can show the spent history if wanted.
|
|
66
|
+
if (!Number.isFinite(resetsLeft) || resetsLeft < 1) return null;
|
|
67
|
+
const startsAt = toMs(g.starts_at ?? g.startsAt);
|
|
68
|
+
const endsAt = toMs(g.ends_at ?? g.endsAt);
|
|
69
|
+
return {
|
|
70
|
+
id: g.id,
|
|
71
|
+
label: g.label || null,
|
|
72
|
+
resetsLeft, resetsTotal, startsAt, endsAt,
|
|
73
|
+
// clears is the money field: [five_hour, seven_day, seven_day_overage_included]
|
|
74
|
+
// means a grant clears BOTH windows (measured live). Default to assuming both
|
|
75
|
+
// when absent rather than none — the observed server always sends it.
|
|
76
|
+
clears: Array.isArray(g.clears) ? g.clears : ['five_hour', 'seven_day'],
|
|
77
|
+
usableNow: g.usable_now !== false && (resetsLeft >= 1),
|
|
78
|
+
paused: g.paused === true,
|
|
79
|
+
requiresLimit: g.use_requires_limit !== false, // most grants only fire at the wall
|
|
80
|
+
expired: endsAt != null && endsAt <= now,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** List this account's banked resets. Zero-spend read (skip_spend=1).
|
|
85
|
+
* Returns { grants: [], nextGrantId, cooldownUntil, eligible, reason } | { error }. */
|
|
86
|
+
export async function listResetGrants(accessToken, { signal } = {}) {
|
|
87
|
+
if (!accessToken) return { error: 'no token' };
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetch(`${USAGE_BASE}?cedar_ember=1&skip_spend=1`, {
|
|
90
|
+
headers: headers(accessToken), signal,
|
|
91
|
+
});
|
|
92
|
+
if (res.status === 401) return { error: 'unauthorized' };
|
|
93
|
+
if (!res.ok) return { error: `HTTP ${res.status}` };
|
|
94
|
+
const body = await res.json();
|
|
95
|
+
const ce = body?.cedar_ember;
|
|
96
|
+
if (!ce || typeof ce !== 'object') {
|
|
97
|
+
// Not an error: most accounts simply aren't in the program.
|
|
98
|
+
return { grants: [], nextGrantId: null, cooldownUntil: null, eligible: false, reason: 'not_offered' };
|
|
99
|
+
}
|
|
100
|
+
if (ce.eligible === false) {
|
|
101
|
+
return { grants: [], nextGrantId: null, cooldownUntil: toMs(ce.cooldown_until ?? ce.cooldownUntil),
|
|
102
|
+
eligible: false, reason: ce.ineligible_reason || 'ineligible' };
|
|
103
|
+
}
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
const grants = (Array.isArray(ce.grants) ? ce.grants : []).map(g => normalizeGrant(g, now)).filter(Boolean);
|
|
106
|
+
return {
|
|
107
|
+
grants,
|
|
108
|
+
nextGrantId: ce.next_grant_id ?? ce.nextGrantId ?? null,
|
|
109
|
+
cooldownUntil: toMs(ce.cooldown_until ?? ce.cooldownUntil),
|
|
110
|
+
eligible: true,
|
|
111
|
+
reason: null,
|
|
112
|
+
};
|
|
113
|
+
} catch (err) {
|
|
114
|
+
return { error: err.message || String(err) };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Claim one grant. The CLI never retries a claim on the wire; our request_id is
|
|
119
|
+
* random per CALL (server treats already_used as success), so a caller retry is
|
|
120
|
+
* safe but the id is not reused across distinct decisions. Returns
|
|
121
|
+
* { ok:true } | { ok:false, error, code }. */
|
|
122
|
+
export async function claimResetGrant(accessToken, grantId, { signal, requestId } = {}) {
|
|
123
|
+
if (!accessToken || !grantId) return { ok: false, error: 'missing args' };
|
|
124
|
+
try {
|
|
125
|
+
const prof = await fetch(PROFILE_URL, { headers: headers(accessToken), signal });
|
|
126
|
+
if (!prof.ok) return { ok: false, error: `profile HTTP ${prof.status}` };
|
|
127
|
+
const orgs = (await prof.json())?.organizations;
|
|
128
|
+
const org = Array.isArray(orgs) && orgs.length ? orgs[0].uuid : null;
|
|
129
|
+
if (!org) return { ok: false, error: 'no oauth organization' };
|
|
130
|
+
|
|
131
|
+
const rid = requestId || randomUUID();
|
|
132
|
+
const res = await fetch(`https://api.anthropic.com${CLAIM_PATH}/${org}/reset_rate_limits`, {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: { ...headers(accessToken), 'Content-Type': 'application/json' },
|
|
135
|
+
body: JSON.stringify({ program: 'cedar_ember', grant_id: grantId, request_id: rid }),
|
|
136
|
+
signal,
|
|
137
|
+
});
|
|
138
|
+
const data = await res.json().catch(() => null);
|
|
139
|
+
// Success shapes observed: {success:true} or result:"already_used" (idempotent).
|
|
140
|
+
const okFlag = data?.success === true || data?.result === 'already_used';
|
|
141
|
+
if (okFlag) return { ok: true, grantId, alreadyUsed: data?.result === 'already_used' };
|
|
142
|
+
return { ok: false, error: data?.error?.message || JSON.stringify(data).slice(0, 200), code: data?.error?.type };
|
|
143
|
+
} catch (err) {
|
|
144
|
+
return { ok: false, error: err.message || String(err) };
|
|
145
|
+
}
|
|
146
|
+
}
|
package/src/prober.js
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
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';
|
|
12
|
+
import { listResetGrants, claimResetGrant } from './claude-reset-grants.js';
|
|
13
|
+
import { decideZai, decideClaude } from './reset-policy.js';
|
|
11
14
|
|
|
12
15
|
export class Prober {
|
|
13
16
|
constructor(accountManager, { intervalMs = 0, probeFn = fetchUsage, providerProbeFn = fetchProviderUsage, timeoutMs = 10_000, log = console.log, usageGapMs = null } = {}) {
|
|
@@ -179,9 +182,56 @@ export class Prober {
|
|
|
179
182
|
const usage = await this._withTimeout(this.providerProbeFn(account));
|
|
180
183
|
if (!usage) return; // timed out — try again next cycle
|
|
181
184
|
this.am.applyProviderUsage(account.index, usage);
|
|
185
|
+
// Reset cards piggyback on the same poll cycle (z.ai only; zero-spend read).
|
|
186
|
+
// Failures are swallowed on purpose: cards are an enhancement, never a probe
|
|
187
|
+
// health signal — a card-list 401 must not turn the quota bars stale.
|
|
188
|
+
// OWNER GATE (2026-09-30): disabled accounts are excluded from listing AND
|
|
189
|
+
// redemption entirely — a readable key on a disabled account (someone else
|
|
190
|
+
// uses it) is not authority to act.
|
|
191
|
+
if (account.enabled !== false && account.credential) {
|
|
192
|
+
if (account.provider === 'zai' && this.am.applyResetCards) {
|
|
193
|
+
try {
|
|
194
|
+
const cards = await this._withTimeout(listResetCards(account.credential));
|
|
195
|
+
if (cards) {
|
|
196
|
+
this.am.applyResetCards(account.index, cards);
|
|
197
|
+
const act = decideZai(account, cards);
|
|
198
|
+
if (act) await this._redeemZaiCard(account, act);
|
|
199
|
+
}
|
|
200
|
+
} catch { /* cards are best-effort */ }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
182
203
|
} catch { /* best-effort; never let a probe throw */ }
|
|
183
204
|
}
|
|
184
205
|
|
|
206
|
+
/** Execute a z.ai card redeem decided by the policy. Logs before/after. */
|
|
207
|
+
async _redeemZaiCard(account, act) {
|
|
208
|
+
try {
|
|
209
|
+
const q = account.quota || {};
|
|
210
|
+
this.log(`[reset-card] ${account.name}: redeeming ${act.resetType} card ${act.card.recordId} (${act.reason}; ses=${q.providerSes ?? '?'} wk=${q.providerWk ?? '?'})`);
|
|
211
|
+
const r = await this._withTimeout(redeemResetCard(account.credential, act.card.recordId, act.resetType));
|
|
212
|
+
if (r?.ok) {
|
|
213
|
+
this.log(`[reset-card] ${account.name}: card ${act.card.recordId} redeemed`);
|
|
214
|
+
if (this.am.applyResetCards) {
|
|
215
|
+
const fresh = await this._withTimeout(listResetCards(account.credential));
|
|
216
|
+
if (fresh) this.am.applyResetCards(account.index, fresh);
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
this.log(`[reset-card] ${account.name}: redeem FAILED card ${act.card.recordId}: ${r?.error}`);
|
|
220
|
+
}
|
|
221
|
+
} catch (e) { this.log(`[reset-card] ${account.name}: redeem error ${e.message}`); }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Execute a Claude cedar_ember claim decided by the policy. */
|
|
225
|
+
async _claimClaudeGrant(account, act) {
|
|
226
|
+
try {
|
|
227
|
+
const q = account.quota || {};
|
|
228
|
+
this.log(`[reset-grant] ${account.name}: claiming grant ${act.grant.id} (${act.reason}; ses=${q.unified5h ?? '?'} wk=${q.unified7d ?? '?'})`);
|
|
229
|
+
const r = await this._withTimeout(claimResetGrant(account.credential, act.grant.id));
|
|
230
|
+
if (r?.ok) this.log(`[reset-grant] ${account.name}: grant ${act.grant.id} claimed${r.alreadyUsed ? ' (already used — idempotent)' : ''}`);
|
|
231
|
+
else this.log(`[reset-grant] ${account.name}: claim FAILED ${act.grant.id}: ${r?.error}`);
|
|
232
|
+
} catch (e) { this.log(`[reset-grant] ${account.name}: claim error ${e.message}`); }
|
|
233
|
+
}
|
|
234
|
+
|
|
185
235
|
/** Probe one OAUTH account. Returns {ok, status} so probeAll can pace/back-off
|
|
186
236
|
* and so a persistent failure is RECORDED (surfaced in the TUI/status) rather
|
|
187
237
|
* than silently swallowed — a swallowed failing probe is what let a stale
|
|
@@ -193,6 +243,18 @@ export class Prober {
|
|
|
193
243
|
// on 2solarmax@ between 2026-09-18 and 09-20.
|
|
194
244
|
if (account.subscriptionGone) return { ok: false, status: 403 };
|
|
195
245
|
try {
|
|
246
|
+
// Claude banked resets (cedar_ember) piggyback on the oauth poll — same
|
|
247
|
+
// OWNER GATE as z.ai cards: a DISABLED account is never listed or claimed.
|
|
248
|
+
try {
|
|
249
|
+
if (account.enabled !== false && account.credential && this.am.applyResetGrants) {
|
|
250
|
+
const listing = await this._withTimeout(listResetGrants(account.credential));
|
|
251
|
+
if (listing) {
|
|
252
|
+
this.am.applyResetGrants(account.index, listing);
|
|
253
|
+
const act = decideClaude(account, listing);
|
|
254
|
+
if (act) await this._claimClaudeGrant(account, act);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
} catch { /* grants are best-effort; never break the quota probe */ }
|
|
196
258
|
await this.am.ensureTokenFresh(account.index);
|
|
197
259
|
let usage = await this._withTimeout(this.probeFn(account.credential));
|
|
198
260
|
if (usage?.status === 401) {
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHEN to redeem a limit-reset: the deliberate decision layer.
|
|
3
|
+
*
|
|
4
|
+
* Two value sources, one policy:
|
|
5
|
+
* - z.ai reset cards: { fiveHour:[{recordId,expiresAt,expired}], weekly:[...] }
|
|
6
|
+
* - Claude cedar_ember grants: { grants:[{id,resetsLeft,endsAt,usableNow,paused,
|
|
7
|
+
* requiresLimit,clears}], nextGrantId, cooldownUntil, eligible }
|
|
8
|
+
*
|
|
9
|
+
* Rules (owner, 2026-09-30 — "account not just for exhaustion, but also the time
|
|
10
|
+
* left until expiry of the reset"):
|
|
11
|
+
*
|
|
12
|
+
* R0 ENABLED GATE — the caller must never even ask about a disabled account. The
|
|
13
|
+
* policy asserts it too: a disabled account's cards/grants are someone else's.
|
|
14
|
+
* (privacy@gomokka.com: owner uses it elsewhere; readable key is not authority.)
|
|
15
|
+
*
|
|
16
|
+
* R1 AT-THE-WALL — redeem when the window the reset clears is effectively spent:
|
|
17
|
+
* weekly >= 0.90, or (no weekly-clearing reset and 5h >= 0.95). Wall, not 100%:
|
|
18
|
+
* routing benches an account before the bar reads full.
|
|
19
|
+
*
|
|
20
|
+
* R2 DYING VALUE — a reset expiring soon is worth MORE than waiting: redeem when
|
|
21
|
+
* expiry < 24h AND the account has consumed enough that the refill buys real
|
|
22
|
+
* capacity (utilization > 0.30 for weekly-clearing; > 0.50 for 5h-only). At
|
|
23
|
+
* expiry the reset is worth zero, so "save it for a worse moment" is the
|
|
24
|
+
* false economy — the worse moment must occur BEFORE expiry or never.
|
|
25
|
+
*
|
|
26
|
+
* R3 EXPIRED — never redeem an expired reset (idempotent keys protect retries,
|
|
27
|
+
* but a redeem against an expired card is just a guaranteed API error).
|
|
28
|
+
*
|
|
29
|
+
* R4 ORDER — prefer the reset that clears the MOST (weekly clears both windows);
|
|
30
|
+
* among equals, the soonest-expiring first. Never spend a weekly-clearing
|
|
31
|
+
* reset when a 5h-only one would do — EXCEPT under R2 with <24h left on the
|
|
32
|
+
* weekly reset and no 5h reset at all.
|
|
33
|
+
*
|
|
34
|
+
* R5 PACE — at most ONE redeem per account per evaluation. The caller invokes the
|
|
35
|
+
* policy each poll cycle; the deterministic z.ai requestId makes an accidental
|
|
36
|
+
* double-fire harmless, but the policy still returns a single action.
|
|
37
|
+
*/
|
|
38
|
+
export const POLICY = {
|
|
39
|
+
wallWeekly: 0.90, // weekly at 90% = at the wall
|
|
40
|
+
wallSes: 0.95, // 5h at 95% = at the wall
|
|
41
|
+
dyingWindowMs: 24 * 3600 * 1000, // redeem-don't-save horizon
|
|
42
|
+
dyingMinUtilWeekly: 0.30, // below this a dying weekly refill buys too little
|
|
43
|
+
dyingMinUtilSes: 0.50, // 5h refills are cheap capacity; demand more waste first
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function msLeft(t, now) { return t == null ? Infinity : t - now; }
|
|
47
|
+
|
|
48
|
+
/** Decide the z.ai action. Returns {kind:'card', card, resetType} | null. */
|
|
49
|
+
export function decideZai(account, cards, now = Date.now()) {
|
|
50
|
+
if (account?.enabled === false) return null; // R0
|
|
51
|
+
if (!cards || cards.error) return null;
|
|
52
|
+
const q = account.quota || {};
|
|
53
|
+
const ses = q.providerSes ?? null;
|
|
54
|
+
const wk = q.weeklyAbsent ? null : (q.providerWk ?? null);
|
|
55
|
+
const wkUtil = wk; const sesUtil = ses;
|
|
56
|
+
|
|
57
|
+
const weeklyCards = (cards.weekly || []).filter(c => !c.expired && c.recordId != null);
|
|
58
|
+
const sesCards = (cards.fiveHour || []).filter(c => !c.expired && c.recordId != null);
|
|
59
|
+
// R4: soonest expiry first inside each class.
|
|
60
|
+
weeklyCards.sort((a, b) => (a.expiresAt ?? Infinity) - (b.expiresAt ?? Infinity));
|
|
61
|
+
sesCards.sort((a, b) => (a.expiresAt ?? Infinity) - (b.expiresAt ?? Infinity));
|
|
62
|
+
|
|
63
|
+
const weeklyCard = weeklyCards[0] || null;
|
|
64
|
+
const sesCard = sesCards[0] || null;
|
|
65
|
+
|
|
66
|
+
// --- weekly card: clears BOTH windows (z.ai docs; measured: glm1 100%->0% both)
|
|
67
|
+
if (weeklyCard) {
|
|
68
|
+
const worst = Math.max(wkUtil ?? 0, sesUtil ?? 0);
|
|
69
|
+
const atWall = wkUtil != null && wkUtil >= POLICY.wallWeekly;
|
|
70
|
+
const dying = msLeft(weeklyCard.expiresAt, now) < POLICY.dyingWindowMs
|
|
71
|
+
&& worst >= POLICY.dyingMinUtilWeekly;
|
|
72
|
+
if (atWall || dying) {
|
|
73
|
+
return { kind: 'card', card: weeklyCard, resetType: 'WEEK',
|
|
74
|
+
reason: atWall ? 'weekly at wall' : 'dying value' };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- 5h card: clears only the session window
|
|
79
|
+
if (sesCard) {
|
|
80
|
+
const atWall = sesUtil != null && sesUtil >= POLICY.wallSes
|
|
81
|
+
&& (weeklyCard == null); // R4: a weekly card can do this job AND more
|
|
82
|
+
const dying = msLeft(sesCard.expiresAt, now) < POLICY.dyingWindowMs
|
|
83
|
+
&& (sesUtil ?? 0) >= POLICY.dyingMinUtilSes;
|
|
84
|
+
if (atWall || dying) {
|
|
85
|
+
return { kind: 'card', card: sesCard, resetType: 'FIVE_HOUR',
|
|
86
|
+
reason: atWall ? 'session at wall' : 'dying value' };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Decide the Claude action. Returns {kind:'grant', grant} | null. */
|
|
93
|
+
export function decideClaude(account, listing, now = Date.now()) {
|
|
94
|
+
if (account?.enabled === false) return null; // R0
|
|
95
|
+
if (!listing || listing.error || listing.eligible === false) return null;
|
|
96
|
+
if (listing.cooldownUntil != null && listing.cooldownUntil > now) return null;
|
|
97
|
+
const grants = (listing.grants || [])
|
|
98
|
+
.filter(g => g.usableNow && !g.paused && !g.expired)
|
|
99
|
+
.sort((a, b) => (a.endsAt ?? Infinity) - (b.endsAt ?? Infinity));
|
|
100
|
+
// The server names the next grant it would spend; honoring it avoids surprises.
|
|
101
|
+
const chosen = grants.find(g => listing.nextGrantId == null || g.id === listing.nextGrantId) || grants[0];
|
|
102
|
+
if (!chosen) return null;
|
|
103
|
+
|
|
104
|
+
const q = account.quota || {};
|
|
105
|
+
const wkUtil = q.unified7d ?? null;
|
|
106
|
+
const sesUtil = q.unified5h ?? null;
|
|
107
|
+
const clearsWk = chosen.clears?.includes('seven_day');
|
|
108
|
+
|
|
109
|
+
let atWall = false;
|
|
110
|
+
if (clearsWk) atWall = wkUtil != null && wkUtil >= POLICY.wallWeekly;
|
|
111
|
+
else atWall = sesUtil != null && sesUtil >= POLICY.wallSes;
|
|
112
|
+
|
|
113
|
+
// use_requires_limit: some grants may only fire AT the wall (server-enforced).
|
|
114
|
+
const dying = msLeft(chosen.endsAt, now) < POLICY.dyingWindowMs
|
|
115
|
+
&& Math.max(wkUtil ?? 0, sesUtil ?? 0) >= (clearsWk ? POLICY.dyingMinUtilWeekly : POLICY.dyingMinUtilSes);
|
|
116
|
+
// A requires-limit grant cannot be redeemed pre-wall even for dying value.
|
|
117
|
+
if (atWall || (dying && !chosen.requiresLimit)) {
|
|
118
|
+
return { kind: 'grant', grant: chosen, reason: atWall ? 'at wall' : 'dying value' };
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
package/src/tui.js
CHANGED
|
@@ -255,6 +255,30 @@ function settingsTags(am, a) {
|
|
|
255
255
|
}
|
|
256
256
|
const capTag = capText(a, capBenched(am, a), am);
|
|
257
257
|
if (capTag) tags.push(capTag);
|
|
258
|
+
// A switched-off account whose credentials ALSO died keeps "disabled" in the status
|
|
259
|
+
// column (so the disabled inventory stays readable at a glance) and carries the second
|
|
260
|
+
// problem here. Without this the row would hide the fact that re-enabling it later
|
|
261
|
+
// yields a broken account — the 2026-08-10 report that 8 disabled accounts were all
|
|
262
|
+
// sitting on dead credentials.
|
|
263
|
+
if (a?.enabled === false) {
|
|
264
|
+
if (a.refreshDead) tags.push(yellow('needs login'));
|
|
265
|
+
else if (a.subscriptionGone) tags.push(red('no sub'));
|
|
266
|
+
return tags; // disabled: never show card inventory either (owner gate)
|
|
267
|
+
}
|
|
268
|
+
// Available limit resets — the pool redeems these automatically per policy
|
|
269
|
+
// (reset-policy.js). Shown so the owner can see banked value before it expires.
|
|
270
|
+
const rc = a?.resetCards;
|
|
271
|
+
if (rc) {
|
|
272
|
+
const live5 = (rc.fiveHour || []).filter(c => !c.expired).length;
|
|
273
|
+
const liveW = (rc.weekly || []).filter(c => !c.expired).length;
|
|
274
|
+
if (liveW) tags.push(green(`reset wk×${liveW}`));
|
|
275
|
+
if (live5) tags.push(green(`reset 5h×${live5}`));
|
|
276
|
+
}
|
|
277
|
+
const rg = a?.resetGrants;
|
|
278
|
+
if (rg?.eligible) {
|
|
279
|
+
const live = (rg.grants || []).filter(g => g.usableNow && !g.expired).length;
|
|
280
|
+
if (live) tags.push(green(`reset×${live}`));
|
|
281
|
+
}
|
|
258
282
|
return tags;
|
|
259
283
|
}
|
|
260
284
|
|
|
@@ -2084,7 +2108,9 @@ export class TUI {
|
|
|
2084
2108
|
if (a.refreshDead) effectiveStatus = a.enabled === false ? 'disabled-reauth' : 'reauth';
|
|
2085
2109
|
// Subscription gone (org-disabled 403): a DISTINCT state from reauth — re-logging in
|
|
2086
2110
|
// will NOT fix it until the subscription is re-purchased. Says the actionable thing.
|
|
2087
|
-
|
|
2111
|
+
// Same precedence rule as reauth above: when the account is ALSO switched off,
|
|
2112
|
+
// "disabled" stays the headline so the disabled inventory remains readable.
|
|
2113
|
+
else if (a.subscriptionGone) effectiveStatus = a.enabled === false ? 'disabled-nosub' : 'no sub';
|
|
2088
2114
|
switch (effectiveStatus) {
|
|
2089
2115
|
case 'active': status = isCur ? green('active') : 'active'; break;
|
|
2090
2116
|
case 'reauth': status = yellow('reauth'); break;
|
|
@@ -2093,9 +2119,19 @@ export class TUI {
|
|
|
2093
2119
|
case 'waiting': status = yellow('waiting'); break;
|
|
2094
2120
|
case 'paused': status = yellow('paused'); break;
|
|
2095
2121
|
case 'disabled': status = red('✕ disabled'); break;
|
|
2096
|
-
// Disabled AND needs re-login — both facts matter
|
|
2097
|
-
//
|
|
2098
|
-
|
|
2122
|
+
// Disabled AND needs re-login — both facts matter, and DISABLED LEADS.
|
|
2123
|
+
// The owner's question that drove this (2026-09-30): "when I disable the account
|
|
2124
|
+
// and then the token expires, does it show disabled or reauth? It needs to
|
|
2125
|
+
// continue to show disabled otherwise I'm confused as to which ones I have
|
|
2126
|
+
// disabled." Showing only '✕ reauth' made a deliberately-off account
|
|
2127
|
+
// indistinguishable from a live one with dead credentials, so the disabled
|
|
2128
|
+
// inventory could not be read off the screen at all. So the STATUS column always
|
|
2129
|
+
// says 'disabled' for an account you switched off, and the reason it ALSO cannot
|
|
2130
|
+
// serve rides as its own tag ("needs login" / "no sub") — collapsing both into one
|
|
2131
|
+
// '+' marker would lose the distinction between "log in again" (fixable) and
|
|
2132
|
+
// "subscription gone" (not fixable by logging in).
|
|
2133
|
+
case 'disabled-reauth':
|
|
2134
|
+
case 'disabled-nosub': status = red('✕ disabled'); break;
|
|
2099
2135
|
case 'no sub': status = red('✕ no sub'); break;
|
|
2100
2136
|
case 'throttled': {
|
|
2101
2137
|
// A transient auto-recovering cooldown — show the remaining time (from
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* z.ai Quota Reset Cards — list + redeem, via the same API key the quota poller
|
|
3
|
+
* already uses. Zero browser, zero captcha (the exact pain that motivated this:
|
|
4
|
+
* ~6 provider accounts, each a manual web login with an aggressive captcha to
|
|
5
|
+
* check for a card that usually isn't there).
|
|
6
|
+
*
|
|
7
|
+
* Endpoints (verified live 2026-09-30):
|
|
8
|
+
* GET /api/biz/customer-package-reset/list?targetType=PERSONAL
|
|
9
|
+
* -> data.fiveHourResets[] / weekResets[], each {recordId, grantType,
|
|
10
|
+
* expireTime 'YYYY-MM-DD HH:mm:ss' (UTC), available}
|
|
11
|
+
* POST /api/biz/customer-package-reset/use
|
|
12
|
+
* body {recordId, targetType:'PERSONAL', resetType:'WEEK'|'FIVE_HOUR',
|
|
13
|
+
* requestId} -> envelope; data echoes the recordId on success
|
|
14
|
+
*
|
|
15
|
+
* Semantics measured live:
|
|
16
|
+
* - a WEEKLY card ALSO refills the 5h window (z.ai docs) — never spend a weekly
|
|
17
|
+
* card on a 5h wall.
|
|
18
|
+
* - `requestId` is the idempotency key: a retry with the SAME id cannot burn a
|
|
19
|
+
* second card. We derive it deterministically from recordId so any retry of a
|
|
20
|
+
* failed redeem is automatically safe.
|
|
21
|
+
* - the envelope returns HTTP 200 with success:false on failure — always check
|
|
22
|
+
* the envelope, never the status code.
|
|
23
|
+
* - an unavailable/expired/foreign card returns code 400 "The specified reset
|
|
24
|
+
* attempt is unavailable. Refresh and try again".
|
|
25
|
+
* - invalid resetType vocabulary yields 400 "Unsupported reset type: X" (safe
|
|
26
|
+
* probe that cannot spend anything).
|
|
27
|
+
*/
|
|
28
|
+
import { createHash } from 'node:crypto';
|
|
29
|
+
|
|
30
|
+
const BASE = 'https://api.z.ai/api/biz/customer-package-reset';
|
|
31
|
+
const LIST_URL = `${BASE}/list?targetType=PERSONAL`;
|
|
32
|
+
const USE_URL = `${BASE}/use`;
|
|
33
|
+
|
|
34
|
+
/** 'YYYY-MM-DD HH:mm:ss' (z.ai has no timezone suffix; it is UTC) -> epoch ms. */
|
|
35
|
+
export function parseCardExpiry(s) {
|
|
36
|
+
if (!s || typeof s !== 'string') return null;
|
|
37
|
+
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/);
|
|
38
|
+
if (!m) return null;
|
|
39
|
+
return Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Normalize a raw card array -> [{recordId, expiresAt, expired}], available only. */
|
|
43
|
+
function normalizeCards(arr, now = Date.now()) {
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const c of (Array.isArray(arr) ? arr : [])) {
|
|
46
|
+
if (!c || c.available !== true) continue;
|
|
47
|
+
const recordId = Number(c.recordId);
|
|
48
|
+
if (!Number.isFinite(recordId)) continue;
|
|
49
|
+
const expiresAt = parseCardExpiry(c.expireTime);
|
|
50
|
+
out.push({ recordId, expiresAt, expired: expiresAt != null && expiresAt <= now });
|
|
51
|
+
}
|
|
52
|
+
// Soonest-expiring first — that's the one to spend first.
|
|
53
|
+
out.sort((a, b) => (a.expiresAt ?? Infinity) - (b.expiresAt ?? Infinity));
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** List this account's available reset cards. Zero-spend read.
|
|
58
|
+
* Returns { fiveHour: [], weekly: [] } | { error }. */
|
|
59
|
+
export async function listResetCards(token, { signal } = {}) {
|
|
60
|
+
if (!token) return { error: 'no token' };
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetch(LIST_URL, {
|
|
63
|
+
headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json' },
|
|
64
|
+
signal,
|
|
65
|
+
});
|
|
66
|
+
if (res.status === 401) return { error: 'unauthorized' };
|
|
67
|
+
const data = await res.json();
|
|
68
|
+
if (data?.code !== 200 || !data?.data) {
|
|
69
|
+
return { error: `bad_envelope${data?.code != null ? ' code=' + data.code : ''}` };
|
|
70
|
+
}
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
return {
|
|
73
|
+
fiveHour: normalizeCards(data.data.fiveHourResets, now),
|
|
74
|
+
weekly: normalizeCards(data.data.weekResets, now),
|
|
75
|
+
source: 'zai',
|
|
76
|
+
};
|
|
77
|
+
} catch (err) {
|
|
78
|
+
return { error: err.message || String(err) };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Deterministic requestId for a card: retries of the SAME redeem are idempotent
|
|
83
|
+
* server-side, so a network blip mid-redeem can never double-spend. */
|
|
84
|
+
function requestIdFor(recordId) {
|
|
85
|
+
const h = createHash('sha256').update(`maxpool-zai-card-${recordId}`).digest('hex').slice(0, 24);
|
|
86
|
+
return `maxpool-${h}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Redeem one card. NEVER retries on the wire here (the idempotency key makes a
|
|
90
|
+
* caller-level retry safe). Returns { ok:true, recordId } | { ok:false, error, code }. */
|
|
91
|
+
export async function redeemResetCard(token, recordId, resetType, { signal } = {}) {
|
|
92
|
+
if (resetType !== 'WEEK' && resetType !== 'FIVE_HOUR') {
|
|
93
|
+
return { ok: false, error: `invalid resetType ${resetType}` };
|
|
94
|
+
}
|
|
95
|
+
if (!Number.isFinite(recordId)) return { ok: false, error: 'invalid recordId' };
|
|
96
|
+
try {
|
|
97
|
+
const res = await fetch(USE_URL, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
100
|
+
body: JSON.stringify({
|
|
101
|
+
recordId, targetType: 'PERSONAL', resetType,
|
|
102
|
+
requestId: requestIdFor(recordId),
|
|
103
|
+
}),
|
|
104
|
+
signal,
|
|
105
|
+
});
|
|
106
|
+
const data = await res.json().catch(() => null);
|
|
107
|
+
if (data?.success === true && (data.code === 200 || data.code === 0)) {
|
|
108
|
+
return { ok: true, recordId, data: data.data ?? null };
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
error: data?.msg || `envelope code ${data?.code} (HTTP ${res.status})`,
|
|
113
|
+
code: data?.code ?? null,
|
|
114
|
+
};
|
|
115
|
+
} catch (err) {
|
|
116
|
+
return { ok: false, error: err.message || String(err) };
|
|
117
|
+
}
|
|
118
|
+
}
|