tokenmaxxing 1.18.1 → 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.
- package/DESIGN.md +2 -0
- package/README.md +4 -3
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/switching-policy/SKILL.md +1 -0
- package/agent-plugin/skills/switching-policy/references/policy.md +2 -1
- package/package.json +1 -1
- package/src/cli/check.ts +3 -0
- package/src/cli/config.ts +1 -0
- package/src/cli/status.ts +13 -3
- package/src/entries/codexstophook.ts +1 -0
- package/src/entries/stopfailurehook.ts +9 -6
- package/src/entries/stophook.ts +1 -0
- package/src/entries/supervisor.ts +2 -1
- package/src/lib/bankedreset.ts +234 -0
- package/src/lib/codexdecide.ts +25 -1
- package/src/lib/codexpick.ts +3 -3
- package/src/lib/codexreset.ts +122 -0
- package/src/lib/codexusage.ts +9 -0
- package/src/lib/decide.ts +73 -6
- package/src/lib/paths.ts +1 -0
- package/src/lib/state.ts +93 -3
- package/src/lib/types.ts +54 -0
package/DESIGN.md
CHANGED
|
@@ -85,6 +85,8 @@ The decision engages at `five_hour >= 80%` (policy.greedySessionFloor): from the
|
|
|
85
85
|
|
|
86
86
|
**Model-aware trigger.** Claude subscriptions also enforce **per-model weekly caps** - currently only for Sonnet and Fable (there is no Opus-only quota), and Fable's tighter limit binds *before* the aggregate (e.g. 80% week-Fable at only 50% week-all-models). This cap isn't in statusLine stdin, so when the active model is in `policy.switchModels` we read it from `claude -p '/usage'` (free, 0 tokens, TTL-cached) and add `week(<activeModel>) >= threshold` to the trigger. A Fable session switches on the Fable cap; a Sonnet session rides the aggregate. Both layers apply the per-model gate: a burnt Fable cap screens an account out of a Layer 1 switch, and a Fable cap at the wall screens it out of the Layer 2 squeeze too.
|
|
87
87
|
|
|
88
|
+
**Banked resets (opt-in).** Both CLIs can reset a hit limit on the same account: Claude Code's `/limit-reset` (the `juniper_tide` server program, claimed by `POST /api/organizations/<org>/reset_rate_limits`, once a week, and the reset window still counts toward the weekly limit; the slash command is interactive-only, so tokenmaxxing makes the claim call itself with the live credential) and Codex's banked reset (`POST .../wham/rate-limit-reset-credits/consume` with an idempotency key; it refreshes the 5h and weekly windows together). `policy.preferToUseBankedReset` (a provider list, default `[]`) makes the hard path spend one before swapping, because a swap cold-starts the per-org prompt cache while a reset keeps the seat. Claude holds between the session rung and the wall only while the account still has weekly room for one more session window at its measured rate (`sessionWindowWeeklyCost`, learned from the tee within one window; unmeasured swaps), claims at the wall, and on `reset` respawns the failed turn in place. Codex rides to the wall while the usage endpoint reports a credit, consumes one, re-samples, and never restarts. Detail: `docs/switching.mdx` §Banked resets, `src/lib/bankedreset.ts`, `src/lib/codexreset.ts`.
|
|
89
|
+
|
|
88
90
|
---
|
|
89
91
|
|
|
90
92
|
## 6. Honest papercuts
|
package/README.md
CHANGED
|
@@ -101,12 +101,13 @@ The **target** is chosen greedily off each account's cached windows: among usabl
|
|
|
101
101
|
"greedySessionFloor": 80,
|
|
102
102
|
"switchModels": ["fable"],
|
|
103
103
|
"usagePollTtlMs": 90000,
|
|
104
|
-
"checkIntervalMs": 60000
|
|
104
|
+
"checkIntervalMs": 60000,
|
|
105
|
+
"preferToUseBankedReset": []
|
|
105
106
|
}
|
|
106
107
|
}
|
|
107
108
|
```
|
|
108
109
|
|
|
109
|
-
`thresholds.session` is a ladder: the bar is the lowest rung some pooled account still clears, so the default `[90]` is one bar, while with `[50, 80, 95]` a seat past 50 hands off while a sibling is under 50, and the bar climbs to 80 and then 95 as the whole pool fills; `projectionMargin` is a fixed safety margin subtracted from each threshold bar (effective bar = threshold - margin), so a large turn is less likely to blow past a bar between checks; `greedySessionFloor` is the session-used % at which the greedy convergence engages; `switchModels` names the models whose per-model cap triggers a switch; `usagePollTtlMs` is how long a `/usage` per-model poll stays fresh; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer and which every headroom band is a multiple of, one tick near a bar up to five with plenty of headroom (a depleted wait and the post-swap cooldown sleep to their own deadlines instead) - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick.
|
|
110
|
+
`thresholds.session` is a ladder: the bar is the lowest rung some pooled account still clears, so the default `[90]` is one bar, while with `[50, 80, 95]` a seat past 50 hands off while a sibling is under 50, and the bar climbs to 80 and then 95 as the whole pool fills; `projectionMargin` is a fixed safety margin subtracted from each threshold bar (effective bar = threshold - margin), so a large turn is less likely to blow past a bar between checks; `greedySessionFloor` is the session-used % at which the greedy convergence engages; `switchModels` names the models whose per-model cap triggers a switch; `usagePollTtlMs` is how long a `/usage` per-model poll stays fresh; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer and which every headroom band is a multiple of, one tick near a bar up to five with plenty of headroom (a depleted wait and the post-swap cooldown sleep to their own deadlines instead) - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick; `preferToUseBankedReset` lists the providers (`claude`, `codex`) that spend a banked limit reset (Claude Code's weekly `/limit-reset`, Codex's banked reset) on the same account before swapping, which keeps the prompt cache a swap would cold-start - on Claude the seat holds past the session rung only while the account has weekly room for one more session window at its measured rate, and claims the reset at the wall.
|
|
110
111
|
|
|
111
112
|
State lives entirely in `~/.config/tokenmaxxing/`. Per-account credentials follow the platform's Claude Code store: the login keychain on macOS (`tokenmaxxing-cred-<uuid8>` items, never plaintext on disk), 0600 files under `~/.config/tokenmaxxing/creds/` on Linux (the same plaintext model claude itself uses for `~/.claude/.credentials.json`).
|
|
112
113
|
|
|
@@ -148,7 +149,7 @@ codex # use codex as always
|
|
|
148
149
|
|
|
149
150
|
Codex mechanics differ from Claude Code in one hard way: a running codex process refuses a credential swapped to a different account, so **a restart is the switch**. The installed Stop hook runs the same greedy pace-pressure decision at each turn boundary (usage read free from codex's own rate-limit endpoint: percentages plus absolute reset times, weekly aggregate and per-model caps alike); when it swaps, the supervisor relaunches `codex resume <session-id>` on the fresh account with the transcript intact. `tokenmaxxing switch --codex [sel]` does it manually, `status`/`watch`/`ls` show both pools.
|
|
150
151
|
|
|
151
|
-
Two codex-specific facts worth knowing: codex does not run hooks it has not been told to trust, so after `init --codex` you must open codex once and trust the tokenmaxxing Stop hook via `/hooks` (auto-switching is inert until then); and codex has no cross-process lock on `auth.json`, so tokenmaxxing serializes all of its own credential writes behind its own lock and swaps only at idle turn boundaries.
|
|
152
|
+
Two codex-specific facts worth knowing: codex does not run hooks it has not been told to trust, so after `init --codex` you must open codex once and trust the tokenmaxxing Stop hook via `/hooks` (auto-switching is inert until then); and codex has no cross-process lock on `auth.json`, so tokenmaxxing serializes all of its own credential writes behind its own lock and swaps only at idle turn boundaries. With `codex` in `policy.preferToUseBankedReset`, a seat at its limit consumes a banked reset credit (which refreshes both windows) and keeps running instead of restarting onto another account.
|
|
152
153
|
|
|
153
154
|
## Honest limitations
|
|
154
155
|
|
package/agent-plugin/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "tokenmaxxing",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.19.0",
|
|
5
5
|
"description": "Pool Claude Code and Codex logins, switch on pace pressure, and expose safe ops tools to agent clients.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "anaclumos",
|
|
@@ -12,6 +12,7 @@ description: Explain and apply tokenmaxxing switch policy (greedy vs hard path,
|
|
|
12
12
|
- **HARD path**: a screening bar crossed. Swap to best usable target; if none, Layer 2 wall logic (Claude only).
|
|
13
13
|
- **Pace pressure**: remaining weekly percent / time to weekly reset (highest first). Not most-remaining.
|
|
14
14
|
- **Effective bars**: `effectiveBars(cfg, pool)` = the active rung of the 5h ladder (`thresholds.session`, default `[90]`, a single rung: the lowest rung some pooled account, the current one included, still clears) and the weekly bar, each minus `policy.projectionMargin`. Trigger and screening must share these bars or swaps ping-pong. Codex reads `terminalBars(cfg)`, the top rung only. The check cadence is capped one band per rung climbed, and every band is a multiple of the tick `policy.checkIntervalMs` (default 60000).
|
|
15
|
+
- **Banked reset**: opt-in via `policy.preferToUseBankedReset` (a provider list, default `[]`), hard path only. Claude: the seat holds between the session rung and the wall while a reset is believed available and the weekly windows have room for one more measured session window (`sessionWindowWeeklyCost`; unmeasured swaps), claims the `/limit-reset` server call at the wall, and keeps the seat on `reset` (the failed turn is retriggered in place). Codex: rides to the wall while a reset credit exists, consumes one, no restart. The greedy path never resets.
|
|
15
16
|
|
|
16
17
|
## Layer 2 (Claude only)
|
|
17
18
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
- `docs/content/docs/switching.mdx`
|
|
4
4
|
- `src/lib/decide.ts`, `src/lib/picker.ts`
|
|
5
|
+
- `src/lib/bankedreset.ts`, `src/lib/codexreset.ts` (banked limit resets)
|
|
5
6
|
- `.memory/switch-policy-pace-pressure.md`
|
|
6
7
|
|
|
7
|
-
Default screening bars: session ladder [90] (a single rung; add lower rungs such as [50, 80, 95] to hand off earlier) with the active rung resolved per pool, weekly 98. Engagement floor 80. Wall defaults 100. Cooldown 45s on the automatic path after a swap. Check tick `policy.checkIntervalMs` (default 60000, minimum 10000); `init` writes it into the timer unit. Check cadence ceiling per rung: five ticks, three, two, then the one-tick floor, with the headroom bands at the same multiples (40 points sleeps five ticks, 20 three, 8 two, less every tick).
|
|
8
|
+
Default screening bars: session ladder [90] (a single rung; add lower rungs such as [50, 80, 95] to hand off earlier) with the active rung resolved per pool, weekly 98. Engagement floor 80. Wall defaults 100. Cooldown 45s on the automatic path after a swap. Check tick `policy.checkIntervalMs` (default 60000, minimum 10000); `init` writes it into the timer unit. Check cadence ceiling per rung: five ticks, three, two, then the one-tick floor, with the headroom bands at the same multiples (40 points sleeps five ticks, 20 three, 8 two, less every tick). Banked resets off by default (`policy.preferToUseBankedReset: []`); Claude claims once a week per account at the wall, Codex consumes one credit at the wall.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.19.0",
|
|
4
4
|
"description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cli/check.ts
CHANGED
|
@@ -30,6 +30,7 @@ export async function cmdCheck(args: string[] = [], json = false): Promise<numbe
|
|
|
30
30
|
ok: true,
|
|
31
31
|
due: true,
|
|
32
32
|
swapped: d.swapped,
|
|
33
|
+
reset: d.reset === true,
|
|
33
34
|
account: d.account?.label ?? null,
|
|
34
35
|
reason: d.reason,
|
|
35
36
|
waitUntil: d.waitUntil ?? null,
|
|
@@ -40,6 +41,8 @@ export async function cmdCheck(args: string[] = [], json = false): Promise<numbe
|
|
|
40
41
|
const next = c.dim(`next in ${Math.round(delayMs / 1000)}s`);
|
|
41
42
|
if (d.swapped && d.account) {
|
|
42
43
|
console.log(`${c.green("↻")} switched to ${c.bold(d.account.label)} ${next}`);
|
|
44
|
+
} else if (d.reset && d.account) {
|
|
45
|
+
console.log(`${c.green("↻")} reset the session limit on ${c.bold(d.account.label)} ${next}`);
|
|
43
46
|
} else if (d.waitUntil !== undefined && d.account) {
|
|
44
47
|
console.log(c.yellow(`all accounts at limit - staying on ${c.bold(d.account.label)} (${fmtReset(d.waitUntil)})`) + ` ${next}`);
|
|
45
48
|
} else {
|
package/src/cli/config.ts
CHANGED
|
@@ -21,6 +21,7 @@ export const KNOWN_KEYS = [
|
|
|
21
21
|
"policy.usagePollTtlMs",
|
|
22
22
|
"policy.maxWaitMs",
|
|
23
23
|
"policy.checkIntervalMs",
|
|
24
|
+
"policy.preferToUseBankedReset",
|
|
24
25
|
] as const;
|
|
25
26
|
|
|
26
27
|
const TIMER_REWRITE_NOTE = "note: run `tokenmaxxing init` to rewrite the periodic timer at the new tick";
|
package/src/cli/status.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { isCodexExhausted } from "../lib/codexpick.ts";
|
|
|
12
12
|
import { codexLimitLabel, isSessionWindow } from "../lib/codexusage.ts";
|
|
13
13
|
import { bar, c, claudeTierLabel, count, emitJson, fmtAgo } from "./render.ts";
|
|
14
14
|
import { gatedFamilies, type FullUsage } from "../lib/usage.ts";
|
|
15
|
-
import { ThresholdsSchema, UsageWindowSchema, type Account, type CodexWindow, type Config, type UsageWindow } from "../lib/types.ts";
|
|
15
|
+
import { BankedResetRecordSchema, ThresholdsSchema, UsageWindowSchema, type Account, type CodexWindow, type Config, type UsageWindow } from "../lib/types.ts";
|
|
16
16
|
|
|
17
17
|
const SampleReportSchema = z.discriminatedUnion("ok", [
|
|
18
18
|
z.object({ ok: z.literal(true), source: z.enum(["statusline", "probe"]) }),
|
|
@@ -35,6 +35,9 @@ const ClaudeStatusAccountSchema = z.object({
|
|
|
35
35
|
pingError: z.string().nullable(),
|
|
36
36
|
pingRejected: z.boolean(),
|
|
37
37
|
pinged: z.boolean(),
|
|
38
|
+
bankedReset: BankedResetRecordSchema.nullable(),
|
|
39
|
+
sessionWindowWeeklyCost: z.number().nullable(),
|
|
40
|
+
sessionWindowFamilyCosts: z.record(z.string(), z.number()).nullable(),
|
|
38
41
|
});
|
|
39
42
|
type ClaudeStatusAccount = z.infer<typeof ClaudeStatusAccountSchema>;
|
|
40
43
|
|
|
@@ -56,6 +59,7 @@ const CodexStatusAccountSchema = z.object({
|
|
|
56
59
|
.nullable(),
|
|
57
60
|
usageAt: z.number().nullable(),
|
|
58
61
|
sample: SampleReportSchema,
|
|
62
|
+
resetCredits: z.number().int().nullable(),
|
|
59
63
|
});
|
|
60
64
|
type CodexStatusAccount = z.infer<typeof CodexStatusAccountSchema>;
|
|
61
65
|
|
|
@@ -153,7 +157,8 @@ async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: num
|
|
|
153
157
|
samples.set(a.accountUuid, { outcome, viaTee });
|
|
154
158
|
if (!outcome.ok) return;
|
|
155
159
|
a.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
|
|
156
|
-
a.lastUsageAt = viaTee && teeAt != null ? teeAt : Date.now();
|
|
160
|
+
a.lastUsageAt = viaTee && teeAt != null ? (live?.sampledAt ?? teeAt) : Date.now();
|
|
161
|
+
if (viaTee && live?.sessionWindowWeeklyCost != null) a.sessionWindowWeeklyCost = live.sessionWindowWeeklyCost;
|
|
157
162
|
if (Object.keys(outcome.usage.perModel).length > 0) {
|
|
158
163
|
a.lastPerModel = outcome.usage.perModel;
|
|
159
164
|
a.lastPerModelAt = viaTee && modelUsage ? (modelUsage.sampledAt ?? modelUsage.ts) : a.lastUsageAt;
|
|
@@ -173,6 +178,7 @@ async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: num
|
|
|
173
178
|
const families = gatedFamilies(loadUsage()?.model ?? null, cfg.policy.switchModels);
|
|
174
179
|
const bars = effectiveBars(cfg, { accounts: idx.accounts, now, switchFamilies: families });
|
|
175
180
|
const liveAccount = readOAuthAccount()?.accountUuid ?? null;
|
|
181
|
+
const liveModelUsage = loadModelUsage();
|
|
176
182
|
const ordered = sortBy(idx.accounts, [(a) => (a.needsReauth ? 1 : 0), (a) => earliestReset(a, now)]);
|
|
177
183
|
const accounts = ordered.map((a): ClaudeStatusAccount => {
|
|
178
184
|
const sampled = samples.get(a.accountUuid) ?? { outcome: { ok: false, reason: "not sampled" }, viaTee: false };
|
|
@@ -195,6 +201,9 @@ async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: num
|
|
|
195
201
|
pingError: sampled.outcome.pingError ?? null,
|
|
196
202
|
pingRejected: sampled.outcome.pingRejected === true,
|
|
197
203
|
pinged: pings.has(a.accountUuid) && sampled.outcome.ok && sampled.outcome.pingError == null && aggregate != null && aggregate.fiveHour.resetsAt == null,
|
|
204
|
+
bankedReset: a.bankedReset ?? null,
|
|
205
|
+
sessionWindowWeeklyCost: a.sessionWindowWeeklyCost ?? null,
|
|
206
|
+
sessionWindowFamilyCosts: liveModelUsage && liveModelUsage.account === a.accountUuid ? (liveModelUsage.familyCosts ?? null) : null,
|
|
198
207
|
};
|
|
199
208
|
});
|
|
200
209
|
return {
|
|
@@ -222,7 +231,7 @@ async function collectCodex(input: { cfg: Config; now: number }): Promise<Status
|
|
|
222
231
|
const outcome = await sampleCodexAccount({ account, liveAccountId: liveId, now });
|
|
223
232
|
outcomes.set(account.accountId, outcome);
|
|
224
233
|
if (outcome.ok) {
|
|
225
|
-
account.lastUsage = { aggregate: outcome.usage.aggregate, perLimit: outcome.usage.perLimit };
|
|
234
|
+
account.lastUsage = { aggregate: outcome.usage.aggregate, perLimit: outcome.usage.perLimit, resetCredits: outcome.usage.resetCredits, reachedType: outcome.usage.reachedType };
|
|
226
235
|
account.lastUsageAt = Date.now();
|
|
227
236
|
if (outcome.usage.email != null) account.email = outcome.usage.email;
|
|
228
237
|
if (outcome.usage.planType != null) account.planType = outcome.usage.planType;
|
|
@@ -263,6 +272,7 @@ async function collectCodex(input: { cfg: Config; now: number }): Promise<Status
|
|
|
263
272
|
: null,
|
|
264
273
|
usageAt: account.lastUsageAt ?? null,
|
|
265
274
|
sample: outcome.ok ? { ok: true, source: "probe" } : { ok: false, reason: outcome.reason },
|
|
275
|
+
resetCredits: usage?.resetCredits ?? null,
|
|
266
276
|
};
|
|
267
277
|
});
|
|
268
278
|
return { bars, accounts };
|
|
@@ -99,6 +99,7 @@ export async function handleCodexStop(input: { rawStdin: string }): Promise<void
|
|
|
99
99
|
}
|
|
100
100
|
if (await promoteReconcile({ supervisorId, sessionId })) return;
|
|
101
101
|
const decision = await evaluateAndMaybeSwapCodex({});
|
|
102
|
+
if (decision.reset && decision.account) log("codexstop.reset", { account: decision.account.accountId.slice(0, 8) });
|
|
102
103
|
if (decision.swapped && decision.account) {
|
|
103
104
|
mkdirSync(codexPaths.respawnDir, { recursive: true });
|
|
104
105
|
const payload = CodexRespawnMarkerSchema.parse({
|
|
@@ -4,12 +4,13 @@ import { paths } from "../lib/paths.ts";
|
|
|
4
4
|
import { writeFileAtomic } from "../lib/atomic.ts";
|
|
5
5
|
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
6
6
|
import { enforcedWindowMs, evaluateAndMaybeSwap, postSwapProof, recordEnforcedLimit } from "../lib/decide.ts";
|
|
7
|
-
import { loadConfig, loadLastSwapAt } from "../lib/state.ts";
|
|
7
|
+
import { loadConfig, loadLastSeatChangeAt, loadLastSwapAt } from "../lib/state.ts";
|
|
8
8
|
import { classifyEnforcedLimit, findEnforcedRow, parseErrorBody, readTranscriptTail } from "../lib/usage.ts";
|
|
9
9
|
import { RespawnMarkerSchema, type EnforcedLimit } from "../lib/types.ts";
|
|
10
10
|
import { log } from "../lib/log.ts";
|
|
11
11
|
|
|
12
12
|
export const RETRIGGER_PROMPT = "Continue where the previous turn left off; it was interrupted by a usage limit and tokenmaxxing switched accounts.";
|
|
13
|
+
export const RETRIGGER_RESET_PROMPT = "Continue where the previous turn left off; it was interrupted by the session limit and tokenmaxxing reset that limit on the same account.";
|
|
13
14
|
|
|
14
15
|
const StopFailureStdin = z.looseObject({
|
|
15
16
|
session_id: z.uuid().optional().catch(undefined),
|
|
@@ -52,11 +53,11 @@ export async function runStopFailureHook(): Promise<number> {
|
|
|
52
53
|
|
|
53
54
|
let enforced: EnforcedLimit | null = null;
|
|
54
55
|
if (limit && found && account) {
|
|
55
|
-
if (postSwapProof({ swapAt: loadLastSwapAt(), launchedAt, errorAt: found.errorAt, now })) {
|
|
56
|
+
if (postSwapProof({ swapAt: limit.kind === "session" ? loadLastSeatChangeAt() : loadLastSwapAt(), launchedAt, errorAt: found.errorAt, now })) {
|
|
56
57
|
const stamp = await recordEnforcedLimit({ limit, account, now });
|
|
57
58
|
log("stopfailure.enforced", { kind: limit.kind, family: limit.kind === "model" ? limit.family : undefined, outcome: stamp.outcome, resetsAt: stamp.resetsAt, subagent: !mainLoop });
|
|
58
59
|
if (stamp.outcome !== "account-moved") {
|
|
59
|
-
enforced = { account, family: limit.kind === "model" ? limit.family : null, resetsAt: stamp.resetsAt, windowMs: enforcedWindowMs(limit) };
|
|
60
|
+
enforced = { account, kind: limit.kind, family: limit.kind === "model" ? limit.family : null, resetsAt: stamp.resetsAt, windowMs: enforcedWindowMs(limit) };
|
|
60
61
|
}
|
|
61
62
|
} else {
|
|
62
63
|
log("stopfailure.unproven", { kind: limit.kind });
|
|
@@ -71,18 +72,20 @@ export async function runStopFailureHook(): Promise<number> {
|
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
const decision = await evaluateAndMaybeSwap(now, canPause && enforced != null, enforced);
|
|
74
|
-
|
|
75
|
+
const reset = decision.reset === true;
|
|
76
|
+
if (enforced && canPause && pinnedSid && decision.account && (decision.swapped || decision.waitUntil !== undefined || reset)) {
|
|
75
77
|
const marker = join(paths.respawnDir, pinnedSid);
|
|
76
78
|
const payload = RespawnMarkerSchema.parse({
|
|
77
79
|
account: decision.account.label,
|
|
78
80
|
ts: Date.now(),
|
|
79
81
|
waitUntil: decision.waitUntil ?? now,
|
|
80
82
|
sessionId: stdinSid ?? pinnedSid,
|
|
81
|
-
prompt: RETRIGGER_PROMPT,
|
|
83
|
+
prompt: reset ? RETRIGGER_RESET_PROMPT : RETRIGGER_PROMPT,
|
|
82
84
|
...(launchedAt != null ? { launchedAt } : {}),
|
|
85
|
+
...(reset ? { reset } : {}),
|
|
83
86
|
});
|
|
84
87
|
writeFileAtomic(marker, JSON.stringify(payload));
|
|
85
|
-
log("stopfailure.marker", { session: (stdinSid ?? pinnedSid).slice(0, 8), account: decision.account.accountUuid.slice(0, 8), waitUntil: payload.waitUntil });
|
|
88
|
+
log("stopfailure.marker", { session: (stdinSid ?? pinnedSid).slice(0, 8), account: decision.account.accountUuid.slice(0, 8), waitUntil: payload.waitUntil, reset });
|
|
86
89
|
} else {
|
|
87
90
|
log("stopfailure.decision", { reason: decision.reason, swapped: decision.swapped, account: decision.account?.accountUuid.slice(0, 8), waitUntil: decision.waitUntil });
|
|
88
91
|
}
|
package/src/entries/stophook.ts
CHANGED
|
@@ -25,6 +25,7 @@ export async function runStopHook(): Promise<number> {
|
|
|
25
25
|
try {
|
|
26
26
|
const canPause = process.env.TOKENMAXXING_SUPERVISED === "1" && pinnedSid != null;
|
|
27
27
|
const decision = await evaluateAndMaybeSwap(Date.now(), canPause);
|
|
28
|
+
if (decision.reset && decision.account) log("stop.reset", { account: decision.account.accountUuid.slice(0, 8) });
|
|
28
29
|
if (decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
|
|
29
30
|
log(decision.swapped ? "stop.swapped" : "stop.wait", { account: decision.account.accountUuid.slice(0, 8), waitUntil: decision.waitUntil });
|
|
30
31
|
if (decision.waitUntil !== undefined && canPause && pinnedSid) {
|
|
@@ -286,7 +286,8 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
286
286
|
respawns++;
|
|
287
287
|
if (m.waitUntil > Date.now()) {
|
|
288
288
|
if (await countdownWait(m.account, m.waitUntil)) overriddenUntil = m.waitUntil;
|
|
289
|
-
} else process.stdout.write(`\n\x1b[36m↻ tokenmaxxing:
|
|
289
|
+
} else if (m.reset) process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: reset the session limit on ${m.account} - resuming...\x1b[0m\n`);
|
|
290
|
+
else process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched to ${m.account} - resuming...\x1b[0m\n`);
|
|
290
291
|
saveSessionFlags(m.sessionId, persistable, process.cwd());
|
|
291
292
|
launchArgs = ["--resume", m.sessionId, ...persistable, ...(m.prompt ? ["--", m.prompt] : [])];
|
|
292
293
|
continue;
|
|
@@ -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
|
+
}
|
package/src/lib/codexdecide.ts
CHANGED
|
@@ -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();
|
package/src/lib/codexpick.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/codexusage.ts
CHANGED
|
@@ -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) {
|
|
@@ -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
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
|
});
|
package/src/lib/paths.ts
CHANGED
|
@@ -19,6 +19,7 @@ export const paths = {
|
|
|
19
19
|
usageJson: join(TM_HOME, "usage.json"),
|
|
20
20
|
modelUsageJson: join(TM_HOME, "model-usage.json"),
|
|
21
21
|
lastSwapJson: join(TM_HOME, "lastswap.json"),
|
|
22
|
+
lastResetJson: join(TM_HOME, "lastreset.json"),
|
|
22
23
|
depletedJson: join(TM_HOME, "depleted.json"),
|
|
23
24
|
nextCheckJson: join(TM_HOME, "nextcheck.json"),
|
|
24
25
|
respawnDir: join(TM_HOME, "respawn"),
|
package/src/lib/state.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { closeSync, existsSync, fstatSync, openSync, readFileSync, rmSync, utimesSync } from "node:fs";
|
|
2
|
-
import { isEqual } from "es-toolkit";
|
|
2
|
+
import { isEqual, uniq } from "es-toolkit";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { paths, realClaudeBinFromEnv, realCodexBinFromEnv } from "./paths.ts";
|
|
5
5
|
import { writeFileAtomic } from "./atomic.ts";
|
|
6
6
|
import {
|
|
7
7
|
AccountsIndexSchema,
|
|
8
|
+
BankedResetProvidersSchema,
|
|
8
9
|
ConfigSchema,
|
|
9
10
|
LastSwapSchema,
|
|
10
11
|
ModelUsageStateSchema,
|
|
@@ -13,8 +14,11 @@ import {
|
|
|
13
14
|
UsageStateSchema,
|
|
14
15
|
type AccountsIndex,
|
|
15
16
|
type Config,
|
|
17
|
+
type FamilyAnchor,
|
|
16
18
|
type ModelUsageState,
|
|
17
19
|
type UsageState,
|
|
20
|
+
type UsageWindow,
|
|
21
|
+
type WindowAnchor,
|
|
18
22
|
} from "./types.ts";
|
|
19
23
|
|
|
20
24
|
const DEFAULT_CONFIG: Config = {
|
|
@@ -30,6 +34,7 @@ const DEFAULT_CONFIG: Config = {
|
|
|
30
34
|
usagePollTtlMs: 90_000,
|
|
31
35
|
maxWaitMs: 3_600_000,
|
|
32
36
|
checkIntervalMs: 60_000,
|
|
37
|
+
preferToUseBankedReset: [],
|
|
33
38
|
},
|
|
34
39
|
};
|
|
35
40
|
|
|
@@ -50,6 +55,7 @@ export const ConfigFileSchema = z
|
|
|
50
55
|
usagePollTtlMs: z.number().int().positive(),
|
|
51
56
|
maxWaitMs: z.number().int().positive(),
|
|
52
57
|
checkIntervalMs: z.number().int().min(10_000),
|
|
58
|
+
preferToUseBankedReset: BankedResetProvidersSchema,
|
|
53
59
|
})
|
|
54
60
|
.partial(),
|
|
55
61
|
})
|
|
@@ -83,6 +89,9 @@ export function mergeConfigFile(p: z.infer<typeof ConfigFileSchema>): MergeOutco
|
|
|
83
89
|
if (p.policy?.switchModels) {
|
|
84
90
|
cfg.policy.switchModels = p.policy.switchModels.map((s) => s.toLowerCase());
|
|
85
91
|
}
|
|
92
|
+
if (p.policy?.preferToUseBankedReset) {
|
|
93
|
+
cfg.policy.preferToUseBankedReset = uniq(p.policy.preferToUseBankedReset);
|
|
94
|
+
}
|
|
86
95
|
const envBin = realClaudeBinFromEnv();
|
|
87
96
|
if (envBin) cfg.claudeBin = envBin;
|
|
88
97
|
const envCodexBin = realCodexBinFromEnv();
|
|
@@ -187,6 +196,29 @@ export function saveLastSwapAt(ts: number): void {
|
|
|
187
196
|
writeFileAtomic(paths.lastSwapJson, JSON.stringify(LastSwapSchema.parse({ ts })));
|
|
188
197
|
}
|
|
189
198
|
|
|
199
|
+
export function loadLastResetAt(): number | null {
|
|
200
|
+
if (!existsSync(paths.lastResetJson)) return null;
|
|
201
|
+
let json: unknown;
|
|
202
|
+
try {
|
|
203
|
+
json = JSON.parse(readFileSync(paths.lastResetJson, "utf8"));
|
|
204
|
+
} catch {
|
|
205
|
+
throw new Error(`${paths.lastResetJson} is corrupt (unparsable JSON) - refusing to treat a damaged reset clock as never-reset; repair or remove the file`);
|
|
206
|
+
}
|
|
207
|
+
return LastSwapSchema.parse(json).ts;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function saveLastResetAt(ts: number): void {
|
|
211
|
+
writeFileAtomic(paths.lastResetJson, JSON.stringify(LastSwapSchema.parse({ ts })));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function loadLastSeatChangeAt(): number | null {
|
|
215
|
+
const swapAt = loadLastSwapAt();
|
|
216
|
+
const resetAt = loadLastResetAt();
|
|
217
|
+
if (swapAt == null) return resetAt;
|
|
218
|
+
if (resetAt == null) return swapAt;
|
|
219
|
+
return Math.max(swapAt, resetAt);
|
|
220
|
+
}
|
|
221
|
+
|
|
190
222
|
const DepletedWaitSchema = z.object({ waitUntil: z.number(), accountUuid: z.string(), ts: z.number() });
|
|
191
223
|
export type DepletedWait = z.infer<typeof DepletedWaitSchema>;
|
|
192
224
|
|
|
@@ -237,10 +269,68 @@ export function clearNextCheck(): void {
|
|
|
237
269
|
}
|
|
238
270
|
|
|
239
271
|
const USAGE_TS_REFRESH_MS = 10 * 60_000;
|
|
272
|
+
const SAMPLED_AT_REFRESH_MS = 30_000;
|
|
273
|
+
const SESSION_COST_MIN_DELTA = 25;
|
|
274
|
+
|
|
275
|
+
function anchorHolds(anchor: WindowAnchor, next: UsageState): boolean {
|
|
276
|
+
return (
|
|
277
|
+
anchor.fiveHourResetsAt === next.fiveHour.resetsAt &&
|
|
278
|
+
anchor.sevenDayResetsAt === next.sevenDay.resetsAt &&
|
|
279
|
+
next.fiveHour.usedPercentage >= anchor.session &&
|
|
280
|
+
next.sevenDay.usedPercentage >= anchor.weekly
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function measureSessionWindow(prev: UsageState | null, next: UsageState, stamp: boolean): Pick<UsageState, "anchor" | "sessionWindowWeeklyCost"> {
|
|
285
|
+
const same = prev != null && prev.account === next.account;
|
|
286
|
+
const carried = same ? prev.sessionWindowWeeklyCost : undefined;
|
|
287
|
+
if (stamp) return { ...(same && prev.anchor ? { anchor: prev.anchor } : {}), ...(carried != null ? { sessionWindowWeeklyCost: carried } : {}) };
|
|
288
|
+
const anchor: WindowAnchor =
|
|
289
|
+
same && prev.anchor && anchorHolds(prev.anchor, next)
|
|
290
|
+
? prev.anchor
|
|
291
|
+
: { fiveHourResetsAt: next.fiveHour.resetsAt, sevenDayResetsAt: next.sevenDay.resetsAt, session: next.fiveHour.usedPercentage, weekly: next.sevenDay.usedPercentage };
|
|
292
|
+
const sessionDelta = next.fiveHour.usedPercentage - anchor.session;
|
|
293
|
+
const weeklyDelta = next.sevenDay.usedPercentage - anchor.weekly;
|
|
294
|
+
const measured = sessionDelta >= SESSION_COST_MIN_DELTA && weeklyDelta > 0 ? (100 * weeklyDelta) / sessionDelta : undefined;
|
|
295
|
+
const cost = measured ?? carried;
|
|
296
|
+
return { anchor, ...(cost != null ? { sessionWindowWeeklyCost: cost } : {}) };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function familyAnchorHolds(anchor: FamilyAnchor, session: UsageWindow, caps: Record<string, number>): boolean {
|
|
300
|
+
return (
|
|
301
|
+
anchor.fiveHourResetsAt === session.resetsAt &&
|
|
302
|
+
session.usedPercentage >= anchor.session &&
|
|
303
|
+
Object.entries(caps).every(([family, used]) => anchor.caps[family] == null || used >= anchor.caps[family])
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function measureFamilyWindow(prev: ModelUsageState | null, account: string | null, session: UsageWindow | null, caps: Record<string, number>): Pick<ModelUsageState, "anchor" | "familyCosts"> {
|
|
308
|
+
const same = prev != null && prev.account === account;
|
|
309
|
+
const carried = same ? prev.familyCosts : undefined;
|
|
310
|
+
if (session == null) return { ...(same && prev.anchor ? { anchor: prev.anchor } : {}), ...(carried != null ? { familyCosts: carried } : {}) };
|
|
311
|
+
const held = same && prev.anchor && familyAnchorHolds(prev.anchor, session, caps) ? prev.anchor : null;
|
|
312
|
+
const anchor: FamilyAnchor = held ? { ...held, caps: { ...caps, ...held.caps } } : { fiveHourResetsAt: session.resetsAt, session: session.usedPercentage, caps };
|
|
313
|
+
const sessionDelta = session.usedPercentage - anchor.session;
|
|
314
|
+
const measured = Object.entries(caps).flatMap(([family, used]) => {
|
|
315
|
+
const capDelta = used - (anchor.caps[family] ?? used);
|
|
316
|
+
return sessionDelta >= SESSION_COST_MIN_DELTA && capDelta > 0 ? [[family, (100 * capDelta) / sessionDelta] as const] : [];
|
|
317
|
+
});
|
|
318
|
+
const familyCosts = { ...carried, ...Object.fromEntries(measured) };
|
|
319
|
+
return { anchor, ...(Object.keys(familyCosts).length > 0 ? { familyCosts } : {}) };
|
|
320
|
+
}
|
|
240
321
|
|
|
241
|
-
export function writeUsage(
|
|
322
|
+
export function writeUsage(input: UsageState, opts: { stamp?: boolean } = {}): boolean {
|
|
242
323
|
const prev = loadUsage();
|
|
243
|
-
|
|
324
|
+
const stamp = opts.stamp === true;
|
|
325
|
+
const carriedSampleAt = prev != null && prev.account === input.account ? (prev.sampledAt ?? prev.ts) : undefined;
|
|
326
|
+
const sampledAt = stamp ? (carriedSampleAt ?? input.sampledAt) : input.ts;
|
|
327
|
+
const next: UsageState = { ...input, ...measureSessionWindow(prev, input, stamp), ...(sampledAt != null ? { sampledAt } : {}) };
|
|
328
|
+
if (
|
|
329
|
+
prev &&
|
|
330
|
+
isEqual({ ...prev, ts: 0, sampledAt: 0 }, { ...next, ts: 0, sampledAt: 0 }) &&
|
|
331
|
+
next.ts - prev.ts < USAGE_TS_REFRESH_MS &&
|
|
332
|
+
(stamp || next.ts - (prev.sampledAt ?? prev.ts) < SAMPLED_AT_REFRESH_MS)
|
|
333
|
+
) {
|
|
244
334
|
try {
|
|
245
335
|
utimesSync(paths.usageJson, new Date(next.ts), new Date(next.ts));
|
|
246
336
|
} catch (e) {
|
package/src/lib/types.ts
CHANGED
|
@@ -41,21 +41,51 @@ export type UsageWindows = z.infer<typeof UsageWindowsSchema>;
|
|
|
41
41
|
const ModelInfoSchema = z.object({ id: z.string(), display: z.string() });
|
|
42
42
|
export type ModelInfo = z.infer<typeof ModelInfoSchema>;
|
|
43
43
|
|
|
44
|
+
const WindowAnchorSchema = z.object({
|
|
45
|
+
fiveHourResetsAt: z.number().nullable(),
|
|
46
|
+
sevenDayResetsAt: z.number().nullable(),
|
|
47
|
+
session: z.number(),
|
|
48
|
+
weekly: z.number(),
|
|
49
|
+
});
|
|
50
|
+
export type WindowAnchor = z.infer<typeof WindowAnchorSchema>;
|
|
51
|
+
|
|
44
52
|
export const UsageStateSchema = UsageWindowsSchema.extend({
|
|
45
53
|
account: z.string().nullable(),
|
|
46
54
|
ts: z.number(),
|
|
47
55
|
model: ModelInfoSchema.nullable().default(null),
|
|
56
|
+
anchor: WindowAnchorSchema.optional(),
|
|
57
|
+
sessionWindowWeeklyCost: z.number().positive().optional(),
|
|
58
|
+
sampledAt: z.number().optional(),
|
|
48
59
|
});
|
|
49
60
|
export type UsageState = z.infer<typeof UsageStateSchema>;
|
|
50
61
|
|
|
62
|
+
const FamilyAnchorSchema = z.object({
|
|
63
|
+
fiveHourResetsAt: z.number().nullable(),
|
|
64
|
+
session: z.number(),
|
|
65
|
+
caps: z.record(z.string(), z.number()),
|
|
66
|
+
});
|
|
67
|
+
export type FamilyAnchor = z.infer<typeof FamilyAnchorSchema>;
|
|
68
|
+
|
|
51
69
|
export const ModelUsageStateSchema = z.object({
|
|
52
70
|
perModel: z.record(z.string(), UsageWindowSchema).default({}),
|
|
53
71
|
account: z.string().nullable(),
|
|
54
72
|
ts: z.number(),
|
|
55
73
|
sampledAt: z.number().optional(),
|
|
74
|
+
anchor: FamilyAnchorSchema.optional(),
|
|
75
|
+
familyCosts: z.record(z.string(), z.number()).optional(),
|
|
56
76
|
});
|
|
57
77
|
export type ModelUsageState = z.infer<typeof ModelUsageStateSchema>;
|
|
58
78
|
|
|
79
|
+
export const BankedResetOutcomeSchema = z.enum(["reset", "already_used", "not_limited", "ineligible", "unavailable", "rate_limited", "auth_error", "error"]);
|
|
80
|
+
export type BankedResetOutcome = z.infer<typeof BankedResetOutcomeSchema>;
|
|
81
|
+
|
|
82
|
+
export const BankedResetRecordSchema = z.object({
|
|
83
|
+
outcome: BankedResetOutcomeSchema,
|
|
84
|
+
at: z.number(),
|
|
85
|
+
nextAvailableAt: z.number().nullable(),
|
|
86
|
+
});
|
|
87
|
+
export type BankedResetRecord = z.infer<typeof BankedResetRecordSchema>;
|
|
88
|
+
|
|
59
89
|
export const AccountSchema = z.object({
|
|
60
90
|
accountUuid: z.string(),
|
|
61
91
|
email: z.string(),
|
|
@@ -72,6 +102,8 @@ export const AccountSchema = z.object({
|
|
|
72
102
|
needsReauth: z.boolean().optional(),
|
|
73
103
|
subscriptionType: z.string().optional(),
|
|
74
104
|
rateLimitTier: z.string().optional(),
|
|
105
|
+
sessionWindowWeeklyCost: z.number().positive().optional(),
|
|
106
|
+
bankedReset: BankedResetRecordSchema.optional(),
|
|
75
107
|
});
|
|
76
108
|
export type Account = z.infer<typeof AccountSchema>;
|
|
77
109
|
|
|
@@ -88,6 +120,7 @@ export const NextCheckSchema = z.object({ dueAt: z.number(), ts: z.number() });
|
|
|
88
120
|
|
|
89
121
|
export const EnforcedLimitSchema = z.object({
|
|
90
122
|
account: z.string(),
|
|
123
|
+
kind: z.enum(["session", "weekly", "model"]),
|
|
91
124
|
family: z.string().nullable(),
|
|
92
125
|
resetsAt: z.number().nullable(),
|
|
93
126
|
windowMs: z.number(),
|
|
@@ -112,6 +145,10 @@ export const ScreeningThresholdsSchema = z.object({
|
|
|
112
145
|
weekly: z.number().min(0).max(100),
|
|
113
146
|
});
|
|
114
147
|
|
|
148
|
+
export const BankedResetProviderSchema = z.enum(["claude", "codex"]);
|
|
149
|
+
export type BankedResetProvider = z.infer<typeof BankedResetProviderSchema>;
|
|
150
|
+
export const BankedResetProvidersSchema = z.array(BankedResetProviderSchema);
|
|
151
|
+
|
|
115
152
|
export const ConfigSchema = z
|
|
116
153
|
.object({
|
|
117
154
|
thresholds: ScreeningThresholdsSchema,
|
|
@@ -126,6 +163,7 @@ export const ConfigSchema = z
|
|
|
126
163
|
usagePollTtlMs: z.number().int().positive(),
|
|
127
164
|
maxWaitMs: z.number().int().positive(),
|
|
128
165
|
checkIntervalMs: z.number().int().min(10_000),
|
|
166
|
+
preferToUseBankedReset: BankedResetProvidersSchema,
|
|
129
167
|
}),
|
|
130
168
|
})
|
|
131
169
|
.refine((cfg) => cfg.policy.projectionMargin < Math.min(...cfg.thresholds.session, cfg.thresholds.weekly), {
|
|
@@ -143,6 +181,7 @@ export const RespawnMarkerSchema = z.object({
|
|
|
143
181
|
sessionId: z.string(),
|
|
144
182
|
prompt: z.string().optional(),
|
|
145
183
|
launchedAt: z.number().optional(),
|
|
184
|
+
reset: z.boolean().optional(),
|
|
146
185
|
});
|
|
147
186
|
|
|
148
187
|
export const RateLimitsStdinSchema = z.looseObject({
|
|
@@ -242,9 +281,21 @@ export const CodexUsageSchema = z.object({
|
|
|
242
281
|
planType: z.string().nullable(),
|
|
243
282
|
aggregate: z.array(CodexWindowSchema),
|
|
244
283
|
perLimit: z.record(z.string(), z.array(CodexWindowSchema)),
|
|
284
|
+
resetCredits: z.number().int().nullable(),
|
|
285
|
+
reachedType: z.string().nullable(),
|
|
245
286
|
});
|
|
246
287
|
export type CodexUsage = z.infer<typeof CodexUsageSchema>;
|
|
247
288
|
|
|
289
|
+
export const CodexBankedResetOutcomeSchema = z.enum(["reset", "nothing_to_reset", "no_credit", "already_redeemed", "auth_error", "error"]);
|
|
290
|
+
export type CodexBankedResetOutcome = z.infer<typeof CodexBankedResetOutcomeSchema>;
|
|
291
|
+
|
|
292
|
+
export const CodexBankedResetRecordSchema = z.object({
|
|
293
|
+
outcome: CodexBankedResetOutcomeSchema,
|
|
294
|
+
at: z.number(),
|
|
295
|
+
key: z.string().optional(),
|
|
296
|
+
});
|
|
297
|
+
export type CodexBankedResetRecord = z.infer<typeof CodexBankedResetRecordSchema>;
|
|
298
|
+
|
|
248
299
|
const BareFileNameSchema = z
|
|
249
300
|
.string()
|
|
250
301
|
.refine((s) => s.length > 0 && s !== "." && s !== ".." && !s.includes("/") && !s.includes("\\"), {
|
|
@@ -263,9 +314,12 @@ export const CodexAccountSchema = z.object({
|
|
|
263
314
|
.object({
|
|
264
315
|
aggregate: z.array(CodexWindowSchema),
|
|
265
316
|
perLimit: z.record(z.string(), z.array(CodexWindowSchema)),
|
|
317
|
+
resetCredits: z.number().int().nullable().optional(),
|
|
318
|
+
reachedType: z.string().nullable().optional(),
|
|
266
319
|
})
|
|
267
320
|
.optional(),
|
|
268
321
|
lastUsageAt: z.number().optional(),
|
|
322
|
+
bankedReset: CodexBankedResetRecordSchema.optional(),
|
|
269
323
|
});
|
|
270
324
|
export type CodexAccount = z.infer<typeof CodexAccountSchema>;
|
|
271
325
|
|