maxpool 1.23.0 → 1.24.1
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 +27 -0
- 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 +48 -6
- package/src/zai-reset-cards.js +118 -0
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -3423,6 +3423,33 @@ export class AccountManager {
|
|
|
3423
3423
|
* (never the unified or scopedWeekly fields) so a provider reading can't reach
|
|
3424
3424
|
* the OAuth quota gates.
|
|
3425
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
|
+
|
|
3426
3453
|
applyProviderUsage(accountIndex, usage) {
|
|
3427
3454
|
const account = this.accounts[accountIndex];
|
|
3428
3455
|
if (!account || !usage) return;
|
|
@@ -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
|
@@ -32,6 +32,8 @@ const vw = s => strip(s).length;
|
|
|
32
32
|
const NAME_W = 20; // a.name.slice(0, NAME_W).padEnd(NAME_W) — fits a full email like 2solarmax@gmail.com (19)
|
|
33
33
|
const PROVIDER_W = 9; // providerLabel(a).padEnd(PROVIDER_W) — fits "Anthropic"
|
|
34
34
|
const STATUS_W = 13; // rpad(status, STATUS_W) — fits "throttled 59s"
|
|
35
|
+
const RESETS_W = 3; // 'Rst' column — banked limit resets (cards/grants) the
|
|
36
|
+
// pool can auto-redeem; '0' when none, count when some.
|
|
35
37
|
const ROW_PREFIX = ' '; // ' ' + sel(1) + cur(1) + ' ' — 4 cols before the name
|
|
36
38
|
|
|
37
39
|
// Human provider name for the accounts-table "Provider" column. account.provider
|
|
@@ -60,6 +62,7 @@ function acctHeader(W) {
|
|
|
60
62
|
+ 'Account'.padEnd(NAME_W) + ' '
|
|
61
63
|
+ 'Provider'.padEnd(PROVIDER_W) + ' '
|
|
62
64
|
+ 'Status'.padEnd(STATUS_W) + ' '
|
|
65
|
+
+ 'Rst'.padEnd(RESETS_W) + ' '
|
|
63
66
|
+ quota;
|
|
64
67
|
}
|
|
65
68
|
|
|
@@ -255,6 +258,16 @@ function settingsTags(am, a) {
|
|
|
255
258
|
}
|
|
256
259
|
const capTag = capText(a, capBenched(am, a), am);
|
|
257
260
|
if (capTag) tags.push(capTag);
|
|
261
|
+
// A switched-off account whose credentials ALSO died keeps "disabled" in the status
|
|
262
|
+
// column (so the disabled inventory stays readable at a glance) and carries the second
|
|
263
|
+
// problem here. Without this the row would hide the fact that re-enabling it later
|
|
264
|
+
// yields a broken account — the 2026-08-10 report that 8 disabled accounts were all
|
|
265
|
+
// sitting on dead credentials.
|
|
266
|
+
if (a?.enabled === false) {
|
|
267
|
+
if (a.refreshDead) tags.push(yellow('needs login'));
|
|
268
|
+
else if (a.subscriptionGone) tags.push(red('no sub'));
|
|
269
|
+
return tags; // disabled: never show card inventory either (owner gate)
|
|
270
|
+
}
|
|
258
271
|
return tags;
|
|
259
272
|
}
|
|
260
273
|
|
|
@@ -2084,7 +2097,9 @@ export class TUI {
|
|
|
2084
2097
|
if (a.refreshDead) effectiveStatus = a.enabled === false ? 'disabled-reauth' : 'reauth';
|
|
2085
2098
|
// Subscription gone (org-disabled 403): a DISTINCT state from reauth — re-logging in
|
|
2086
2099
|
// will NOT fix it until the subscription is re-purchased. Says the actionable thing.
|
|
2087
|
-
|
|
2100
|
+
// Same precedence rule as reauth above: when the account is ALSO switched off,
|
|
2101
|
+
// "disabled" stays the headline so the disabled inventory remains readable.
|
|
2102
|
+
else if (a.subscriptionGone) effectiveStatus = a.enabled === false ? 'disabled-nosub' : 'no sub';
|
|
2088
2103
|
switch (effectiveStatus) {
|
|
2089
2104
|
case 'active': status = isCur ? green('active') : 'active'; break;
|
|
2090
2105
|
case 'reauth': status = yellow('reauth'); break;
|
|
@@ -2093,9 +2108,19 @@ export class TUI {
|
|
|
2093
2108
|
case 'waiting': status = yellow('waiting'); break;
|
|
2094
2109
|
case 'paused': status = yellow('paused'); break;
|
|
2095
2110
|
case 'disabled': status = red('✕ disabled'); break;
|
|
2096
|
-
// Disabled AND needs re-login — both facts matter
|
|
2097
|
-
//
|
|
2098
|
-
|
|
2111
|
+
// Disabled AND needs re-login — both facts matter, and DISABLED LEADS.
|
|
2112
|
+
// The owner's question that drove this (2026-09-30): "when I disable the account
|
|
2113
|
+
// and then the token expires, does it show disabled or reauth? It needs to
|
|
2114
|
+
// continue to show disabled otherwise I'm confused as to which ones I have
|
|
2115
|
+
// disabled." Showing only '✕ reauth' made a deliberately-off account
|
|
2116
|
+
// indistinguishable from a live one with dead credentials, so the disabled
|
|
2117
|
+
// inventory could not be read off the screen at all. So the STATUS column always
|
|
2118
|
+
// says 'disabled' for an account you switched off, and the reason it ALSO cannot
|
|
2119
|
+
// serve rides as its own tag ("needs login" / "no sub") — collapsing both into one
|
|
2120
|
+
// '+' marker would lose the distinction between "log in again" (fixable) and
|
|
2121
|
+
// "subscription gone" (not fixable by logging in).
|
|
2122
|
+
case 'disabled-reauth':
|
|
2123
|
+
case 'disabled-nosub': status = red('✕ disabled'); break;
|
|
2099
2124
|
case 'no sub': status = red('✕ no sub'); break;
|
|
2100
2125
|
case 'throttled': {
|
|
2101
2126
|
// A transient auto-recovering cooldown — show the remaining time (from
|
|
@@ -2111,8 +2136,25 @@ export class TUI {
|
|
|
2111
2136
|
// Widened from 10 to fit "throttled 59s" so the quota bars stay column-aligned.
|
|
2112
2137
|
status = rpad(status, STATUS_W);
|
|
2113
2138
|
|
|
2139
|
+
// Rst column — banked limit resets (owner request 2026-09-30: a separate
|
|
2140
|
+
// column showing 0 or the count). z.ai cards + Claude cedar_ember grants,
|
|
2141
|
+
// not-yet-expired, on ENABLED accounts only: a disabled account's resets are
|
|
2142
|
+
// not ours to spend, and showing a count there would imply otherwise.
|
|
2143
|
+
let resetsCell;
|
|
2144
|
+
{
|
|
2145
|
+
let n = 0;
|
|
2146
|
+
if (a.enabled !== false) {
|
|
2147
|
+
const rc = a.resetCards;
|
|
2148
|
+
if (rc) n += (rc.fiveHour || []).filter(c => !c.expired).length
|
|
2149
|
+
+ (rc.weekly || []).filter(c => !c.expired).length;
|
|
2150
|
+
const rg = a.resetGrants;
|
|
2151
|
+
if (rg?.eligible) n += (rg.grants || []).filter(g => g.usableNow && !g.expired).length;
|
|
2152
|
+
}
|
|
2153
|
+
resetsCell = rpad(n > 0 ? green(String(n)) : gray('0'), RESETS_W);
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2114
2156
|
if (a.type === 'provider') {
|
|
2115
|
-
return this._renderProviderAcct(sel, cur, name, type, status, a, bw, showBoth);
|
|
2157
|
+
return this._renderProviderAcct(sel, cur, name, type, status + ' ' + resetsCell, a, bw, showBoth);
|
|
2116
2158
|
}
|
|
2117
2159
|
|
|
2118
2160
|
// Quota ratios — prefer unified (Claude Max), fall back to standard (API key)
|
|
@@ -2135,7 +2177,7 @@ export class TUI {
|
|
|
2135
2177
|
t2 = t1;
|
|
2136
2178
|
}
|
|
2137
2179
|
|
|
2138
|
-
let line = ` ${sel}${cur} ${name} ${type} ${status} ${l1} ${bar(r1, bw, t1)}`;
|
|
2180
|
+
let line = ` ${sel}${cur} ${name} ${type} ${status} ${resetsCell} ${l1} ${bar(r1, bw, t1)}`;
|
|
2139
2181
|
if (showBoth) {
|
|
2140
2182
|
// "no weekly cap on this plan" is a DIFFERENT state from "not read yet", and
|
|
2141
2183
|
// both render as an empty bar. Say which, so a healthy uncapped account
|
|
@@ -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
|
+
}
|