tokenmaxxing 1.19.1 → 1.21.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 +17 -6
- package/README.md +11 -5
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/switching-policy/SKILL.md +7 -3
- package/agent-plugin/skills/switching-policy/references/policy.md +4 -3
- package/package.json +1 -1
- package/src/cli/check.ts +0 -3
- package/src/cli/config.ts +0 -1
- package/src/cli/status.ts +2 -12
- package/src/cli/switch.ts +5 -1
- package/src/entries/codexstophook.ts +0 -1
- package/src/entries/stopfailurehook.ts +6 -9
- package/src/entries/stophook.ts +0 -1
- package/src/entries/supervisor.ts +1 -2
- package/src/lib/codexdecide.ts +1 -25
- package/src/lib/codexpick.ts +3 -3
- package/src/lib/codexusage.ts +0 -9
- package/src/lib/decide.ts +59 -76
- package/src/lib/oauth.ts +2 -1
- package/src/lib/paths.ts +0 -1
- package/src/lib/picker.ts +25 -5
- package/src/lib/sample.ts +25 -11
- package/src/lib/state.ts +2 -82
- package/src/lib/swap.ts +17 -1
- package/src/lib/types.ts +0 -53
- package/src/lib/usage.ts +13 -7
- package/src/lib/bankedreset.ts +0 -234
- package/src/lib/codexreset.ts +0 -122
package/src/lib/decide.ts
CHANGED
|
@@ -2,11 +2,11 @@ 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,
|
|
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";
|
|
6
6
|
import { readOAuthAccount } from "./claudejson.ts";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { currentWins, effectiveBars, hardBars, isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, sessionLadder, usableAt } from "./picker.ts";
|
|
7
|
+
import { chooseAndSwap, isSkippableSwapError, performSwap, type VerifyVerdict } from "./swap.ts";
|
|
8
|
+
import { probeParkedUsage } from "./sample.ts";
|
|
9
|
+
import { currentWins, effectiveBars, hardBars, isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, sessionLadder, usableAt, type PickCtx } from "./picker.ts";
|
|
10
10
|
import { familyTokens, gatedFamilies, probeUsage, type EnforcedClass } from "./usage.ts";
|
|
11
11
|
import { log } from "./log.ts";
|
|
12
12
|
import { AccountSchema, ModelUsageStateSchema, UsageStateSchema, type Account, type Config, type EnforcedLimit, type ModelUsageState, type Thresholds, type UsageState, type UsageWindow } from "./types.ts";
|
|
@@ -16,12 +16,13 @@ const SwapDecisionSchema = z.object({
|
|
|
16
16
|
account: AccountSchema.nullable(),
|
|
17
17
|
reason: z.string(),
|
|
18
18
|
waitUntil: z.number().optional(),
|
|
19
|
-
reset: z.boolean().optional(),
|
|
20
19
|
});
|
|
21
20
|
export type SwapDecision = z.infer<typeof SwapDecisionSchema>;
|
|
22
21
|
|
|
23
22
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
24
23
|
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
|
|
24
|
+
const VERIFY_PROBE_BUDGET = 2;
|
|
25
|
+
const VERIFY_PROBE_KILL_MS = 12_000;
|
|
25
26
|
|
|
26
27
|
function liveUsed(input: { window: UsageWindow; windowMs: number; sampledAt: number; now: number }): number {
|
|
27
28
|
const { window: w, windowMs, sampledAt, now } = input;
|
|
@@ -37,17 +38,6 @@ function capForFamily(mu: ModelUsageState, family: string, now: number): UsageWi
|
|
|
37
38
|
return maxBy(rows, (w) => liveUsed({ window: w, windowMs: WEEK_MS, sampledAt: mu.sampledAt ?? mu.ts, now }));
|
|
38
39
|
}
|
|
39
40
|
|
|
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
|
-
|
|
51
41
|
function overlayLive(accounts: Account[], u: UsageState | null, mu: ModelUsageState | null, account: string | null): Account[] {
|
|
52
42
|
return accounts.map((a) => {
|
|
53
43
|
if (account == null || a.accountUuid !== account) return a;
|
|
@@ -76,41 +66,6 @@ function needsPerModel(u: UsageState | null, cfg: Config): boolean {
|
|
|
76
66
|
return u != null && gatedFamilies(u.model, cfg.policy.switchModels).length > 0;
|
|
77
67
|
}
|
|
78
68
|
|
|
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
|
-
|
|
114
69
|
function isEngaged(u: UsageState | null, mu: ModelUsageState | null, account: string | null, bars: Thresholds, cfg: Config, now: number): boolean {
|
|
115
70
|
if (!u || !account || u.account !== account) return false;
|
|
116
71
|
return liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }) >= cfg.policy.greedySessionFloor || isOver(u, mu, account, bars, cfg, now);
|
|
@@ -146,7 +101,6 @@ async function loadFreshSnapshots(cfg: Config, account: string | null, now: numb
|
|
|
146
101
|
if (account && !probeAttempted && (!usageFresh(u, uAt, account, ttl, now) || needsPerModel(u, cfg))) {
|
|
147
102
|
const full = await probeUsage();
|
|
148
103
|
const ts = Date.now();
|
|
149
|
-
const prevMu = mu;
|
|
150
104
|
if (readOAuthAccount()?.accountUuid === account) {
|
|
151
105
|
if (full) {
|
|
152
106
|
const teed = loadUsageSnapshot();
|
|
@@ -158,7 +112,7 @@ async function loadFreshSnapshots(cfg: Config, account: string | null, now: numb
|
|
|
158
112
|
writeUsage(u);
|
|
159
113
|
uAt = ts;
|
|
160
114
|
}
|
|
161
|
-
mu = { perModel: full.perModel, account, ts, sampledAt: ts
|
|
115
|
+
mu = { perModel: full.perModel, account, ts, sampledAt: ts };
|
|
162
116
|
saveModelUsage(mu);
|
|
163
117
|
const expected = gatedFamilies(u?.model ?? null, cfg.policy.switchModels);
|
|
164
118
|
const rows = Object.keys(full.perModel);
|
|
@@ -166,7 +120,7 @@ async function loadFreshSnapshots(cfg: Config, account: string | null, now: numb
|
|
|
166
120
|
log("usage.no_permodel_row", { families: expected.join(","), rows: rows.join(",") });
|
|
167
121
|
}
|
|
168
122
|
} else {
|
|
169
|
-
mu = { perModel: mu?.account === account ? (mu?.perModel ?? {}) : {}, account, ts, sampledAt: mu?.account === account ? (mu?.sampledAt ?? mu?.ts) : undefined
|
|
123
|
+
mu = { perModel: mu?.account === account ? (mu?.perModel ?? {}) : {}, account, ts, sampledAt: mu?.account === account ? (mu?.sampledAt ?? mu?.ts) : undefined };
|
|
170
124
|
saveModelUsage(mu);
|
|
171
125
|
}
|
|
172
126
|
}
|
|
@@ -221,7 +175,6 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
221
175
|
if (tee && tee.state.account === account2 && (active.lastUsageAt == null || tee.at >= active.lastUsageAt)) {
|
|
222
176
|
active.lastUsage = { fiveHour: tee.state.fiveHour, sevenDay: tee.state.sevenDay };
|
|
223
177
|
active.lastUsageAt = tee.state.sampledAt ?? tee.at;
|
|
224
|
-
if (tee.state.sessionWindowWeeklyCost != null) active.sessionWindowWeeklyCost = tee.state.sessionWindowWeeklyCost;
|
|
225
178
|
sampled = true;
|
|
226
179
|
}
|
|
227
180
|
if (mu2 && mu2.account === account2 && Object.keys(mu2.perModel).length > 0) {
|
|
@@ -253,18 +206,60 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
253
206
|
rejected.add(candidate.accountUuid);
|
|
254
207
|
log("decide.candidate_rejected", { account: candidate.accountUuid.slice(0, 8), error: e instanceof Error ? e.message : String(e) });
|
|
255
208
|
};
|
|
209
|
+
let probeBudget = VERIFY_PROBE_BUDGET;
|
|
210
|
+
const verified = async (candidate: Account, ctx: PickCtx): Promise<VerifyVerdict> => {
|
|
211
|
+
if (candidate.lastUsageAt != null && now - candidate.lastUsageAt <= cfg.policy.usagePollTtlMs) return "go";
|
|
212
|
+
const short = candidate.accountUuid.slice(0, 8);
|
|
213
|
+
const idx2 = loadAccounts();
|
|
214
|
+
const stored = idx2.accounts.find((a) => a.accountUuid === candidate.accountUuid);
|
|
215
|
+
if (!stored) return "go";
|
|
216
|
+
if (probeBudget <= 0) {
|
|
217
|
+
log("decide.verify_budget_spent", { account: short });
|
|
218
|
+
return "go";
|
|
219
|
+
}
|
|
220
|
+
probeBudget -= 1;
|
|
221
|
+
const sample = await probeParkedUsage(stored, { retries: 0, refreshParked: false, killMs: VERIFY_PROBE_KILL_MS, signal: AbortSignal.timeout(VERIFY_PROBE_KILL_MS) });
|
|
222
|
+
if (sample.ok) {
|
|
223
|
+
const at = Date.now();
|
|
224
|
+
stored.lastUsage = { fiveHour: sample.usage.session, sevenDay: sample.usage.weekAll };
|
|
225
|
+
stored.lastUsageAt = at;
|
|
226
|
+
if (Object.keys(sample.usage.perModel).length > 0) {
|
|
227
|
+
stored.lastPerModel = sample.usage.perModel;
|
|
228
|
+
stored.lastPerModelAt = at;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (sample.ok || stored.needsReauth === true) saveAccounts(idx2);
|
|
232
|
+
if (stored.needsReauth === true) {
|
|
233
|
+
rejected.add(candidate.accountUuid);
|
|
234
|
+
log("decide.candidate_rejected", { account: short, error: "needs reauth" });
|
|
235
|
+
return "skip";
|
|
236
|
+
}
|
|
237
|
+
if (!sample.ok) {
|
|
238
|
+
log("decide.candidate_unverified", { account: short });
|
|
239
|
+
return "go";
|
|
240
|
+
}
|
|
241
|
+
log(isExhausted(stored, ctx) ? "decide.candidate_walled" : "decide.candidate_verified", {
|
|
242
|
+
account: short,
|
|
243
|
+
session: sample.usage.session.usedPercentage,
|
|
244
|
+
weekly: sample.usage.weekAll.usedPercentage,
|
|
245
|
+
});
|
|
246
|
+
return "rerank";
|
|
247
|
+
};
|
|
256
248
|
|
|
257
249
|
const greedy = async (holdMargin: number): Promise<SwapDecision> => {
|
|
258
250
|
while (true) {
|
|
259
251
|
const cur = loadAccounts();
|
|
260
252
|
const active = seatOf(cur);
|
|
261
|
-
const
|
|
262
|
-
const
|
|
253
|
+
const ctxAll = { now, thresholds: barsOf(cur.accounts), currentAccountUuid: null, currentOrganizationUuid: active?.organizationUuid ?? null, orgAffinityFloor: cfg.policy.greedySessionFloor, switchFamilies, holdMargin };
|
|
254
|
+
const seatOrg = active != null && !active.needsReauth && !isExhausted(active, ctxAll) ? active.organizationUuid : null;
|
|
255
|
+
const pool = usable(cur.accounts).filter((a) => seatOrg == null || a.organizationUuid === seatOrg);
|
|
263
256
|
if (currentWins(active, pool, ctxAll)) {
|
|
264
257
|
return { swapped: false, account: null, reason: "current-best" };
|
|
265
258
|
}
|
|
266
|
-
const
|
|
259
|
+
const ctx = { ...ctxAll, currentAccountUuid: active?.accountUuid ?? null };
|
|
260
|
+
const best = pickBest(pool, ctx);
|
|
267
261
|
if (!best) return { swapped: false, account: null, reason: "no-usable-target" };
|
|
262
|
+
if ((await verified(best, ctx)) !== "go") continue;
|
|
268
263
|
try {
|
|
269
264
|
await performSwap(best);
|
|
270
265
|
} catch (e) {
|
|
@@ -277,34 +272,23 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
277
272
|
};
|
|
278
273
|
if (!enforced2 && !isOver(u2, mu2, account2, bars, cfg, now)) return greedy(cfg.policy.greedySwapMargin);
|
|
279
274
|
|
|
280
|
-
const resetPreferred = cfg.policy.preferToUseBankedReset.includes("claude");
|
|
281
|
-
let resetTried = false;
|
|
282
275
|
while (true) {
|
|
283
276
|
const cur = loadAccounts();
|
|
284
277
|
const seat = seatOf(cur);
|
|
285
278
|
const pool = usable(cur.accounts);
|
|
286
279
|
const thresholds = barsOf(cur.accounts);
|
|
287
280
|
if (!enforced2 && seat && !seat.needsReauth && !isExhausted(seat, { now, thresholds, currentAccountUuid: null, switchFamilies })) return greedy(0);
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
}
|
|
300
|
-
const best = pickBest(pool, { now, thresholds, currentAccountUuid: seat?.accountUuid ?? null, switchFamilies });
|
|
281
|
+
const ctx = { now, thresholds, currentAccountUuid: seat?.accountUuid ?? null, currentOrganizationUuid: seat?.organizationUuid ?? null, orgAffinityFloor: cfg.policy.greedySessionFloor, switchFamilies };
|
|
282
|
+
const best = pickBest(pool, ctx);
|
|
301
283
|
if (!best) break;
|
|
284
|
+
if ((await verified(best, ctx)) !== "go") continue;
|
|
302
285
|
try {
|
|
303
286
|
await performSwap(best);
|
|
304
287
|
} catch (e) {
|
|
305
288
|
skipOrThrow(e, best);
|
|
306
289
|
continue;
|
|
307
290
|
}
|
|
291
|
+
log("decide.hard_swap", { account: best.accountUuid.slice(0, 8), enforced: enforced2 != null });
|
|
308
292
|
return { swapped: true, account: best, reason: "swapped" };
|
|
309
293
|
}
|
|
310
294
|
|
|
@@ -314,7 +298,7 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
314
298
|
log("decide.last_drop_hold", { account: seat.accountUuid.slice(0, 8) });
|
|
315
299
|
return { swapped: false, account: null, reason: "last-drop-hold" };
|
|
316
300
|
}
|
|
317
|
-
const squeezed = await chooseAndSwap({ ...hardCtx, currentAccountUuid: seat?.accountUuid ?? null }, rejected);
|
|
301
|
+
const squeezed = await chooseAndSwap({ ...hardCtx, currentAccountUuid: seat?.accountUuid ?? null, currentOrganizationUuid: seat?.organizationUuid ?? null, orgAffinityFloor: cfg.policy.greedySessionFloor }, rejected, verified);
|
|
318
302
|
if (squeezed) {
|
|
319
303
|
log("decide.last_drop_swap", { account: squeezed.accountUuid.slice(0, 8) });
|
|
320
304
|
return { swapped: true, account: squeezed, reason: "last-drop-swap" };
|
|
@@ -404,12 +388,11 @@ export async function recordEnforcedLimit(input: { limit: EnforcedClass; account
|
|
|
404
388
|
account: accountUuid,
|
|
405
389
|
ts: now,
|
|
406
390
|
sampledAt: resetsAt == null ? now : sampledAt ?? now,
|
|
407
|
-
...measureFamilyWindow(muSame, accountUuid, null, {}),
|
|
408
391
|
});
|
|
409
392
|
log("usage.enforced_limit", { kind: limit.kind, family: limit.family, resetsAt });
|
|
410
393
|
return { outcome: "stamped", resetsAt };
|
|
411
394
|
}
|
|
412
|
-
saveModelUsage({ perModel: carriedRows, account: accountUuid, ts: now, sampledAt
|
|
395
|
+
saveModelUsage({ perModel: carriedRows, account: accountUuid, ts: now, sampledAt });
|
|
413
396
|
if (account && limit.resetsAt != null) {
|
|
414
397
|
account.enforcedUntil = limit.resetsAt;
|
|
415
398
|
saveAccounts(idx);
|
package/src/lib/oauth.ts
CHANGED
|
@@ -94,11 +94,12 @@ export function isAccessTokenExpiring(creds: OAuthCreds, skewMs = 120_000, now =
|
|
|
94
94
|
return !creds.expiresAt || creds.expiresAt - now <= skewMs;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
export async function fetchTokenIdentity(accessToken: string): Promise<TokenIdentity> {
|
|
97
|
+
export async function fetchTokenIdentity(accessToken: string, signal?: AbortSignal): Promise<TokenIdentity> {
|
|
98
98
|
let res: Response;
|
|
99
99
|
try {
|
|
100
100
|
res = await http.get(PROFILE_URL, {
|
|
101
101
|
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
|
|
102
|
+
signal,
|
|
102
103
|
});
|
|
103
104
|
} catch (e) {
|
|
104
105
|
throw new IdentityUnavailableError(null, e instanceof Error ? e.message : String(e));
|
package/src/lib/paths.ts
CHANGED
|
@@ -19,7 +19,6 @@ 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"),
|
|
23
22
|
depletedJson: join(TM_HOME, "depleted.json"),
|
|
24
23
|
nextCheckJson: join(TM_HOME, "nextcheck.json"),
|
|
25
24
|
respawnDir: join(TM_HOME, "respawn"),
|
package/src/lib/picker.ts
CHANGED
|
@@ -34,6 +34,8 @@ const PickCtxSchema = z.object({
|
|
|
34
34
|
now: z.number(),
|
|
35
35
|
thresholds: ThresholdsSchema,
|
|
36
36
|
currentAccountUuid: z.string().nullable(),
|
|
37
|
+
currentOrganizationUuid: z.string().nullable().optional(),
|
|
38
|
+
orgAffinityFloor: z.number().optional(),
|
|
37
39
|
switchFamilies: z.array(z.string()),
|
|
38
40
|
holdMargin: z.number().min(0).optional(),
|
|
39
41
|
});
|
|
@@ -94,9 +96,27 @@ export function pacePressure(a: Account, now: number): number {
|
|
|
94
96
|
return Math.max(0, 100 - used) / Math.max(1, reset - now);
|
|
95
97
|
}
|
|
96
98
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
(
|
|
99
|
+
function sessionUsed(a: Account, now: number): number {
|
|
100
|
+
const w = a.lastUsage?.fiveHour;
|
|
101
|
+
if (w == null) return 101;
|
|
102
|
+
if (w.resetsAt != null) return w.resetsAt <= now ? 0 : w.usedPercentage;
|
|
103
|
+
if (a.lastUsageAt != null && now >= a.lastUsageAt + FIVE_HOURS_MS) return 0;
|
|
104
|
+
return w.usedPercentage;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function orgTier(a: Account, ctx: PickCtx): number {
|
|
108
|
+
if (ctx.currentOrganizationUuid == null || a.organizationUuid !== ctx.currentOrganizationUuid) return 1;
|
|
109
|
+
if (ctx.orgAffinityFloor != null && sessionUsed(a, ctx.now) >= ctx.orgAffinityFloor) return 1;
|
|
110
|
+
if (!a.lastUsage || ctx.switchFamilies.some((family) => gatedPerModelWindows(a, [family]).length === 0)) return 1;
|
|
111
|
+
const headroom = Math.max(1, ctx.thresholds.session - (ctx.orgAffinityFloor ?? ctx.thresholds.session));
|
|
112
|
+
if (isExhausted(a, { ...ctx, thresholds: { session: ctx.thresholds.session - headroom, weekly: ctx.thresholds.weekly - headroom } })) return 1;
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const swapPreference = (ctx: PickCtx) => [
|
|
117
|
+
(a: Account) => orgTier(a, ctx),
|
|
118
|
+
(a: Account) => -pacePressure(a, ctx.now),
|
|
119
|
+
(a: Account) => weeklyExpiry(a, ctx.now),
|
|
100
120
|
(a: Account) => a.lastUsage?.sevenDay.usedPercentage ?? 101,
|
|
101
121
|
];
|
|
102
122
|
|
|
@@ -104,7 +124,7 @@ export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
|
|
|
104
124
|
const usable = accounts.filter(
|
|
105
125
|
(a) => a.accountUuid !== ctx.currentAccountUuid && !a.needsReauth && !isExhausted(a, ctx),
|
|
106
126
|
);
|
|
107
|
-
return sortBy(usable, swapPreference(ctx
|
|
127
|
+
return sortBy(usable, swapPreference(ctx))[0] ?? null;
|
|
108
128
|
}
|
|
109
129
|
|
|
110
130
|
export function currentWins(active: Account | null, accounts: Account[], ctx: PickCtx): boolean {
|
|
@@ -114,7 +134,7 @@ export function currentWins(active: Account | null, accounts: Account[], ctx: Pi
|
|
|
114
134
|
const margin = ctx.holdMargin ?? 0;
|
|
115
135
|
const bestPace = pacePressure(best, ctx.now);
|
|
116
136
|
if (margin > 0 && bestPace > 0 && bestPace <= pacePressure(active, ctx.now) * (1 + margin)) return true;
|
|
117
|
-
return swapPreference(ctx
|
|
137
|
+
return swapPreference(ctx).every((k) => k(active) === k(best));
|
|
118
138
|
}
|
|
119
139
|
|
|
120
140
|
export function usableAt(a: Account, ctx: PickCtx): number {
|
package/src/lib/sample.ts
CHANGED
|
@@ -24,10 +24,10 @@ const IdentityCheckSchema = z.discriminatedUnion("status", [
|
|
|
24
24
|
]);
|
|
25
25
|
type IdentityCheck = z.infer<typeof IdentityCheckSchema>;
|
|
26
26
|
|
|
27
|
-
async function checkIdentity(creds: OAuthCreds, account: Account): Promise<IdentityCheck> {
|
|
27
|
+
async function checkIdentity(creds: OAuthCreds, account: Account, signal?: AbortSignal): Promise<IdentityCheck> {
|
|
28
28
|
let identity: TokenIdentity;
|
|
29
29
|
try {
|
|
30
|
-
identity = await fetchTokenIdentity(creds.accessToken);
|
|
30
|
+
identity = await fetchTokenIdentity(creds.accessToken, signal);
|
|
31
31
|
} catch (e) {
|
|
32
32
|
return {
|
|
33
33
|
status: "unavailable",
|
|
@@ -44,7 +44,10 @@ function refreshPlanFields(account: Account, creds: OAuthCreds): void {
|
|
|
44
44
|
if (creds.rateLimitTier != null) account.rateLimitTier = creds.rateLimitTier;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
export async function probeParkedUsage(
|
|
47
|
+
export async function probeParkedUsage(
|
|
48
|
+
account: Account,
|
|
49
|
+
opts: { ping?: boolean; retries?: number; refreshParked?: boolean; killMs?: number; signal?: AbortSignal } = {},
|
|
50
|
+
): Promise<SampleOutcome> {
|
|
48
51
|
const backup = parkedTarget(account.keychainItem);
|
|
49
52
|
const parkedRaw = await readItem(backup);
|
|
50
53
|
if (!parkedRaw) return { ok: false, reason: "no parked credential - run `tokenmaxxing auth`" };
|
|
@@ -59,6 +62,9 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
59
62
|
account.needsReauth = true;
|
|
60
63
|
return { ok: false, reason: "parked credential was cleared after a failed refresh - re-auth with `tokenmaxxing auth`" };
|
|
61
64
|
}
|
|
65
|
+
if (opts.refreshParked === false && isAccessTokenExpiring(creds, 300_000)) {
|
|
66
|
+
return { ok: false, reason: "parked access token is near expiry - verification requires an unexpired token" };
|
|
67
|
+
}
|
|
62
68
|
|
|
63
69
|
const liveRaw = await readItem(liveTarget());
|
|
64
70
|
let liveAccount: string | null = null;
|
|
@@ -73,7 +79,7 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
73
79
|
if (!isDeadCredential(liveCreds)) {
|
|
74
80
|
liveToken = liveCreds.accessToken;
|
|
75
81
|
try {
|
|
76
|
-
liveAccount = (await fetchTokenIdentity(liveCreds.accessToken)).accountUuid;
|
|
82
|
+
liveAccount = (await fetchTokenIdentity(liveCreds.accessToken, opts.signal)).accountUuid;
|
|
77
83
|
} catch (e) {
|
|
78
84
|
return { ok: false, reason: `cannot verify the live credential's owner (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - refusing to sample a possibly-live account` };
|
|
79
85
|
}
|
|
@@ -92,6 +98,9 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
92
98
|
};
|
|
93
99
|
|
|
94
100
|
if (isAccessTokenExpiring(creds, 300_000)) {
|
|
101
|
+
if (opts.refreshParked === false) {
|
|
102
|
+
return { ok: false, reason: "parked access token is near expiry - verification requires an unexpired token" };
|
|
103
|
+
}
|
|
95
104
|
const owner = await checkIdentity(creds, account);
|
|
96
105
|
if (owner.status === "mismatch") {
|
|
97
106
|
const kept = await relocate(owner.owner);
|
|
@@ -136,7 +145,7 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
136
145
|
await writeItem(backup, JSON.stringify({ claudeAiOauth: creds }));
|
|
137
146
|
}
|
|
138
147
|
|
|
139
|
-
const identity = await checkIdentity(creds, account);
|
|
148
|
+
const identity = await checkIdentity(creds, account, opts.signal);
|
|
140
149
|
if (identity.status === "mismatch") {
|
|
141
150
|
const kept = await relocate(identity.owner);
|
|
142
151
|
account.needsReauth = true;
|
|
@@ -151,13 +160,13 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
151
160
|
rmSync(dir, { recursive: true, force: true });
|
|
152
161
|
mkdirSync(dir, { recursive: true });
|
|
153
162
|
const isoTarget = isolatedTarget(dir);
|
|
154
|
-
const installed = JSON.stringify({ claudeAiOauth: creds });
|
|
163
|
+
const installed = JSON.stringify({ claudeAiOauth: opts.refreshParked === false ? { ...creds, refreshToken: "" } : creds });
|
|
155
164
|
|
|
156
165
|
try {
|
|
157
166
|
await writeItem(isoTarget, installed);
|
|
158
167
|
writeFileSync(join(dir, ".claude.json"), JSON.stringify({ oauthAccount: account.oauthAccount, hasCompletedOnboarding: true }));
|
|
159
168
|
const ping = opts.ping ? await pingSession(dir) : null;
|
|
160
|
-
const usage = await probeUsage(dir);
|
|
169
|
+
const usage = await probeUsage(dir, Date.now(), opts);
|
|
161
170
|
const outcome: SampleOutcome = usage
|
|
162
171
|
? { ok: true, usage }
|
|
163
172
|
: { ok: false, reason: "`/usage` returned no limit data (see log)" };
|
|
@@ -167,10 +176,15 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
167
176
|
}
|
|
168
177
|
return outcome;
|
|
169
178
|
} finally {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
179
|
+
try {
|
|
180
|
+
if (opts.refreshParked !== false) {
|
|
181
|
+
const afterIso = await readItem(isoTarget);
|
|
182
|
+
if (afterIso && afterIso !== installed) await writeItem(backup, claudeAiOauthOnly(afterIso));
|
|
183
|
+
}
|
|
184
|
+
} finally {
|
|
185
|
+
await deleteItem(isoTarget);
|
|
186
|
+
rmSync(dir, { recursive: true, force: true });
|
|
187
|
+
}
|
|
174
188
|
}
|
|
175
189
|
}
|
|
176
190
|
|
package/src/lib/state.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { closeSync, existsSync, fstatSync, openSync, readFileSync, rmSync, utimesSync } from "node:fs";
|
|
2
|
-
import { isEqual
|
|
2
|
+
import { isEqual } 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,
|
|
9
8
|
ConfigSchema,
|
|
10
9
|
LastSwapSchema,
|
|
11
10
|
ModelUsageStateSchema,
|
|
@@ -14,11 +13,8 @@ import {
|
|
|
14
13
|
UsageStateSchema,
|
|
15
14
|
type AccountsIndex,
|
|
16
15
|
type Config,
|
|
17
|
-
type FamilyAnchor,
|
|
18
16
|
type ModelUsageState,
|
|
19
17
|
type UsageState,
|
|
20
|
-
type UsageWindow,
|
|
21
|
-
type WindowAnchor,
|
|
22
18
|
} from "./types.ts";
|
|
23
19
|
|
|
24
20
|
const DEFAULT_CONFIG: Config = {
|
|
@@ -34,7 +30,6 @@ const DEFAULT_CONFIG: Config = {
|
|
|
34
30
|
usagePollTtlMs: 90_000,
|
|
35
31
|
maxWaitMs: 3_600_000,
|
|
36
32
|
checkIntervalMs: 60_000,
|
|
37
|
-
preferToUseBankedReset: [],
|
|
38
33
|
},
|
|
39
34
|
};
|
|
40
35
|
|
|
@@ -55,7 +50,6 @@ export const ConfigFileSchema = z
|
|
|
55
50
|
usagePollTtlMs: z.number().int().positive(),
|
|
56
51
|
maxWaitMs: z.number().int().positive(),
|
|
57
52
|
checkIntervalMs: z.number().int().min(10_000),
|
|
58
|
-
preferToUseBankedReset: BankedResetProvidersSchema,
|
|
59
53
|
})
|
|
60
54
|
.partial(),
|
|
61
55
|
})
|
|
@@ -89,9 +83,6 @@ export function mergeConfigFile(p: z.infer<typeof ConfigFileSchema>): MergeOutco
|
|
|
89
83
|
if (p.policy?.switchModels) {
|
|
90
84
|
cfg.policy.switchModels = p.policy.switchModels.map((s) => s.toLowerCase());
|
|
91
85
|
}
|
|
92
|
-
if (p.policy?.preferToUseBankedReset) {
|
|
93
|
-
cfg.policy.preferToUseBankedReset = uniq(p.policy.preferToUseBankedReset);
|
|
94
|
-
}
|
|
95
86
|
const envBin = realClaudeBinFromEnv();
|
|
96
87
|
if (envBin) cfg.claudeBin = envBin;
|
|
97
88
|
const envCodexBin = realCodexBinFromEnv();
|
|
@@ -196,29 +187,6 @@ export function saveLastSwapAt(ts: number): void {
|
|
|
196
187
|
writeFileAtomic(paths.lastSwapJson, JSON.stringify(LastSwapSchema.parse({ ts })));
|
|
197
188
|
}
|
|
198
189
|
|
|
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
|
-
|
|
222
190
|
const DepletedWaitSchema = z.object({ waitUntil: z.number(), accountUuid: z.string(), ts: z.number() });
|
|
223
191
|
export type DepletedWait = z.infer<typeof DepletedWaitSchema>;
|
|
224
192
|
|
|
@@ -270,61 +238,13 @@ export function clearNextCheck(): void {
|
|
|
270
238
|
|
|
271
239
|
const USAGE_TS_REFRESH_MS = 10 * 60_000;
|
|
272
240
|
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
|
-
}
|
|
321
241
|
|
|
322
242
|
export function writeUsage(input: UsageState, opts: { stamp?: boolean } = {}): boolean {
|
|
323
243
|
const prev = loadUsage();
|
|
324
244
|
const stamp = opts.stamp === true;
|
|
325
245
|
const carriedSampleAt = prev != null && prev.account === input.account ? (prev.sampledAt ?? prev.ts) : undefined;
|
|
326
246
|
const sampledAt = stamp ? (carriedSampleAt ?? input.sampledAt) : input.ts;
|
|
327
|
-
const next: UsageState = { ...input, ...
|
|
247
|
+
const next: UsageState = { ...input, ...(sampledAt != null ? { sampledAt } : {}) };
|
|
328
248
|
if (
|
|
329
249
|
prev &&
|
|
330
250
|
isEqual({ ...prev, ts: 0, sampledAt: 0 }, { ...next, ts: 0, sampledAt: 0 }) &&
|
package/src/lib/swap.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
1
2
|
import { clearDepletedWait, clearNextCheck, clearUsageSnapshots, loadAccounts, saveAccounts, saveLastSwapAt } from "./state.ts";
|
|
2
3
|
import { readItem, writeItem, liveTarget, parkedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
|
|
3
4
|
import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, InvalidGrantError, RefreshRejectedError } from "./oauth.ts";
|
|
@@ -11,6 +12,9 @@ function parseBlob(raw: string) {
|
|
|
11
12
|
return CredentialBlobSchema.parse(JSON.parse(raw));
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
const VerifyVerdictSchema = z.enum(["go", "rerank", "skip"]);
|
|
16
|
+
export type VerifyVerdict = z.infer<typeof VerifyVerdictSchema>;
|
|
17
|
+
|
|
14
18
|
export async function performSwap(target: Account): Promise<void> {
|
|
15
19
|
const idx = loadAccounts();
|
|
16
20
|
|
|
@@ -196,13 +200,25 @@ export function isSkippableSwapError(e: unknown): boolean {
|
|
|
196
200
|
return e instanceof InvalidGrantError || e instanceof RefreshRejectedError || e instanceof IdentityUnavailableError;
|
|
197
201
|
}
|
|
198
202
|
|
|
199
|
-
export async function chooseAndSwap(
|
|
203
|
+
export async function chooseAndSwap(
|
|
204
|
+
ctx: PickCtx,
|
|
205
|
+
exclude: ReadonlySet<string> = new Set(),
|
|
206
|
+
verify?: (candidate: Account, ctx: PickCtx) => Promise<VerifyVerdict>,
|
|
207
|
+
): Promise<Account | null> {
|
|
200
208
|
const tried = new Set<string>(exclude);
|
|
201
209
|
while (true) {
|
|
202
210
|
const idx = loadAccounts();
|
|
203
211
|
const candidates = idx.accounts.filter((a) => !tried.has(a.accountUuid));
|
|
204
212
|
const best = pickBest(candidates, ctx);
|
|
205
213
|
if (!best) return null;
|
|
214
|
+
if (verify) {
|
|
215
|
+
const verdict = await verify(best, ctx);
|
|
216
|
+
if (verdict === "rerank") continue;
|
|
217
|
+
if (verdict === "skip") {
|
|
218
|
+
tried.add(best.accountUuid);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
206
222
|
tried.add(best.accountUuid);
|
|
207
223
|
try {
|
|
208
224
|
await performSwap(best);
|