tokenmaxxing 1.18.0 → 1.19.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.
@@ -0,0 +1,234 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { z } from "zod";
4
+ import { MAX_WRAP_DEPTH, WRAP_DEPTH_ENV, resolveRealClaude } from "./claudebin.ts";
5
+ import { withClaudeRefreshLock } from "./claudelock.ts";
6
+ import { liveTarget, mergeIntoLive, readItem, writeItem } from "./credstore.ts";
7
+ import { http, safeErrorDetail } from "./http.ts";
8
+ import { log } from "./log.ts";
9
+ import { InvalidGrantError, fetchTokenIdentity, isAccessTokenExpiring, isDeadCredential, refreshCredential } from "./oauth.ts";
10
+ import { clearDepletedWait, clearNextCheck, loadAccounts, loadUsage, saveAccounts, saveLastResetAt, writeUsage } from "./state.ts";
11
+ import { keepRotatedPair } from "./swap.ts";
12
+ import { normalizeResetsAt } from "./usage.ts";
13
+ import { CredentialBlobSchema, type Account, type BankedResetOutcome, type BankedResetRecord, type OAuthCreds } from "./types.ts";
14
+
15
+ const EnvOverrideSchema = z.string().min(1).optional().catch(undefined);
16
+ const API_BASE_URL = EnvOverrideSchema.parse(process.env.TOKENMAXXING_OAUTH_API_BASE_URL) ?? "https://api.anthropic.com";
17
+ const OAUTH_BETA = "oauth-2025-04-20";
18
+ const RESET_PROGRAM = "juniper_tide";
19
+ const CLAIM_TIMEOUT_MS = 25_000;
20
+ const VERSION_PROBE_TIMEOUT_MS = 5_000;
21
+ const REFRESH_SKEW_MS = 300_000;
22
+ const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
23
+ const INELIGIBLE_TTL_MS = 24 * 60 * 60 * 1000;
24
+
25
+ const ClaimResponseSchema = z.looseObject({
26
+ result: z.enum(["reset", "already_used", "not_limited", "ineligible", "unavailable"]).catch("unavailable"),
27
+ next_available_at: z.union([z.string(), z.number()]).nullable().optional().catch(null),
28
+ });
29
+
30
+ const ClaimSchema = z.object({ outcome: z.enum(["reset", "pass"]) });
31
+ export type Claim = z.infer<typeof ClaimSchema>["outcome"];
32
+
33
+ export function bankedResetBelievedAvailable(account: Account, now: number): boolean {
34
+ const rec = account.bankedReset;
35
+ if (!rec) return true;
36
+ if (rec.nextAvailableAt != null) return rec.nextAvailableAt <= now;
37
+ if (rec.outcome === "reset" || rec.outcome === "already_used") return now - rec.at >= WEEK_MS;
38
+ if (rec.outcome === "ineligible") return now - rec.at >= INELIGIBLE_TTL_MS;
39
+ return true;
40
+ }
41
+
42
+ const VersionSchema = z.string().regex(/^\d+\.\d+\.\d+\S*$/);
43
+ const PackageJsonSchema = z.looseObject({ version: VersionSchema });
44
+
45
+ function claudeVersion(): string | undefined {
46
+ const bin = resolveRealClaude();
47
+ const fromPath = VersionSchema.safeParse(bin.match(/\/versions\/(\d+\.\d+\.\d+[^/]*)$/)?.[1]);
48
+ if (fromPath.success) return fromPath.data;
49
+ try {
50
+ const pkg = PackageJsonSchema.safeParse(JSON.parse(readFileSync(join(dirname(bin), "package.json"), "utf8")));
51
+ if (pkg.success) return pkg.data.version;
52
+ } catch {
53
+ }
54
+ try {
55
+ const r = Bun.spawnSync([bin, "--version"], {
56
+ env: { ...process.env, TOKENMAXXING_PROBE: "1", [WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH) },
57
+ stdout: "pipe",
58
+ stderr: "pipe",
59
+ timeout: VERSION_PROBE_TIMEOUT_MS,
60
+ });
61
+ const spawned = VersionSchema.safeParse(r.stdout.toString("utf8").trim().match(/^(\d+\.\d+\.\d+\S*)/)?.[1]);
62
+ if (spawned.success) return spawned.data;
63
+ } catch {
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ function claudeUserAgent(): string {
69
+ const version = claudeVersion();
70
+ return version ? `claude-cli/${version} (external, cli)` : "claude-cli (external, cli)";
71
+ }
72
+
73
+ const LiveTokenSchema = z.discriminatedUnion("ok", [
74
+ z.object({ ok: z.literal(true), accessToken: z.string(), organizationUuid: z.string() }),
75
+ z.object({ ok: z.literal(false), outcome: z.enum(["auth_error", "error"]), detail: z.string() }),
76
+ ]);
77
+ type LiveToken = z.infer<typeof LiveTokenSchema>;
78
+
79
+ function liveAccessToken(raw: string | null): string | null {
80
+ if (raw == null) return null;
81
+ const parsed = CredentialBlobSchema.safeParse((() => {
82
+ try { return JSON.parse(raw); } catch { return null; }
83
+ })());
84
+ return parsed.success ? parsed.data.claudeAiOauth.accessToken : null;
85
+ }
86
+
87
+ async function liveTokenLocked(account: Account, lock: { compromised: () => boolean }): Promise<LiveToken> {
88
+ const raw = await readItem(liveTarget());
89
+ if (!raw) return { ok: false, outcome: "auth_error", detail: "no live credential" };
90
+ let creds: OAuthCreds;
91
+ try {
92
+ creds = CredentialBlobSchema.parse(JSON.parse(raw)).claudeAiOauth;
93
+ } catch (e) {
94
+ return { ok: false, outcome: "error", detail: `live credential blob unreadable (${(e instanceof Error ? e.message : String(e)).slice(0, 80)})` };
95
+ }
96
+ if (isDeadCredential(creds)) return { ok: false, outcome: "auth_error", detail: "live credential was cleared after a failed refresh" };
97
+ if (isAccessTokenExpiring(creds, REFRESH_SKEW_MS)) {
98
+ let next: OAuthCreds;
99
+ try {
100
+ next = await refreshCredential(creds);
101
+ } catch (e) {
102
+ const detail = e instanceof Error ? e.message : String(e);
103
+ return { ok: false, outcome: e instanceof InvalidGrantError ? "auth_error" : "error", detail };
104
+ }
105
+ if (lock.compromised()) {
106
+ let owner: Account | null = null;
107
+ try {
108
+ const id = await fetchTokenIdentity(next.accessToken);
109
+ owner = loadAccounts().accounts.find((a) => a.accountUuid === id.accountUuid) ?? null;
110
+ } catch {
111
+ }
112
+ const kept = await keepRotatedPair({ fresh: next, owner, fallback: account, liveOwnerUuid: account.accountUuid, expectedLiveToken: creds.accessToken, lock });
113
+ return { ok: false, outcome: "error", detail: `refresh lock compromised mid-refresh - the rotated token ${kept}` };
114
+ }
115
+ await writeItem(liveTarget(), mergeIntoLive(raw, next));
116
+ creds = next;
117
+ }
118
+ let identity;
119
+ try {
120
+ identity = await fetchTokenIdentity(creds.accessToken);
121
+ } catch (e) {
122
+ return { ok: false, outcome: "error", detail: e instanceof Error ? e.message : String(e) };
123
+ }
124
+ if (identity.accountUuid !== account.accountUuid) {
125
+ return { ok: false, outcome: "error", detail: `live credential belongs to account ${identity.accountUuid.slice(0, 8)}, not the seat` };
126
+ }
127
+ return { ok: true, accessToken: creds.accessToken, organizationUuid: identity.organizationUuid };
128
+ }
129
+
130
+ const PostResultSchema = z.object({
131
+ outcome: z.enum(["reset", "already_used", "not_limited", "ineligible", "unavailable", "rate_limited", "auth_error", "error"]),
132
+ nextAvailableAt: z.number().nullable(),
133
+ detail: z.string(),
134
+ ambiguous: z.boolean().optional(),
135
+ });
136
+ type PostResult = z.infer<typeof PostResultSchema>;
137
+
138
+ async function postClaim(input: { accessToken: string; organizationUuid: string; userAgent: string }): Promise<PostResult> {
139
+ const url = `${API_BASE_URL}/api/organizations/${input.organizationUuid}/reset_rate_limits`;
140
+ let res: Response;
141
+ try {
142
+ res = await http.post(url, {
143
+ headers: {
144
+ Authorization: `Bearer ${input.accessToken}`,
145
+ "anthropic-beta": OAUTH_BETA,
146
+ "Content-Type": "application/json",
147
+ "User-Agent": input.userAgent,
148
+ },
149
+ body: JSON.stringify({ program: RESET_PROGRAM }),
150
+ timeout: CLAIM_TIMEOUT_MS,
151
+ });
152
+ } catch (e) {
153
+ return { outcome: "error", nextAvailableAt: null, detail: `reset endpoint unreachable: ${e instanceof Error ? e.message : String(e)}` };
154
+ }
155
+ let text: string;
156
+ try {
157
+ text = await res.text();
158
+ } catch (e) {
159
+ return { outcome: "error", nextAvailableAt: null, detail: `reset endpoint answered HTTP ${res.status} but the body could not be read: ${e instanceof Error ? e.message : String(e)}`, ambiguous: true };
160
+ }
161
+ if (!res.ok) {
162
+ const detail = `HTTP ${res.status}: ${safeErrorDetail({ text })}`;
163
+ if (res.status === 429) return { outcome: "rate_limited", nextAvailableAt: null, detail };
164
+ if (res.status === 401 || res.status === 403) return { outcome: "auth_error", nextAvailableAt: null, detail };
165
+ return { outcome: "error", nextAvailableAt: null, detail };
166
+ }
167
+ const parsed = ClaimResponseSchema.safeParse((() => {
168
+ try { return JSON.parse(text); } catch { return null; }
169
+ })());
170
+ if (!parsed.success) {
171
+ return { outcome: "error", nextAvailableAt: null, detail: `reset endpoint answered HTTP ${res.status} with an unrecognized body (${text.length} bytes, withheld)`, ambiguous: true };
172
+ }
173
+ return { outcome: parsed.data.result, nextAvailableAt: normalizeResetsAt(parsed.data.next_available_at), detail: parsed.data.result };
174
+ }
175
+
176
+ function record(accountUuid: string, rec: BankedResetRecord, reset: boolean, now: number): void {
177
+ const idx = loadAccounts();
178
+ const account = idx.accounts.find((a) => a.accountUuid === accountUuid);
179
+ if (!account) return;
180
+ account.bankedReset = rec;
181
+ if (reset) {
182
+ delete account.enforcedUntil;
183
+ if (account.lastUsage) {
184
+ account.lastUsage = { ...account.lastUsage, fiveHour: { usedPercentage: 0, resetsAt: null } };
185
+ }
186
+ }
187
+ saveAccounts(idx);
188
+ if (!reset) return;
189
+ const u = loadUsage();
190
+ if (u && u.account === accountUuid) {
191
+ writeUsage({ ...u, fiveHour: { usedPercentage: 0, resetsAt: null }, ts: now }, { stamp: true });
192
+ }
193
+ saveLastResetAt(now);
194
+ clearDepletedWait();
195
+ clearNextCheck();
196
+ }
197
+
198
+ export async function claimBankedReset(input: { account: Account }): Promise<Claim> {
199
+ const { account } = input;
200
+ const short = account.accountUuid.slice(0, 8);
201
+ let result: PostResult;
202
+ let moved = false;
203
+ try {
204
+ const userAgent = claudeUserAgent();
205
+ ({ result, moved } = await withClaudeRefreshLock(async (lock) => {
206
+ const token = await liveTokenLocked(account, lock);
207
+ if (!token.ok) return { result: { outcome: token.outcome, nextAvailableAt: null, detail: token.detail }, moved: false };
208
+ let posted = await postClaim({ accessToken: token.accessToken, organizationUuid: token.organizationUuid, userAgent });
209
+ if (posted.ambiguous) {
210
+ const again = await postClaim({ accessToken: token.accessToken, organizationUuid: token.organizationUuid, userAgent });
211
+ posted =
212
+ again.outcome === "already_used"
213
+ ? { outcome: "reset", nextAvailableAt: again.nextAvailableAt, detail: "already_used on the confirming claim, so the ambiguous claim was the reset", ambiguous: true }
214
+ : again;
215
+ }
216
+ return { result: posted, moved: liveAccessToken(await readItem(liveTarget())) !== token.accessToken };
217
+ }));
218
+ } catch (e) {
219
+ result = { outcome: "error", nextAvailableAt: null, detail: `claim aborted: ${e instanceof Error ? e.message : String(e)}` };
220
+ }
221
+ const outcome: BankedResetOutcome = result.outcome;
222
+ const reset = outcome === "reset";
223
+ const completedAt = Date.now();
224
+ record(account.accountUuid, { outcome, at: completedAt, nextAvailableAt: result.nextAvailableAt }, reset, completedAt);
225
+ log(reset ? "bankedreset.claimed" : "bankedreset.refused", {
226
+ account: short,
227
+ outcome,
228
+ nextAvailableAt: result.nextAvailableAt,
229
+ moved,
230
+ ambiguous: result.ambiguous === true ? true : undefined,
231
+ detail: reset && result.ambiguous !== true ? undefined : result.detail.slice(0, 200),
232
+ });
233
+ return reset && !moved ? "reset" : "pass";
234
+ }
@@ -11,6 +11,7 @@ import { performCodexSwap } from "./codexswap.ts";
11
11
  import { CodexInvalidGrantError, refreshCodexAuth } from "./codexoauth.ts";
12
12
  import { fetchCodexUsage } from "./codexusage.ts";
13
13
  import { codexIdentityOf, isCodexAccessExpiring, readLiveCodexAuth, writeLiveCodexAuth, writeParkedCodexAuth } from "./codexauth.ts";
14
+ import { codexBankedResetVerdict, consumeLiveCodexBankedReset } from "./codexreset.ts";
14
15
  import { liveCodexAccountId } from "./codexsample.ts";
15
16
  import { livingCodexPresences, presentCodexAccountIds, targetableCodexAccounts } from "./codexpresence.ts";
16
17
  import { terminalBars } from "./picker.ts";
@@ -21,6 +22,7 @@ const CodexSwapDecisionSchema = z.object({
21
22
  swapped: z.boolean(),
22
23
  account: CodexAccountSchema.nullable(),
23
24
  reason: z.string(),
25
+ reset: z.boolean().optional(),
24
26
  });
25
27
  export type CodexSwapDecision = z.infer<typeof CodexSwapDecisionSchema>;
26
28
 
@@ -51,7 +53,7 @@ async function sampleLiveOntoOwner(input: { now: number }): Promise<string | nul
51
53
  writeParkedCodexAuth({ credFile: owner.credFile, auth: live });
52
54
  }
53
55
  const usage = await fetchCodexUsage({ auth: live });
54
- owner.lastUsage = { aggregate: usage.aggregate, perLimit: usage.perLimit };
56
+ owner.lastUsage = { aggregate: usage.aggregate, perLimit: usage.perLimit, resetCredits: usage.resetCredits, reachedType: usage.reachedType };
55
57
  owner.lastUsageAt = now;
56
58
  if (usage.email != null) owner.email = usage.email;
57
59
  if (usage.planType != null) owner.planType = usage.planType;
@@ -137,6 +139,28 @@ export async function evaluateAndMaybeSwapCodex(input: { now?: number }): Promis
137
139
  isCodexExhausted({ account: active, thresholds: bars, now });
138
140
  if (!engaged) return { swapped: false, account: null, reason: "under-threshold-or-stale" };
139
141
 
142
+ if (cfg.policy.preferToUseBankedReset.includes("codex") && active.needsReauth !== true) {
143
+ const seat = active;
144
+ const verdict = codexBankedResetVerdict({ account: seat, thresholds: bars, now, pollTtlMs: cfg.policy.usagePollTtlMs });
145
+ if (verdict === "hold") {
146
+ log("codexdecide.banked_reset_hold", { account: seat.accountId.slice(0, 8) });
147
+ return { swapped: false, account: seat, reason: "banked-reset-hold" };
148
+ }
149
+ if (verdict === "claim") {
150
+ const claim = await consumeLiveCodexBankedReset({ account: seat, now });
151
+ if (claim === "reset") {
152
+ try {
153
+ await sampleLiveOntoOwner({ now: Date.now() });
154
+ } catch (e) {
155
+ log("codexdecide.resample_failed", { err: e instanceof Error ? e.message : String(e) });
156
+ }
157
+ const fresh = loadCodexAccounts().accounts.find((account) => account.accountId === activeId) ?? seat;
158
+ if (fresh.lastUsage) postSwapResweep({ liveAccountId: activeId, bars, now });
159
+ return { swapped: false, account: fresh, reason: "banked-reset", reset: true };
160
+ }
161
+ }
162
+ }
163
+
140
164
  if (!isCodexExhausted({ account: active, thresholds: bars, now })) {
141
165
  while (true) {
142
166
  const current = loadCodexAccounts();
@@ -3,20 +3,20 @@ import { nextWeeklyReset } from "./picker.ts";
3
3
  import { isSessionWindow, weeklyWindowOf } from "./codexusage.ts";
4
4
  import type { CodexAccount, CodexWindow, Thresholds } from "./types.ts";
5
5
 
6
- function liveUsed(input: { window: CodexWindow; now: number; sampledAt: number | null }): number {
6
+ export function liveUsed(input: { window: CodexWindow; now: number; sampledAt: number | null }): number {
7
7
  const { window, now, sampledAt } = input;
8
8
  if (window.resetsAt != null) return window.resetsAt <= now ? 0 : window.usedPercentage;
9
9
  if (sampledAt != null && window.windowSeconds != null && now >= sampledAt + window.windowSeconds * 1000) return 0;
10
10
  return window.usedPercentage;
11
11
  }
12
12
 
13
- function allWindows(account: CodexAccount): CodexWindow[] {
13
+ export function allWindows(account: CodexAccount): CodexWindow[] {
14
14
  const usage = account.lastUsage;
15
15
  if (!usage) return [];
16
16
  return [...usage.aggregate, ...Object.values(usage.perLimit).flat()];
17
17
  }
18
18
 
19
- function barFor(input: { window: CodexWindow; thresholds: Thresholds }): number {
19
+ export function barFor(input: { window: CodexWindow; thresholds: Thresholds }): number {
20
20
  return isSessionWindow({ window: input.window }) ? input.thresholds.session : input.thresholds.weekly;
21
21
  }
22
22
 
@@ -0,0 +1,122 @@
1
+ import { z } from "zod";
2
+ import { codexIdentityOf, readLiveCodexAuth } from "./codexauth.ts";
3
+ import { allWindows, barFor, liveUsed } from "./codexpick.ts";
4
+ import { loadCodexAccounts, saveCodexAccounts } from "./codexstate.ts";
5
+ import { http, safeErrorDetail } from "./http.ts";
6
+ import { log } from "./log.ts";
7
+ import type { CodexAccount, CodexAuthJson, CodexBankedResetOutcome, Thresholds } from "./types.ts";
8
+
9
+ const EnvOverrideSchema = z.string().min(1).optional().catch(undefined);
10
+ const CONSUME_URL =
11
+ EnvOverrideSchema.parse(process.env.TOKENMAXXING_CODEX_RESET_URL) ?? "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume";
12
+ const CONSUME_TIMEOUT_MS = 30_000;
13
+ const RETRY_KEY_TTL_MS = 10 * 60_000;
14
+ const WALL_PERCENT = 100;
15
+ const LIMIT_REACHED = "rate_limit_reached";
16
+
17
+ const VerdictSchema = z.enum(["hold", "claim", "pass"]);
18
+ export type CodexBankedVerdict = z.infer<typeof VerdictSchema>;
19
+ const ClaimSchema = z.enum(["reset", "pass"]);
20
+ export type CodexBankedClaim = z.infer<typeof ClaimSchema>;
21
+
22
+ export function codexBankedResetVerdict(input: { account: CodexAccount; thresholds: Thresholds; now: number; pollTtlMs: number }): CodexBankedVerdict {
23
+ const { account, thresholds, now, pollTtlMs } = input;
24
+ const usage = account.lastUsage;
25
+ if (!usage) return "pass";
26
+ const credits = usage.resetCredits ?? null;
27
+ if (credits == null || credits <= 0) return "pass";
28
+ const rec = account.bankedReset;
29
+ if (rec != null && rec.outcome !== "reset" && now - rec.at < pollTtlMs) return "pass";
30
+ const sampledAt = account.lastUsageAt ?? null;
31
+ const windows = allWindows(account);
32
+ const over = windows.some((window) => liveUsed({ window, now, sampledAt }) >= barFor({ window, thresholds }));
33
+ if (!over) return "pass";
34
+ const atWall = usage.reachedType === LIMIT_REACHED || windows.some((window) => liveUsed({ window, now, sampledAt }) >= WALL_PERCENT);
35
+ return atWall ? "claim" : "hold";
36
+ }
37
+
38
+ const ConsumeResponseSchema = z.looseObject({
39
+ code: z.enum(["reset", "nothing_to_reset", "no_credit", "already_redeemed"]),
40
+ windows_reset: z.number().int().nullish(),
41
+ });
42
+
43
+ const ConsumeResultSchema = z.object({ outcome: z.enum(["reset", "nothing_to_reset", "no_credit", "already_redeemed", "auth_error", "error"]), detail: z.string() });
44
+ type ConsumeResult = z.infer<typeof ConsumeResultSchema>;
45
+
46
+ export async function consumeCodexBankedReset(input: { auth: CodexAuthJson; idempotencyKey: string }): Promise<ConsumeResult> {
47
+ const { auth, idempotencyKey } = input;
48
+ const identity = codexIdentityOf({ auth });
49
+ let res: Response;
50
+ let text: string;
51
+ try {
52
+ res = await http.post(CONSUME_URL, {
53
+ headers: {
54
+ Authorization: `Bearer ${auth.tokens.access_token}`,
55
+ "ChatGPT-Account-Id": identity.accountId,
56
+ "User-Agent": "codex-cli",
57
+ "Content-Type": "application/json",
58
+ },
59
+ body: JSON.stringify({ redeem_request_id: idempotencyKey }),
60
+ timeout: CONSUME_TIMEOUT_MS,
61
+ });
62
+ text = await res.text();
63
+ } catch (e) {
64
+ return { outcome: "error", detail: `reset endpoint unreachable: ${e instanceof Error ? e.message : String(e)}` };
65
+ }
66
+ if (!res.ok) {
67
+ const detail = `HTTP ${res.status}: ${safeErrorDetail({ text })}`;
68
+ if (res.status === 401 || res.status === 403) return { outcome: "auth_error", detail };
69
+ return { outcome: "error", detail };
70
+ }
71
+ const parsed = ConsumeResponseSchema.safeParse((() => {
72
+ try {
73
+ return JSON.parse(text);
74
+ } catch {
75
+ return null;
76
+ }
77
+ })());
78
+ if (!parsed.success) return { outcome: "error", detail: `reset endpoint returned an unrecognized body (${text.length} bytes, withheld)` };
79
+ return { outcome: parsed.data.code, detail: `${parsed.data.code} (windows reset: ${parsed.data.windows_reset ?? "unknown"})` };
80
+ }
81
+
82
+ function rememberPendingKey(accountId: string, key: string, now: number): void {
83
+ const index = loadCodexAccounts();
84
+ const entry = index.accounts.find((a) => a.accountId === accountId);
85
+ if (!entry) return;
86
+ entry.bankedReset = { outcome: "error", at: now, key };
87
+ saveCodexAccounts({ index });
88
+ }
89
+
90
+ export async function consumeLiveCodexBankedReset(input: { account: CodexAccount; now: number }): Promise<CodexBankedClaim> {
91
+ const { account, now } = input;
92
+ const short = account.accountId.slice(0, 8);
93
+ const live = readLiveCodexAuth();
94
+ let result: ConsumeResult;
95
+ let idempotencyKey: string | null = null;
96
+ if (!live) {
97
+ result = { outcome: "auth_error", detail: "live auth.json vanished" };
98
+ } else if (codexIdentityOf({ auth: live }).accountId !== account.accountId) {
99
+ result = { outcome: "error", detail: "live codex credential belongs to another account" };
100
+ } else {
101
+ const prior = account.bankedReset;
102
+ const retrying = prior != null && prior.outcome === "error" && prior.key != null && now - prior.at < RETRY_KEY_TTL_MS;
103
+ idempotencyKey = retrying ? prior.key! : crypto.randomUUID();
104
+ if (!retrying) rememberPendingKey(account.accountId, idempotencyKey, now);
105
+ result = await consumeCodexBankedReset({ auth: live, idempotencyKey });
106
+ if (result.outcome === "already_redeemed" && retrying) result = { outcome: "reset", detail: `${result.detail}; the retried key had already completed` };
107
+ }
108
+ const outcome: CodexBankedResetOutcome = result.outcome;
109
+ const reset = outcome === "reset";
110
+ const index = loadCodexAccounts();
111
+ const entry = index.accounts.find((a) => a.accountId === account.accountId);
112
+ if (entry) {
113
+ entry.bankedReset = { outcome, at: now, ...(outcome === "error" && idempotencyKey != null ? { key: idempotencyKey } : {}) };
114
+ if (reset) {
115
+ delete entry.lastUsage;
116
+ delete entry.lastUsageAt;
117
+ }
118
+ saveCodexAccounts({ index });
119
+ }
120
+ log(reset ? "codexreset.consumed" : "codexreset.refused", { account: short, outcome, detail: result.detail.slice(0, 200) });
121
+ return reset ? "reset" : "pass";
122
+ }
@@ -25,6 +25,8 @@ const WireRateLimitSchema = z.looseObject({
25
25
  secondary_window: WireWindowSchema.nullish(),
26
26
  });
27
27
 
28
+ const WireReachedTypeSchema = z.union([z.string(), z.looseObject({ type: z.string().nullish(), kind: z.string().nullish() })]).nullish().catch(null);
29
+
28
30
  const WireUsageSchema = z.looseObject({
29
31
  account_id: z.string(),
30
32
  email: z.string().nullish(),
@@ -33,6 +35,8 @@ const WireUsageSchema = z.looseObject({
33
35
  additional_rate_limits: z
34
36
  .array(z.looseObject({ limit_name: z.string(), rate_limit: WireRateLimitSchema.nullish() }))
35
37
  .nullish(),
38
+ rate_limit_reset_credits: z.looseObject({ available_count: z.number().int().nullish() }).nullish(),
39
+ rate_limit_reached_type: WireReachedTypeSchema,
36
40
  });
37
41
 
38
42
  function toWindows(rateLimit: z.infer<typeof WireRateLimitSchema> | null | undefined): CodexWindow[] {
@@ -92,6 +96,11 @@ export async function fetchCodexUsage(input: { auth: CodexAuthJson }): Promise<C
92
96
  planType: wire.plan_type ?? null,
93
97
  aggregate: toWindows(wire.rate_limit),
94
98
  perLimit,
99
+ resetCredits: wire.rate_limit_reset_credits?.available_count ?? null,
100
+ reachedType:
101
+ typeof wire.rate_limit_reached_type === "string"
102
+ ? wire.rate_limit_reached_type
103
+ : (wire.rate_limit_reached_type?.type ?? wire.rate_limit_reached_type?.kind ?? null),
95
104
  });
96
105
  }
97
106
 
package/src/lib/decide.ts CHANGED
@@ -2,8 +2,9 @@ import { maxBy } from "es-toolkit";
2
2
  import { z } from "zod";
3
3
  import { withLock } from "./lock.ts";
4
4
  import { paths } from "./paths.ts";
5
- import { MAX_CHECK_DELAY_TICKS, POST_SWAP_COOLDOWN_MS, maxCheckDelayMs, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadUsageSnapshot, loadModelUsage, saveAccounts, saveDepletedWait, saveModelUsage, writeUsage } from "./state.ts";
5
+ import { MAX_CHECK_DELAY_TICKS, POST_SWAP_COOLDOWN_MS, maxCheckDelayMs, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadUsageSnapshot, loadModelUsage, measureFamilyWindow, saveAccounts, saveDepletedWait, saveModelUsage, writeUsage } from "./state.ts";
6
6
  import { readOAuthAccount } from "./claudejson.ts";
7
+ import { bankedResetBelievedAvailable, claimBankedReset } from "./bankedreset.ts";
7
8
  import { chooseAndSwap, isSkippableSwapError, performSwap } from "./swap.ts";
8
9
  import { currentWins, effectiveBars, hardBars, isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, sessionLadder, usableAt } from "./picker.ts";
9
10
  import { familyTokens, gatedFamilies, probeUsage, type EnforcedClass } from "./usage.ts";
@@ -15,6 +16,7 @@ const SwapDecisionSchema = z.object({
15
16
  account: AccountSchema.nullable(),
16
17
  reason: z.string(),
17
18
  waitUntil: z.number().optional(),
19
+ reset: z.boolean().optional(),
18
20
  });
19
21
  export type SwapDecision = z.infer<typeof SwapDecisionSchema>;
20
22
 
@@ -35,6 +37,17 @@ function capForFamily(mu: ModelUsageState, family: string, now: number): UsageWi
35
37
  return maxBy(rows, (w) => liveUsed({ window: w, windowMs: WEEK_MS, sampledAt: mu.sampledAt ?? mu.ts, now }));
36
38
  }
37
39
 
40
+ function familyCaps(perModel: Record<string, UsageWindow>, families: string[]): Record<string, number> {
41
+ return Object.fromEntries(
42
+ families.flatMap((family) => {
43
+ const rows = Object.entries(perModel)
44
+ .filter(([k]) => familyTokens(k).includes(family))
45
+ .map(([, w]) => w.usedPercentage);
46
+ return rows.length > 0 ? [[family, Math.max(...rows)] as const] : [];
47
+ }),
48
+ );
49
+ }
50
+
38
51
  function overlayLive(accounts: Account[], u: UsageState | null, mu: ModelUsageState | null, account: string | null): Account[] {
39
52
  return accounts.map((a) => {
40
53
  if (account == null || a.accountUuid !== account) return a;
@@ -63,6 +76,41 @@ function needsPerModel(u: UsageState | null, cfg: Config): boolean {
63
76
  return u != null && gatedFamilies(u.model, cfg.policy.switchModels).length > 0;
64
77
  }
65
78
 
79
+ const BankedVerdictSchema = z.enum(["hold", "claim", "pass"]);
80
+ type BankedVerdict = z.infer<typeof BankedVerdictSchema>;
81
+
82
+ function bankedResetVerdict(input: {
83
+ seat: Account;
84
+ u: UsageState | null;
85
+ mu: ModelUsageState | null;
86
+ enforced: EnforcedLimit | null;
87
+ cfg: Config;
88
+ bars: Thresholds;
89
+ switchFamilies: string[];
90
+ now: number;
91
+ }): BankedVerdict {
92
+ const { seat, u, mu, enforced, cfg, bars, switchFamilies, now } = input;
93
+ if (enforced && enforced.kind !== "session") return "pass";
94
+ if (!u || u.account !== seat.accountUuid) return "pass";
95
+ if (!bankedResetBelievedAvailable(seat, now)) return "pass";
96
+ if (u.sampledAt == null || now - u.sampledAt > cfg.policy.usagePollTtlMs) return "pass";
97
+ const cost = u.sessionWindowWeeklyCost ?? seat.sessionWindowWeeklyCost;
98
+ if (cost == null) return "pass";
99
+ const weeklyUsed = liveUsed({ window: u.sevenDay, windowMs: WEEK_MS, sampledAt: u.ts, now });
100
+ if (weeklyUsed + cost > bars.weekly) return "pass";
101
+ const muSame = mu && mu.account === seat.accountUuid && now - (mu.sampledAt ?? mu.ts) <= cfg.policy.usagePollTtlMs ? mu : null;
102
+ for (const family of switchFamilies) {
103
+ const cap = muSame ? capForFamily(muSame, family, now) : undefined;
104
+ if (!cap || !muSame) return "pass";
105
+ const familyCost = muSame.familyCosts?.[family];
106
+ if (familyCost == null) return "pass";
107
+ const capUsed = liveUsed({ window: cap, windowMs: WEEK_MS, sampledAt: muSame.sampledAt ?? muSame.ts, now });
108
+ if (capUsed + familyCost > bars.weekly) return "pass";
109
+ }
110
+ const atWall = enforced?.kind === "session" || liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }) >= hardBars(cfg).session;
111
+ return atWall ? "claim" : "hold";
112
+ }
113
+
66
114
  function isEngaged(u: UsageState | null, mu: ModelUsageState | null, account: string | null, bars: Thresholds, cfg: Config, now: number): boolean {
67
115
  if (!u || !account || u.account !== account) return false;
68
116
  return liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }) >= cfg.policy.greedySessionFloor || isOver(u, mu, account, bars, cfg, now);
@@ -98,6 +146,7 @@ async function loadFreshSnapshots(cfg: Config, account: string | null, now: numb
98
146
  if (account && !probeAttempted && (!usageFresh(u, uAt, account, ttl, now) || needsPerModel(u, cfg))) {
99
147
  const full = await probeUsage();
100
148
  const ts = Date.now();
149
+ const prevMu = mu;
101
150
  if (readOAuthAccount()?.accountUuid === account) {
102
151
  if (full) {
103
152
  const teed = loadUsageSnapshot();
@@ -109,7 +158,7 @@ async function loadFreshSnapshots(cfg: Config, account: string | null, now: numb
109
158
  writeUsage(u);
110
159
  uAt = ts;
111
160
  }
112
- mu = { perModel: full.perModel, account, ts, sampledAt: ts };
161
+ mu = { perModel: full.perModel, account, ts, sampledAt: ts, ...measureFamilyWindow(prevMu, account, full.session, familyCaps(full.perModel, cfg.policy.switchModels)) };
113
162
  saveModelUsage(mu);
114
163
  const expected = gatedFamilies(u?.model ?? null, cfg.policy.switchModels);
115
164
  const rows = Object.keys(full.perModel);
@@ -117,7 +166,7 @@ async function loadFreshSnapshots(cfg: Config, account: string | null, now: numb
117
166
  log("usage.no_permodel_row", { families: expected.join(","), rows: rows.join(",") });
118
167
  }
119
168
  } else {
120
- mu = { perModel: mu?.account === account ? (mu?.perModel ?? {}) : {}, account, ts, sampledAt: mu?.account === account ? (mu?.sampledAt ?? mu?.ts) : undefined };
169
+ mu = { perModel: mu?.account === account ? (mu?.perModel ?? {}) : {}, account, ts, sampledAt: mu?.account === account ? (mu?.sampledAt ?? mu?.ts) : undefined, ...measureFamilyWindow(prevMu, account, null, {}) };
121
170
  saveModelUsage(mu);
122
171
  }
123
172
  }
@@ -171,7 +220,8 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
171
220
  let sampled = false;
172
221
  if (tee && tee.state.account === account2 && (active.lastUsageAt == null || tee.at >= active.lastUsageAt)) {
173
222
  active.lastUsage = { fiveHour: tee.state.fiveHour, sevenDay: tee.state.sevenDay };
174
- active.lastUsageAt = tee.at;
223
+ active.lastUsageAt = tee.state.sampledAt ?? tee.at;
224
+ if (tee.state.sessionWindowWeeklyCost != null) active.sessionWindowWeeklyCost = tee.state.sessionWindowWeeklyCost;
175
225
  sampled = true;
176
226
  }
177
227
  if (mu2 && mu2.account === account2 && Object.keys(mu2.perModel).length > 0) {
@@ -209,7 +259,7 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
209
259
  const cur = loadAccounts();
210
260
  const active = seatOf(cur);
211
261
  const pool = usable(cur.accounts);
212
- const ctxAll = { now, thresholds: barsOf(pool), currentAccountUuid: null, switchFamilies, holdMargin };
262
+ const ctxAll = { now, thresholds: barsOf(cur.accounts), currentAccountUuid: null, switchFamilies, holdMargin };
213
263
  if (currentWins(active, pool, ctxAll)) {
214
264
  return { swapped: false, account: null, reason: "current-best" };
215
265
  }
@@ -227,12 +277,26 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
227
277
  };
228
278
  if (!enforced2 && !isOver(u2, mu2, account2, bars, cfg, now)) return greedy(cfg.policy.greedySwapMargin);
229
279
 
280
+ const resetPreferred = cfg.policy.preferToUseBankedReset.includes("claude");
281
+ let resetTried = false;
230
282
  while (true) {
231
283
  const cur = loadAccounts();
232
284
  const seat = seatOf(cur);
233
285
  const pool = usable(cur.accounts);
234
- const thresholds = barsOf(pool);
286
+ const thresholds = barsOf(cur.accounts);
235
287
  if (!enforced2 && seat && !seat.needsReauth && !isExhausted(seat, { now, thresholds, currentAccountUuid: null, switchFamilies })) return greedy(0);
288
+ if (resetPreferred && !resetTried && seat && !seat.needsReauth) {
289
+ const verdict = bankedResetVerdict({ seat, u: u2, mu: mu2, enforced: enforced2, cfg, bars: thresholds, switchFamilies, now });
290
+ if (verdict === "hold") {
291
+ log("decide.banked_reset_hold", { account: seat.accountUuid.slice(0, 8), enforced: enforced2 != null });
292
+ return { swapped: false, account: seat, reason: "banked-reset-hold" };
293
+ }
294
+ if (verdict === "claim") {
295
+ resetTried = true;
296
+ const claim = await claimBankedReset({ account: seat });
297
+ if (claim === "reset") return { swapped: false, account: seat, reason: "banked-reset", reset: true };
298
+ }
299
+ }
236
300
  const best = pickBest(pool, { now, thresholds, currentAccountUuid: seat?.accountUuid ?? null, switchFamilies });
237
301
  if (!best) break;
238
302
  try {
@@ -340,11 +404,12 @@ export async function recordEnforcedLimit(input: { limit: EnforcedClass; account
340
404
  account: accountUuid,
341
405
  ts: now,
342
406
  sampledAt: resetsAt == null ? now : sampledAt ?? now,
407
+ ...measureFamilyWindow(muSame, accountUuid, null, {}),
343
408
  });
344
409
  log("usage.enforced_limit", { kind: limit.kind, family: limit.family, resetsAt });
345
410
  return { outcome: "stamped", resetsAt };
346
411
  }
347
- saveModelUsage({ perModel: carriedRows, account: accountUuid, ts: now, sampledAt });
412
+ saveModelUsage({ perModel: carriedRows, account: accountUuid, ts: now, sampledAt, ...measureFamilyWindow(muSame, accountUuid, null, {}) });
348
413
  if (account && limit.resetsAt != null) {
349
414
  account.enforcedUntil = limit.resetsAt;
350
415
  saveAccounts(idx);
@@ -353,12 +418,14 @@ export async function recordEnforcedLimit(input: { limit: EnforcedClass; account
353
418
  if (!carrier) return { outcome: "no-carrier", resetsAt: limit.resetsAt };
354
419
  const window: UsageWindow = { usedPercentage: 100, resetsAt: limit.resetsAt };
355
420
  writeUsage({
421
+ ...(priorSame ?? {}),
422
+ ...(priorSame == null && account?.lastUsageAt != null ? { sampledAt: account.lastUsageAt } : {}),
356
423
  fiveHour: limit.kind === "session" ? window : carrier.fiveHour,
357
424
  sevenDay: limit.kind === "weekly" ? window : carrier.sevenDay,
358
425
  account: accountUuid,
359
426
  ts: now,
360
427
  model: carrier.model,
361
- });
428
+ }, { stamp: true });
362
429
  log("usage.enforced_limit", { kind: limit.kind, resetsAt: limit.resetsAt });
363
430
  return { outcome: "stamped", resetsAt: limit.resetsAt };
364
431
  });