tokenmaxxing 1.34.0 → 1.35.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 +3 -5
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/cli/add.ts +23 -40
- package/src/cli/auth.ts +38 -52
- package/src/cli/check.ts +2 -1
- package/src/cli/doctor.ts +13 -13
- package/src/cli/init.ts +29 -126
- package/src/cli/rename.ts +11 -45
- package/src/cli/render.ts +0 -7
- package/src/cli/rm.ts +24 -39
- package/src/cli/setuptoken.ts +16 -16
- package/src/cli/status.ts +95 -238
- package/src/cli/switch.ts +54 -46
- package/src/entries/codexstophook.ts +22 -16
- package/src/entries/codexsupervisor.ts +3 -3
- package/src/entries/sessionstart.ts +3 -2
- package/src/entries/statusline.ts +15 -13
- package/src/entries/stopfailurehook.ts +7 -5
- package/src/entries/stophook.ts +3 -2
- package/src/lib/claude.ts +320 -0
- package/src/lib/codex.ts +353 -0
- package/src/lib/codexauth.ts +6 -0
- package/src/lib/codexpresence.ts +0 -10
- package/src/lib/codexusage.ts +14 -32
- package/src/lib/decide.ts +86 -174
- package/src/lib/oauth.ts +7 -0
- package/src/lib/paths.ts +14 -6
- package/src/lib/picker.ts +56 -37
- package/src/lib/provider.ts +34 -0
- package/src/lib/sample.ts +33 -33
- package/src/lib/state.ts +49 -16
- package/src/lib/swap.ts +41 -38
- package/src/lib/types.ts +17 -51
- package/src/lib/usage.ts +17 -4
- package/src/main.ts +20 -20
- package/src/cli/codexadd.ts +0 -89
- package/src/cli/codexinit.ts +0 -120
- package/src/cli/codexrm.ts +0 -36
- package/src/cli/codexswitch.ts +0 -113
- package/src/cli/onboard.ts +0 -92
- package/src/lib/codexdecide.ts +0 -157
- package/src/lib/codexpick.ts +0 -80
- package/src/lib/codexsample.ts +0 -48
- package/src/lib/codexstate.ts +0 -22
- package/src/lib/codexswap.ts +0 -70
package/src/lib/decide.ts
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
|
-
import { maxBy } from "es-toolkit";
|
|
2
1
|
import { z } from "zod";
|
|
3
2
|
import { withLock } from "./lock.ts";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { isSkippableSwapError, performSwap } from "./swap.ts";
|
|
8
|
-
import { isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, thresholdBars, usableAt } from "./picker.ts";
|
|
9
|
-
import { familyTokens, gatedFamilies, keepRows, probeUsage } from "./usage.ts";
|
|
3
|
+
import { POST_SWAP_COOLDOWN_MS, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, saveAccounts, saveDepletedWait } from "./state.ts";
|
|
4
|
+
import { isExhausted, limitWindows, nextWeeklyReset, pickBest, pickEarliestReset, sessionWindow, thresholdBars, usableAt, weeklyWindow, type PickCtx } from "./picker.ts";
|
|
5
|
+
import { familyTokens } from "./usage.ts";
|
|
10
6
|
import { log } from "./log.ts";
|
|
11
|
-
import
|
|
7
|
+
import type { Observation, Provider } from "./provider.ts";
|
|
8
|
+
import { AccountSchema, type Account, type EnforcedLimit } from "./types.ts";
|
|
12
9
|
|
|
13
10
|
const SwapDecisionSchema = z.object({
|
|
14
11
|
swapped: z.boolean(),
|
|
@@ -21,201 +18,132 @@ export type SwapDecision = z.infer<typeof SwapDecisionSchema>;
|
|
|
21
18
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
22
19
|
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
|
|
23
20
|
|
|
24
|
-
function
|
|
25
|
-
const { window: w, windowMs, sampledAt, now } = input;
|
|
26
|
-
if (w.resetsAt != null) return w.resetsAt <= now ? 0 : w.usedPercentage;
|
|
27
|
-
if (now >= sampledAt + windowMs) return 0;
|
|
28
|
-
return w.usedPercentage;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function rowsSampledAt(account: Account, now: number): number {
|
|
32
|
-
return account.lastUsage?.rowsAt ?? account.lastUsageAt ?? now;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function capForFamily(account: Account, family: string, now: number): UsageWindow | undefined {
|
|
36
|
-
const rows = Object.entries(account.lastUsage?.perModel ?? {})
|
|
37
|
-
.filter(([k]) => familyTokens(k).includes(family))
|
|
38
|
-
.map(([, w]) => w);
|
|
39
|
-
return maxBy(rows, (w) => liveUsed({ window: w, windowMs: WEEK_MS, sampledAt: rowsSampledAt(account, now), now }));
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function isOver(u: UsageState | null, account: Account | undefined, bars: Thresholds, cfg: Config, now: number): boolean {
|
|
21
|
+
function isOver(account: Account | undefined, observed: Observation | null, ctx: PickCtx): boolean {
|
|
43
22
|
if (!account) return false;
|
|
44
|
-
if (account.
|
|
45
|
-
if (
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
liveUsed({ window: u.sevenDay, windowMs: WEEK_MS, sampledAt: u.ts, now }) >= bars.weekly
|
|
49
|
-
) return true;
|
|
50
|
-
for (const family of gatedFamilies(u.model, cfg.policy.switchModels)) {
|
|
51
|
-
const cap = capForFamily(account, family, now);
|
|
52
|
-
if (cap && liveUsed({ window: cap, windowMs: WEEK_MS, sampledAt: rowsSampledAt(account, now), now }) >= bars.weekly) return true;
|
|
53
|
-
}
|
|
54
|
-
return false;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function needsPerModel(u: UsageState | null, cfg: Config): boolean {
|
|
58
|
-
return u != null && gatedFamilies(u.model, cfg.policy.switchModels).length > 0;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const SnapshotsSchema = z.object({
|
|
62
|
-
u: UsageStateSchema.nullable(),
|
|
63
|
-
uAt: z.number().nullable(),
|
|
64
|
-
});
|
|
65
|
-
type Snapshots = z.infer<typeof SnapshotsSchema>;
|
|
66
|
-
|
|
67
|
-
function usageFresh(u: UsageState | null, uAt: number | null, account: string | null, ttl: number, now: number): boolean {
|
|
68
|
-
return u != null && u.account === account && uAt != null && now - uAt <= ttl;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function freshest(u: UsageState | null, uAt: number | null, account: Account | undefined): UsageState | null {
|
|
72
|
-
if (!u || uAt == null) return u;
|
|
73
|
-
const stored = account?.lastUsage;
|
|
74
|
-
if (stored && account.lastUsageAt != null && u.account === account.accountUuid && uAt < account.lastUsageAt) {
|
|
75
|
-
return { ...u, fiveHour: stored.fiveHour, sevenDay: stored.sevenDay, ts: account.lastUsageAt };
|
|
76
|
-
}
|
|
77
|
-
return { ...u, ts: uAt };
|
|
23
|
+
if (account.needsReauth === true) return true;
|
|
24
|
+
if (account.enforcedUntil != null && account.enforcedUntil > ctx.now) return true;
|
|
25
|
+
if (!observed) return false;
|
|
26
|
+
return isExhausted({ ...account, windows: observed.windows }, ctx);
|
|
78
27
|
}
|
|
79
28
|
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (teed && usageFresh(teed.state, teed.at, account, ttl, ts)) {
|
|
96
|
-
u = teed.state;
|
|
97
|
-
uAt = teed.at;
|
|
98
|
-
} else {
|
|
99
|
-
u = { fiveHour: full.fiveHour, sevenDay: full.sevenDay, account, ts, model: null };
|
|
100
|
-
writeUsage(u);
|
|
101
|
-
uAt = ts;
|
|
102
|
-
}
|
|
103
|
-
const expected = gatedFamilies(u?.model ?? null, cfg.policy.switchModels);
|
|
104
|
-
const rows = Object.keys(full.perModel);
|
|
105
|
-
if (expected.length > 0 && !expected.some((f) => rows.some((k) => familyTokens(k).includes(f)))) {
|
|
106
|
-
log("usage.no_permodel_row", { families: expected.join(","), rows: rows.join(",") });
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
await withLock(paths.lockFile, () => {
|
|
110
|
-
const idx = loadAccounts();
|
|
111
|
-
const a = idx.accounts.find((x) => x.accountUuid === account);
|
|
112
|
-
if (!a) return;
|
|
113
|
-
a.lastProbeAt = startedAt;
|
|
114
|
-
if (full) {
|
|
115
|
-
if (!a.lastUsage) a.lastUsageAt = startedAt;
|
|
116
|
-
a.lastUsage = keepRows({ ...(a.lastUsage ?? full), perModel: full.perModel }, a.lastUsage, startedAt);
|
|
117
|
-
}
|
|
118
|
-
saveAccounts(idx);
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
return { u, uAt };
|
|
29
|
+
function enforcedWall(limit: EnforcedLimit, account: Account, now: number): number {
|
|
30
|
+
const { family } = limit;
|
|
31
|
+
const familyReset =
|
|
32
|
+
family == null
|
|
33
|
+
? null
|
|
34
|
+
: limitWindows(account)
|
|
35
|
+
.filter((w) => familyTokens(w.name ?? "").includes(family))
|
|
36
|
+
.map((w) => w.resetsAt)
|
|
37
|
+
.find((r): r is number => r != null) ?? null;
|
|
38
|
+
const session = sessionWindow(account)?.resetsAt ?? null;
|
|
39
|
+
const cachedReset =
|
|
40
|
+
limit.kind === "session"
|
|
41
|
+
? session != null && session > now ? session : null
|
|
42
|
+
: nextWeeklyReset(familyReset ?? weeklyWindow(account)?.resetsAt ?? null, now);
|
|
43
|
+
return limit.resetsAt ?? cachedReset ?? now + (limit.kind === "session" ? FIVE_HOURS_MS : WEEK_MS);
|
|
123
44
|
}
|
|
124
45
|
|
|
125
|
-
export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = false, enforced: EnforcedLimit | null = null): Promise<SwapDecision> {
|
|
126
|
-
const
|
|
46
|
+
export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), anticipatory = false, enforced: EnforcedLimit | null = null): Promise<SwapDecision> {
|
|
47
|
+
const activeId = p.liveId();
|
|
127
48
|
|
|
128
|
-
const lastSwapAt = loadLastSwapAt();
|
|
49
|
+
const lastSwapAt = loadLastSwapAt(p.pool);
|
|
129
50
|
if (!enforced && lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) {
|
|
130
|
-
return depletedReplay(now) ?? { swapped: false, account: null, reason: "post-swap-cooldown" };
|
|
51
|
+
return depletedReplay(p, now) ?? { swapped: false, account: null, reason: "post-swap-cooldown" };
|
|
131
52
|
}
|
|
132
53
|
|
|
133
54
|
const cfg = loadConfig();
|
|
134
55
|
const bars = thresholdBars(cfg);
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
if (!enforced && !isOver(
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const replay = depletedReplay(now);
|
|
56
|
+
const stored0 = loadAccounts(p.pool).accounts.find((a) => a.id === activeId);
|
|
57
|
+
const walled0 = stored0?.enforcedUntil != null && stored0.enforcedUntil > now;
|
|
58
|
+
const observed = stored0 ? await p.observeLive(stored0, cfg, now, { probe: enforced == null && !walled0 }) : null;
|
|
59
|
+
const stored = loadAccounts(p.pool).accounts.find((a) => a.id === activeId);
|
|
60
|
+
|
|
61
|
+
if (!enforced && !isOver(stored, observed, { now, thresholds: bars, currentId: activeId, families: p.gatedFamilies(cfg) })) {
|
|
62
|
+
if (!observed) {
|
|
63
|
+
const replay = depletedReplay(p, now);
|
|
144
64
|
if (replay) return replay;
|
|
145
65
|
}
|
|
146
66
|
return { swapped: false, account: null, reason: "under-threshold-or-stale" };
|
|
147
67
|
}
|
|
148
68
|
|
|
149
|
-
return withLock(
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const
|
|
69
|
+
return withLock(p.pool.lockFile, async () => {
|
|
70
|
+
const lastSwapAt2 = loadLastSwapAt(p.pool);
|
|
71
|
+
if (!enforced && lastSwapAt2 != null && now - lastSwapAt2 < POST_SWAP_COOLDOWN_MS) {
|
|
72
|
+
return { swapped: false, account: null, reason: "raced-already-swapped" };
|
|
73
|
+
}
|
|
74
|
+
const idx = loadAccounts(p.pool);
|
|
75
|
+
const id2 = p.liveId();
|
|
76
|
+
const active = id2 ? idx.accounts.find((a) => a.id === id2) : undefined;
|
|
155
77
|
|
|
156
|
-
const origin = enforced ? idx.accounts.find((a) => a.
|
|
78
|
+
const origin = enforced ? idx.accounts.find((a) => a.id === enforced.account) : undefined;
|
|
157
79
|
const prior = origin?.enforcedUntil != null && origin.enforcedUntil > now;
|
|
158
80
|
if (enforced && origin) {
|
|
159
81
|
origin.enforcedUntil = Math.max(origin.enforcedUntil ?? 0, enforcedWall(enforced, origin, now));
|
|
160
82
|
origin.lastProbeAt = now;
|
|
161
|
-
saveAccounts(idx);
|
|
83
|
+
saveAccounts(p.pool, idx);
|
|
162
84
|
log("usage.enforced_limit", { kind: enforced.kind, family: enforced.family ?? undefined, resetsAt: origin.enforcedUntil, blind: prior || enforced.blind, live: origin === active });
|
|
163
85
|
}
|
|
164
86
|
const enforced2 = enforced && origin && origin === active ? enforced : null;
|
|
165
87
|
|
|
166
|
-
if (
|
|
88
|
+
if (id2 != null && !active) {
|
|
167
89
|
return { swapped: false, account: null, reason: "live-credential-not-in-pool" };
|
|
168
90
|
}
|
|
169
91
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
active.
|
|
173
|
-
|
|
92
|
+
const obs2 = active ? await p.observeLive(active, cfg, now, { probe: false }) : null;
|
|
93
|
+
if (active && obs2 && (active.lastUsageAt == null || obs2.at > active.lastUsageAt)) {
|
|
94
|
+
active.windows = p.mergeWindows(obs2.windows, active.windows);
|
|
95
|
+
active.lastUsageAt = obs2.at;
|
|
96
|
+
saveAccounts(p.pool, idx);
|
|
174
97
|
}
|
|
175
98
|
|
|
99
|
+
const families = p.gatedFamilies(cfg);
|
|
176
100
|
const walled = active?.enforcedUntil != null && active.enforcedUntil > now;
|
|
177
101
|
const blindScreen = (enforced != null && (prior || enforced.blind)) || (walled && !enforced2);
|
|
178
|
-
const
|
|
102
|
+
const screened = blindScreen && families != null ? cfg.policy.switchModels : families;
|
|
179
103
|
const family = enforced?.family ?? null;
|
|
180
|
-
const switchFamilies = family != null && !
|
|
104
|
+
const switchFamilies = screened == null ? null : family != null && !screened.includes(family) ? [...screened, family] : screened;
|
|
181
105
|
|
|
182
|
-
const seatExhausted = active != null && isExhausted(active, { now, thresholds: bars,
|
|
183
|
-
if (!enforced2 && !isOver(
|
|
184
|
-
return depletedReplay(now) ?? { swapped: false, account: null, reason: "raced-already-swapped" };
|
|
106
|
+
const seatExhausted = active != null && isExhausted(active, { now, thresholds: bars, currentId: id2, families: switchFamilies });
|
|
107
|
+
if (!enforced2 && !isOver(active, obs2, { now, thresholds: bars, currentId: id2, families }) && !(enforced && seatExhausted)) {
|
|
108
|
+
return depletedReplay(p, now) ?? { swapped: false, account: null, reason: "raced-already-swapped" };
|
|
185
109
|
}
|
|
186
110
|
|
|
187
|
-
const seatOf = (
|
|
188
|
-
|
|
189
|
-
idx2.accounts.find((a) => a.accountUuid === idx2.activeAccountUuid) ??
|
|
190
|
-
null;
|
|
111
|
+
const seatOf = (cur: { activeId: string | null; accounts: Account[] }): Account | null =>
|
|
112
|
+
cur.accounts.find((a) => a.id === id2) ?? cur.accounts.find((a) => a.id === cur.activeId) ?? null;
|
|
191
113
|
|
|
114
|
+
const present = p.presentIds();
|
|
192
115
|
const rejected = new Set<string>();
|
|
193
|
-
const usable = (accounts: Account[]): Account[] => accounts.filter((a) => !rejected.has(a.
|
|
116
|
+
const usable = (accounts: Account[]): Account[] => accounts.filter((a) => !rejected.has(a.id) && (a.id === id2 || !present.has(a.id)));
|
|
194
117
|
const skipOrThrow = (e: unknown, candidate: Account): void => {
|
|
195
|
-
if (
|
|
196
|
-
rejected.add(candidate.
|
|
197
|
-
log("decide.candidate_rejected", { account: candidate.
|
|
118
|
+
if (p.classifySwapError(e) === "fatal") throw e;
|
|
119
|
+
rejected.add(candidate.id);
|
|
120
|
+
log("decide.candidate_rejected", { account: candidate.id.slice(0, 8), error: e instanceof Error ? e.message : String(e) });
|
|
198
121
|
};
|
|
199
122
|
while (true) {
|
|
200
|
-
const cur = loadAccounts();
|
|
123
|
+
const cur = loadAccounts(p.pool);
|
|
201
124
|
const seat = seatOf(cur);
|
|
202
|
-
const ctx = { now, thresholds: bars,
|
|
125
|
+
const ctx: PickCtx = { now, thresholds: bars, currentId: seat?.id ?? null, families: switchFamilies };
|
|
203
126
|
const best = pickBest(usable(cur.accounts), ctx);
|
|
204
127
|
if (!best) break;
|
|
205
128
|
try {
|
|
206
|
-
await
|
|
129
|
+
await p.swap(best);
|
|
207
130
|
} catch (e) {
|
|
208
131
|
skipOrThrow(e, best);
|
|
209
132
|
continue;
|
|
210
133
|
}
|
|
211
|
-
log("decide.swap", { account: best.
|
|
134
|
+
log("decide.swap", { account: best.id.slice(0, 8), enforced: enforced2 != null });
|
|
212
135
|
return { swapped: true, account: best, reason: "swapped" };
|
|
213
136
|
}
|
|
214
137
|
|
|
138
|
+
if (!p.waitsWhenDepleted) {
|
|
139
|
+
log("decide.depleted", { waitUntil: 0 });
|
|
140
|
+
return { swapped: false, account: null, reason: "all-depleted" };
|
|
141
|
+
}
|
|
142
|
+
|
|
215
143
|
while (true) {
|
|
216
|
-
const fresh = loadAccounts();
|
|
144
|
+
const fresh = loadAccounts(p.pool);
|
|
217
145
|
const current = seatOf(fresh);
|
|
218
|
-
const ctx = { now, thresholds: bars,
|
|
146
|
+
const ctx: PickCtx = { now, thresholds: bars, currentId: current?.id ?? null, families: switchFamilies };
|
|
219
147
|
const currentAt = current ? usableAt(current, ctx) : Number.POSITIVE_INFINITY;
|
|
220
148
|
const other = pickEarliestReset(usable(fresh.accounts), ctx);
|
|
221
149
|
|
|
@@ -230,48 +158,32 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
230
158
|
return { swapped: false, account: null, reason: "all-depleted", ...(Number.isFinite(waitUntil) ? { waitUntil } : {}) };
|
|
231
159
|
}
|
|
232
160
|
|
|
233
|
-
const isCurrent = target.
|
|
161
|
+
const isCurrent = target.id === (current?.id ?? null);
|
|
234
162
|
if (!isCurrent && !anticipatory) {
|
|
235
|
-
log("decide.depleted_no_park", { account: target.
|
|
163
|
+
log("decide.depleted_no_park", { account: target.id.slice(0, 8), waitUntil });
|
|
236
164
|
return { swapped: false, account: null, reason: "all-depleted", waitUntil };
|
|
237
165
|
}
|
|
238
166
|
if (!isCurrent) {
|
|
239
167
|
try {
|
|
240
|
-
await
|
|
168
|
+
await p.swap(target);
|
|
241
169
|
} catch (e) {
|
|
242
170
|
skipOrThrow(e, target);
|
|
243
171
|
continue;
|
|
244
172
|
}
|
|
245
173
|
}
|
|
246
|
-
saveDepletedWait({ waitUntil,
|
|
247
|
-
log("decide.depleted_wait", { account: target.
|
|
174
|
+
saveDepletedWait({ waitUntil, id: target.id, ts: now });
|
|
175
|
+
log("decide.depleted_wait", { account: target.id.slice(0, 8), waitUntil });
|
|
248
176
|
return { swapped: !isCurrent, account: target, reason: "depleted-wait", waitUntil };
|
|
249
177
|
}
|
|
250
178
|
});
|
|
251
179
|
}
|
|
252
180
|
|
|
253
|
-
function depletedReplay(now: number): SwapDecision | null {
|
|
181
|
+
function depletedReplay(p: Provider, now: number): SwapDecision | null {
|
|
182
|
+
if (!p.waitsWhenDepleted) return null;
|
|
254
183
|
const rec = loadDepletedWait();
|
|
255
184
|
if (!rec || rec.waitUntil <= now) return null;
|
|
256
|
-
const account = loadAccounts().accounts.find((a) => a.
|
|
185
|
+
const account = loadAccounts(p.pool).accounts.find((a) => a.id === rec.id) ?? null;
|
|
257
186
|
if (!account) return null;
|
|
258
|
-
if (account.
|
|
187
|
+
if (account.id !== p.liveId()) return null;
|
|
259
188
|
return { swapped: false, account, reason: "depleted-wait", waitUntil: rec.waitUntil };
|
|
260
189
|
}
|
|
261
|
-
|
|
262
|
-
function enforcedWall(limit: EnforcedLimit, account: Account, now: number): number {
|
|
263
|
-
const cached = account.lastUsage;
|
|
264
|
-
const { family } = limit;
|
|
265
|
-
const familyReset =
|
|
266
|
-
family == null
|
|
267
|
-
? null
|
|
268
|
-
: Object.entries(cached?.perModel ?? {})
|
|
269
|
-
.filter(([k]) => familyTokens(k).includes(family))
|
|
270
|
-
.map(([, w]) => w.resetsAt)
|
|
271
|
-
.find((r): r is number => r != null) ?? null;
|
|
272
|
-
const cachedReset =
|
|
273
|
-
limit.kind === "session"
|
|
274
|
-
? cached?.fiveHour.resetsAt != null && cached.fiveHour.resetsAt > now ? cached.fiveHour.resetsAt : null
|
|
275
|
-
: nextWeeklyReset(familyReset ?? cached?.sevenDay.resetsAt ?? null, now);
|
|
276
|
-
return limit.resetsAt ?? cachedReset ?? now + (limit.kind === "session" ? FIVE_HOURS_MS : WEEK_MS);
|
|
277
|
-
}
|
package/src/lib/oauth.ts
CHANGED
|
@@ -40,6 +40,13 @@ export function isDeadCredential(creds: OAuthCreds): boolean {
|
|
|
40
40
|
return creds.refreshToken === "" || creds.accessToken === "";
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
export function claudeTierLabel(input: { subscriptionType?: string; rateLimitTier?: string }): string | null {
|
|
44
|
+
const segments = input.rateLimitTier?.split("_") ?? [];
|
|
45
|
+
const multiplier = segments.find((seg) => seg.length > 1 && seg.endsWith("x") && Number.isInteger(Number(seg.slice(0, -1))));
|
|
46
|
+
if (input.subscriptionType == null) return multiplier ?? null;
|
|
47
|
+
return multiplier ? `${input.subscriptionType} ${multiplier}` : input.subscriptionType;
|
|
48
|
+
}
|
|
49
|
+
|
|
43
50
|
export async function refreshCredential(creds: OAuthCreds, now = Date.now(), signal?: AbortSignal): Promise<OAuthCreds> {
|
|
44
51
|
if (isDeadCredential(creds)) throw new InvalidGrantError("credential was cleared after a failed refresh");
|
|
45
52
|
const scope = (creds.scopes?.length ? creds.scopes : DEFAULT_SCOPES).join(" ");
|
package/src/lib/paths.ts
CHANGED
|
@@ -15,14 +15,11 @@ const TM_HOME = env("TOKENMAXXING_HOME", join(HOME, ".config", "tokenmaxxing"));
|
|
|
15
15
|
export const paths = {
|
|
16
16
|
home: TM_HOME,
|
|
17
17
|
configJson: join(TM_HOME, "config.json"),
|
|
18
|
-
accountsJson: join(TM_HOME, "accounts.json"),
|
|
19
18
|
usageJson: join(TM_HOME, "usage.json"),
|
|
20
|
-
lastSwapJson: join(TM_HOME, "lastswap.json"),
|
|
21
19
|
depletedJson: join(TM_HOME, "depleted.json"),
|
|
22
20
|
respawnDir: join(TM_HOME, "respawn"),
|
|
23
21
|
binDir: join(TM_HOME, "bin"),
|
|
24
22
|
supervisorLink: join(TM_HOME, "bin", "claude"),
|
|
25
|
-
lockFile: join(TM_HOME, "lock"),
|
|
26
23
|
logFile: join(TM_HOME, "tokenmaxxing.log"),
|
|
27
24
|
onboardDir: join(TM_HOME, "onboard"),
|
|
28
25
|
sampleDir: join(TM_HOME, "sample"),
|
|
@@ -44,15 +41,26 @@ export const paths = {
|
|
|
44
41
|
systemdUserDir: env("TOKENMAXXING_SYSTEMD_USER_DIR", join(HOME, ".config", "systemd", "user")),
|
|
45
42
|
} as const;
|
|
46
43
|
|
|
44
|
+
export const claudePool = {
|
|
45
|
+
accountsJson: join(TM_HOME, "accounts.json"),
|
|
46
|
+
lastSwapJson: join(TM_HOME, "lastswap.json"),
|
|
47
|
+
lockFile: join(TM_HOME, "lock"),
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
50
|
+
export const codexPool = {
|
|
51
|
+
accountsJson: join(TM_HOME, "codex-accounts.json"),
|
|
52
|
+
lastSwapJson: join(TM_HOME, "codex-lastswap.json"),
|
|
53
|
+
lockFile: join(TM_HOME, "codex-lock"),
|
|
54
|
+
} as const;
|
|
55
|
+
|
|
56
|
+
export type PoolPaths = { accountsJson: string; lastSwapJson: string; lockFile: string };
|
|
57
|
+
|
|
47
58
|
const CODEX_HOME = env("TOKENMAXXING_CODEX_HOME", env("CODEX_HOME", join(HOME, ".codex")));
|
|
48
59
|
|
|
49
60
|
export const codexPaths = {
|
|
50
61
|
home: CODEX_HOME,
|
|
51
62
|
authJson: join(CODEX_HOME, "auth.json"),
|
|
52
63
|
hooksJson: join(CODEX_HOME, "hooks.json"),
|
|
53
|
-
accountsJson: join(TM_HOME, "codex-accounts.json"),
|
|
54
|
-
lastSwapJson: join(TM_HOME, "codex-lastswap.json"),
|
|
55
|
-
lockFile: join(TM_HOME, "codex-lock"),
|
|
56
64
|
credsDir: join(TM_HOME, "codex-creds"),
|
|
57
65
|
onboardDir: join(TM_HOME, "codex-onboard"),
|
|
58
66
|
respawnDir: join(TM_HOME, "codex-respawn"),
|
package/src/lib/picker.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { minBy, sortBy } from "es-toolkit";
|
|
1
|
+
import { maxBy, minBy, sortBy } from "es-toolkit";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { familyTokens } from "./usage.ts";
|
|
4
|
-
import { AccountSchema, ThresholdsSchema, type Account, type Config, type Thresholds, type
|
|
4
|
+
import { AccountSchema, ThresholdsSchema, type Account, type Config, type Thresholds, type Window } from "./types.ts";
|
|
5
5
|
|
|
6
6
|
export function thresholdBars(cfg: Config): Thresholds {
|
|
7
7
|
return {
|
|
@@ -13,36 +13,57 @@ export function thresholdBars(cfg: Config): Thresholds {
|
|
|
13
13
|
const PickCtxSchema = z.object({
|
|
14
14
|
now: z.number(),
|
|
15
15
|
thresholds: ThresholdsSchema,
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
currentId: z.string().nullable(),
|
|
17
|
+
families: z.array(z.string()).nullable(),
|
|
18
18
|
});
|
|
19
19
|
export type PickCtx = z.infer<typeof PickCtxSchema>;
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
22
|
+
const SESSION_WINDOW_MAX_S = 6 * 3600;
|
|
23
|
+
|
|
24
|
+
export function isSessionWindow(w: Window): boolean {
|
|
25
|
+
return w.windowSeconds != null && w.windowSeconds <= SESSION_WINDOW_MAX_S;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
export function sessionWindow(a: Account): Window | undefined {
|
|
29
|
+
return a.windows.find((w) => w.name == null && isSessionWindow(w));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function weeklyWindow(a: Account): Window | undefined {
|
|
33
|
+
return maxBy(a.windows.filter((w) => w.name == null && !isSessionWindow(w)), (w) => w.windowSeconds ?? 0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function limitWindows(a: Account): Window[] {
|
|
37
|
+
return a.windows.filter((w) => w.name != null);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function gatedWindows(a: Account, families: string[] | null): Window[] {
|
|
41
|
+
return a.windows.filter((w) => {
|
|
42
|
+
const name = w.name;
|
|
43
|
+
return name != null && (families == null || families.some((f) => familyTokens(name).includes(f)));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function liveUsed(w: Window, now: number): number {
|
|
48
|
+
if (w.resetsAt != null) return w.resetsAt <= now ? 0 : w.usedPercentage;
|
|
49
|
+
if (w.windowSeconds != null && now >= w.sampledAt + w.windowSeconds * 1000) return 0;
|
|
50
|
+
return w.usedPercentage;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function barFor(w: Window, thresholds: Thresholds): number {
|
|
54
|
+
return isSessionWindow(w) ? thresholds.session : thresholds.weekly;
|
|
55
|
+
}
|
|
29
56
|
|
|
30
|
-
function blockedUntil(w:
|
|
31
|
-
if (w.usedPercentage <
|
|
57
|
+
function blockedUntil(w: Window, bar: number): number {
|
|
58
|
+
if (w.usedPercentage < bar) return 0;
|
|
32
59
|
if (w.resetsAt != null) return w.resetsAt;
|
|
33
|
-
return
|
|
60
|
+
return w.windowSeconds != null ? w.sampledAt + w.windowSeconds * 1000 : Number.POSITIVE_INFINITY;
|
|
34
61
|
}
|
|
35
62
|
|
|
36
63
|
function blockingUntil(a: Account, ctx: PickCtx): number[] {
|
|
37
|
-
const u = a.lastUsage;
|
|
38
64
|
return [
|
|
39
|
-
...(
|
|
40
|
-
|
|
41
|
-
blockedUntil(u.fiveHour, FIVE_HOURS_MS, a.lastUsageAt, ctx.thresholds.session),
|
|
42
|
-
blockedUntil(u.sevenDay, WEEK_MS, a.lastUsageAt, ctx.thresholds.weekly),
|
|
43
|
-
]
|
|
44
|
-
: []),
|
|
45
|
-
...gatedPerModelWindows(a, ctx.switchFamilies).map((w) => blockedUntil(w, WEEK_MS, u?.rowsAt ?? a.lastUsageAt, ctx.thresholds.weekly)),
|
|
65
|
+
...a.windows.filter((w) => w.name == null).map((w) => blockedUntil(w, barFor(w, ctx.thresholds))),
|
|
66
|
+
...gatedWindows(a, ctx.families).map((w) => blockedUntil(w, barFor(w, ctx.thresholds))),
|
|
46
67
|
...(a.enforcedUntil != null ? [a.enforcedUntil] : []),
|
|
47
68
|
];
|
|
48
69
|
}
|
|
@@ -57,39 +78,37 @@ export function nextWeeklyReset(resetsAt: number | null, now: number): number |
|
|
|
57
78
|
}
|
|
58
79
|
|
|
59
80
|
export function weeklyExpiry(a: Account, now: number): number {
|
|
60
|
-
return nextWeeklyReset(a
|
|
81
|
+
return nextWeeklyReset(weeklyWindow(a)?.resetsAt ?? null, now) ?? Number.POSITIVE_INFINITY;
|
|
61
82
|
}
|
|
62
83
|
|
|
63
84
|
export function earliestReset(a: Account, now: number): number {
|
|
64
|
-
const
|
|
65
|
-
return Math.min(
|
|
85
|
+
const session = sessionWindow(a)?.resetsAt;
|
|
86
|
+
return Math.min(session != null && session > now ? session : Number.POSITIVE_INFINITY, weeklyExpiry(a, now));
|
|
66
87
|
}
|
|
67
88
|
|
|
68
89
|
export function pacePressure(a: Account, now: number): number {
|
|
69
|
-
const
|
|
70
|
-
const reset = nextWeeklyReset(
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
return Math.max(0, 100 - used) / Math.max(1, reset - now);
|
|
90
|
+
const weekly = weeklyWindow(a);
|
|
91
|
+
const reset = nextWeeklyReset(weekly?.resetsAt ?? null, now);
|
|
92
|
+
if (weekly == null || reset == null) return 0;
|
|
93
|
+
return Math.max(0, 100 - liveUsed(weekly, now)) / Math.max(1, reset - now);
|
|
74
94
|
}
|
|
75
95
|
|
|
76
96
|
const swapPreference = (ctx: PickCtx) => [
|
|
77
97
|
(a: Account) => -pacePressure(a, ctx.now),
|
|
78
98
|
(a: Account) => weeklyExpiry(a, ctx.now),
|
|
79
|
-
(a: Account) => a
|
|
99
|
+
(a: Account) => weeklyWindow(a)?.usedPercentage ?? 101,
|
|
80
100
|
];
|
|
81
101
|
|
|
82
102
|
export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
|
|
83
|
-
const usable = accounts.filter(
|
|
84
|
-
(a) => a.accountUuid !== ctx.currentAccountUuid && !a.needsReauth && !isExhausted(a, ctx),
|
|
85
|
-
);
|
|
103
|
+
const usable = accounts.filter((a) => a.id !== ctx.currentId && !a.needsReauth && !isExhausted(a, ctx));
|
|
86
104
|
return sortBy(usable, swapPreference(ctx))[0] ?? null;
|
|
87
105
|
}
|
|
88
106
|
|
|
89
|
-
export function currentWins(active: Account | null, accounts: Account[], ctx: PickCtx): boolean {
|
|
107
|
+
export function currentWins(active: Account | null, accounts: Account[], ctx: PickCtx, margin = 1): boolean {
|
|
90
108
|
if (!active || active.needsReauth || isExhausted(active, ctx)) return false;
|
|
91
|
-
const best = pickBest(accounts, { ...ctx,
|
|
92
|
-
if (best == null || best.
|
|
109
|
+
const best = pickBest(accounts, { ...ctx, currentId: null });
|
|
110
|
+
if (best == null || best.id === active.id) return true;
|
|
111
|
+
if (margin > 1) return pacePressure(best, ctx.now) <= pacePressure(active, ctx.now) * margin;
|
|
93
112
|
return swapPreference(ctx).every((k) => k(active) === k(best));
|
|
94
113
|
}
|
|
95
114
|
|
|
@@ -103,7 +122,7 @@ export type EarliestReset = z.infer<typeof EarliestResetSchema>;
|
|
|
103
122
|
|
|
104
123
|
export function pickEarliestReset(accounts: Account[], ctx: PickCtx): EarliestReset | null {
|
|
105
124
|
const mapped = accounts
|
|
106
|
-
.filter((a) => a.
|
|
125
|
+
.filter((a) => a.id !== ctx.currentId && !a.needsReauth)
|
|
107
126
|
.map((a) => ({ account: a, availableAt: usableAt(a, ctx) }))
|
|
108
127
|
.filter((x) => Number.isFinite(x.availableAt));
|
|
109
128
|
return minBy(mapped, (x) => x.availableAt) ?? null;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { PoolPaths } from "./paths.ts";
|
|
2
|
+
import type { Harvest } from "./state.ts";
|
|
3
|
+
import type { Account, Config, Window } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export type Observation = { windows: Window[]; at: number };
|
|
6
|
+
|
|
7
|
+
export type SampleReport = { ok: true; source: "statusline" | "probe" } | { ok: false; reason: string };
|
|
8
|
+
|
|
9
|
+
export type SwapFailure = "dead-grant" | "skip" | "fatal";
|
|
10
|
+
|
|
11
|
+
export type Provider = {
|
|
12
|
+
name: "claude" | "codex";
|
|
13
|
+
flag: string;
|
|
14
|
+
pool: PoolPaths;
|
|
15
|
+
waitsWhenDepleted: boolean;
|
|
16
|
+
switchMargin: number;
|
|
17
|
+
switchNote: string;
|
|
18
|
+
liveId(): string | null;
|
|
19
|
+
liveOwner(): Promise<string | null>;
|
|
20
|
+
presentIds(): Set<string>;
|
|
21
|
+
gatedFamilies(cfg: Config): string[] | null;
|
|
22
|
+
observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean }): Promise<Observation | null>;
|
|
23
|
+
samplePool(accounts: Account[], liveId: string | null, now: number): Promise<Map<string, SampleReport>>;
|
|
24
|
+
mergeWindows(next: Window[], prev: Window[]): Window[];
|
|
25
|
+
swap(target: Account): Promise<void>;
|
|
26
|
+
classifySwapError(e: unknown): SwapFailure;
|
|
27
|
+
removeCredentials(account: Account): Promise<void>;
|
|
28
|
+
login(): Promise<Harvest | null>;
|
|
29
|
+
importLive(): Promise<Harvest | null>;
|
|
30
|
+
preflight(): void;
|
|
31
|
+
install(): void;
|
|
32
|
+
loginStep(who: string): string;
|
|
33
|
+
windowLabel(name: string): string;
|
|
34
|
+
};
|