tokenmaxxing 1.34.0 → 1.38.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.
Files changed (54) hide show
  1. package/DESIGN.md +3 -5
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/cli/add.ts +23 -40
  5. package/src/cli/auth.ts +38 -52
  6. package/src/cli/check.ts +2 -1
  7. package/src/cli/doctor.ts +13 -13
  8. package/src/cli/init.ts +29 -126
  9. package/src/cli/rename.ts +11 -45
  10. package/src/cli/render.ts +0 -7
  11. package/src/cli/rm.ts +24 -39
  12. package/src/cli/setuptoken.ts +16 -16
  13. package/src/cli/status.ts +95 -238
  14. package/src/cli/switch.ts +54 -46
  15. package/src/entries/codexstophook.ts +23 -23
  16. package/src/entries/codexsupervisor.ts +3 -3
  17. package/src/entries/sessionstart.ts +5 -3
  18. package/src/entries/statusline.ts +17 -19
  19. package/src/entries/stopfailurehook.ts +9 -7
  20. package/src/entries/stophook.ts +5 -4
  21. package/src/entries/subagentstatusline.ts +2 -7
  22. package/src/lib/atomic.ts +2 -8
  23. package/src/lib/claude.ts +320 -0
  24. package/src/lib/claudelock.ts +2 -3
  25. package/src/lib/codex.ts +345 -0
  26. package/src/lib/codexauth.ts +9 -7
  27. package/src/lib/codexoauth.ts +5 -11
  28. package/src/lib/codexpresence.ts +3 -19
  29. package/src/lib/codexusage.ts +17 -41
  30. package/src/lib/credstore.ts +3 -7
  31. package/src/lib/decide.ts +86 -174
  32. package/src/lib/http.ts +3 -6
  33. package/src/lib/install.ts +4 -7
  34. package/src/lib/oauth.ts +14 -12
  35. package/src/lib/paths.ts +15 -7
  36. package/src/lib/picker.ts +56 -37
  37. package/src/lib/proc.ts +4 -4
  38. package/src/lib/provider.ts +34 -0
  39. package/src/lib/sample.ts +33 -33
  40. package/src/lib/state.ts +51 -18
  41. package/src/lib/swap.ts +41 -38
  42. package/src/lib/types.ts +31 -51
  43. package/src/lib/usage.ts +28 -20
  44. package/src/main.ts +20 -20
  45. package/src/cli/codexadd.ts +0 -89
  46. package/src/cli/codexinit.ts +0 -120
  47. package/src/cli/codexrm.ts +0 -36
  48. package/src/cli/codexswitch.ts +0 -113
  49. package/src/cli/onboard.ts +0 -92
  50. package/src/lib/codexdecide.ts +0 -157
  51. package/src/lib/codexpick.ts +0 -80
  52. package/src/lib/codexsample.ts +0 -48
  53. package/src/lib/codexstate.ts +0 -22
  54. package/src/lib/codexswap.ts +0 -70
@@ -3,18 +3,14 @@ import { join } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { writeFileAtomic } from "./atomic.ts";
5
5
  import { codexPaths } from "./paths.ts";
6
- import { CodexAuthJsonSchema, type CodexAuthJson } from "./types.ts";
7
-
8
- function isEnoent(e: unknown): boolean {
9
- return e instanceof Error && "code" in e && e.code === "ENOENT";
10
- }
6
+ import { CodexAuthJsonSchema, ErrnoSchema, type CodexAuthJson } from "./types.ts";
11
7
 
12
8
  export function readCodexAuthAt(input: { path: string }): CodexAuthJson | null {
13
9
  let raw: string;
14
10
  try {
15
11
  raw = readFileSync(input.path, "utf8");
16
12
  } catch (e) {
17
- if (isEnoent(e)) return null;
13
+ if (ErrnoSchema.safeParse(e).data?.code === "ENOENT") return null;
18
14
  throw e;
19
15
  }
20
16
  const parsed = JSON.parse(raw);
@@ -27,6 +23,12 @@ export function readLiveCodexAuth(): CodexAuthJson | null {
27
23
  return readCodexAuthAt({ path: codexPaths.authJson });
28
24
  }
29
25
 
26
+ export function liveCodexAccountId(): string | null {
27
+ const live = readLiveCodexAuth();
28
+ if (!live) return null;
29
+ return codexIdentityOf({ auth: live }).accountId;
30
+ }
31
+
30
32
  export function writeLiveCodexAuth(input: { auth: CodexAuthJson }): void {
31
33
  writeFileAtomic(codexPaths.authJson, JSON.stringify(CodexAuthJsonSchema.parse(input.auth), null, 2), 0o600);
32
34
  }
@@ -40,7 +42,7 @@ export function readParkedCodexAuth(input: { credFile: string }): CodexAuthJson
40
42
  try {
41
43
  raw = readFileSync(parkedPath(input), "utf8");
42
44
  } catch (e) {
43
- if (isEnoent(e)) return null;
45
+ if (ErrnoSchema.safeParse(e).data?.code === "ENOENT") return null;
44
46
  throw e;
45
47
  }
46
48
  return CodexAuthJsonSchema.parse(JSON.parse(raw));
@@ -1,10 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { http, oauthErrorCode, safeErrorDetail } from "./http.ts";
3
- import { CodexAuthJsonSchema, type CodexAuthJson } from "./types.ts";
3
+ import { env } from "./paths.ts";
4
+ import { CodexAuthJsonSchema, JsonTextSchema, type CodexAuthJson } from "./types.ts";
4
5
 
5
- const EnvOverrideSchema = z.string().min(1).optional().catch(undefined);
6
- const TOKEN_URL = EnvOverrideSchema.parse(process.env.TOKENMAXXING_CODEX_TOKEN_URL) ?? "https://auth.openai.com/oauth/token";
7
- const CLIENT_ID = EnvOverrideSchema.parse(process.env.TOKENMAXXING_CODEX_CLIENT_ID) ?? "app_EMoamEEZ73f0CkXaXp7hrann";
6
+ const TOKEN_URL = env("TOKENMAXXING_CODEX_TOKEN_URL", "https://auth.openai.com/oauth/token");
7
+ const CLIENT_ID = env("TOKENMAXXING_CODEX_CLIENT_ID", "app_EMoamEEZ73f0CkXaXp7hrann");
8
8
 
9
9
  export class CodexInvalidGrantError extends Error {
10
10
  constructor(detail: string) {
@@ -60,13 +60,7 @@ export async function refreshCodexAuth(input: { auth: CodexAuthJson; now?: numbe
60
60
  throw new CodexRefreshFailedError(`HTTP ${res.status}: ${detail}`);
61
61
  }
62
62
 
63
- const parsed = CodexRefreshResponseSchema.safeParse((() => {
64
- try {
65
- return JSON.parse(text);
66
- } catch {
67
- return null;
68
- }
69
- })());
63
+ const parsed = CodexRefreshResponseSchema.safeParse(JsonTextSchema.safeParse(text).data);
70
64
  if (!parsed.success) {
71
65
  throw new CodexRefreshFailedError("endpoint returned an unexpected body shape (withheld: may carry tokens)");
72
66
  }
@@ -4,6 +4,7 @@ import { z } from "zod";
4
4
  import { codexPaths } from "./paths.ts";
5
5
  import { writeFileAtomic } from "./atomic.ts";
6
6
  import { pidExists, pidStartTime } from "./proc.ts";
7
+ import { ErrnoSchema, JsonTextSchema } from "./types.ts";
7
8
 
8
9
  const PresenceSchema = z.object({
9
10
  accountId: z.string(),
@@ -38,17 +39,10 @@ export function livingCodexPresences(): LivingPresence[] {
38
39
  try {
39
40
  raw = readFileSync(file, "utf8");
40
41
  } catch (e) {
41
- const errno = z.object({ code: z.string() }).safeParse(e);
42
- if (errno.success && errno.data.code === "ENOENT") continue;
42
+ if (ErrnoSchema.safeParse(e).data?.code === "ENOENT") continue;
43
43
  throw e;
44
44
  }
45
- const parsed = PresenceSchema.safeParse((() => {
46
- try {
47
- return JSON.parse(raw);
48
- } catch {
49
- return null;
50
- }
51
- })());
45
+ const parsed = PresenceSchema.safeParse(JsonTextSchema.safeParse(raw).data);
52
46
  if (!parsed.success) {
53
47
  throw new Error(`${file} is not a readable presence record - it may belong to a RUNNING codex session, refusing to treat it as absent; remove the file (or respawn that session) to proceed`);
54
48
  }
@@ -68,13 +62,3 @@ export function livingCodexPresences(): LivingPresence[] {
68
62
  export function presentCodexAccountIds(): Set<string> {
69
63
  return new Set(livingCodexPresences().map((presence) => presence.accountId));
70
64
  }
71
-
72
- export function targetableCodexAccounts<T extends { accountId: string }>(input: {
73
- accounts: T[];
74
- activeAccountId: string | null;
75
- }): T[] {
76
- const present = presentCodexAccountIds();
77
- return input.accounts.filter(
78
- (account) => account.accountId === input.activeAccountId || !present.has(account.accountId),
79
- );
80
- }
@@ -1,11 +1,11 @@
1
1
  import { z } from "zod";
2
2
  import { http, safeErrorDetail } from "./http.ts";
3
- import { CodexUsageSchema, type CodexAuthJson, type CodexUsage, type CodexWindow } from "./types.ts";
3
+ import { env } from "./paths.ts";
4
+ import { CodexUsageSchema, JsonTextSchema, type CodexAuthJson, type CodexUsage, type Window } from "./types.ts";
4
5
  import { codexIdentityOf } from "./codexauth.ts";
5
6
  import { familyTokens } from "./usage.ts";
6
7
 
7
- const EnvOverrideSchema = z.string().min(1).optional().catch(undefined);
8
- const USAGE_URL = EnvOverrideSchema.parse(process.env.TOKENMAXXING_CODEX_USAGE_URL) ?? "https://chatgpt.com/backend-api/wham/usage";
8
+ const USAGE_URL = env("TOKENMAXXING_CODEX_USAGE_URL", "https://chatgpt.com/backend-api/wham/usage");
9
9
 
10
10
  export class CodexUsageReadError extends Error {
11
11
  constructor(detail: string) {
@@ -35,21 +35,23 @@ const WireUsageSchema = z.looseObject({
35
35
  .nullish(),
36
36
  });
37
37
 
38
- function toWindows(rateLimit: z.infer<typeof WireRateLimitSchema> | null | undefined): CodexWindow[] {
39
- const out: CodexWindow[] = [];
38
+ function toWindows(rateLimit: z.infer<typeof WireRateLimitSchema> | null | undefined, name: string | null, at: number): Window[] {
39
+ const out: Window[] = [];
40
40
  for (const wire of [rateLimit?.primary_window, rateLimit?.secondary_window]) {
41
41
  if (wire == null) continue;
42
42
  out.push({
43
+ name,
43
44
  usedPercentage: wire.used_percent,
44
45
  resetsAt: wire.reset_at != null ? wire.reset_at * 1000 : null,
45
46
  windowSeconds: wire.limit_window_seconds ?? null,
47
+ sampledAt: at,
46
48
  });
47
49
  }
48
50
  return out;
49
51
  }
50
52
 
51
- export async function fetchCodexUsage(input: { auth: CodexAuthJson }): Promise<CodexUsage> {
52
- const { auth } = input;
53
+ export async function fetchCodexUsage(input: { auth: CodexAuthJson; at: number }): Promise<CodexUsage> {
54
+ const { auth, at } = input;
53
55
  const identity = codexIdentityOf({ auth });
54
56
  let res: Response;
55
57
  try {
@@ -68,53 +70,27 @@ export async function fetchCodexUsage(input: { auth: CodexAuthJson }): Promise<C
68
70
  if (!res.ok) {
69
71
  throw new CodexUsageReadError(`HTTP ${res.status}: ${safeErrorDetail({ text })}`);
70
72
  }
71
- const parsed = WireUsageSchema.safeParse((() => {
72
- try {
73
- return JSON.parse(text);
74
- } catch {
75
- return null;
76
- }
77
- })());
73
+ const parsed = WireUsageSchema.safeParse(JsonTextSchema.safeParse(text).data);
78
74
  if (!parsed.success) {
79
75
  throw new CodexUsageReadError("endpoint returned an unexpected body shape (withheld)");
80
76
  }
81
77
  const wire = parsed.data;
82
78
 
83
- const perLimit: Record<string, CodexWindow[]> = {};
84
- for (const row of wire.additional_rate_limits ?? []) {
85
- const windows = toWindows(row.rate_limit);
86
- if (windows.length > 0) perLimit[row.limit_name] = windows;
87
- }
88
-
89
79
  return CodexUsageSchema.parse({
90
80
  accountId: wire.account_id,
91
81
  email: wire.email ?? null,
92
82
  planType: wire.plan_type ?? null,
93
- aggregate: toWindows(wire.rate_limit),
94
- perLimit,
83
+ windows: [
84
+ ...toWindows(wire.rate_limit, null, at),
85
+ ...(wire.additional_rate_limits ?? []).flatMap((row) => toWindows(row.rate_limit, row.limit_name, at)),
86
+ ],
95
87
  });
96
88
  }
97
89
 
98
90
  const LIMIT_LABEL_ABBREVIATIONS = new Map([["reserve", "rsrv"]]);
99
91
 
100
- export function codexLimitLabel(input: { limitName: string }): string {
101
- const tokens = familyTokens(input.limitName).filter((t) => Number.isNaN(Number(t)));
102
- const label = tokens.at(-1) ?? input.limitName.trim().toLowerCase();
92
+ export function codexLimitLabel(limitName: string): string {
93
+ const tokens = familyTokens(limitName).filter((t) => Number.isNaN(Number(t)));
94
+ const label = tokens.at(-1) ?? limitName.trim().toLowerCase();
103
95
  return LIMIT_LABEL_ABBREVIATIONS.get(label) ?? label;
104
96
  }
105
-
106
- const SESSION_WINDOW_MAX_S = 6 * 3600;
107
-
108
- export function isSessionWindow(input: { window: CodexWindow }): boolean {
109
- const seconds = input.window.windowSeconds;
110
- return seconds != null && seconds <= SESSION_WINDOW_MAX_S;
111
- }
112
-
113
- export function weeklyWindowOf(input: { aggregate: CodexWindow[] }): CodexWindow | null {
114
- let best: CodexWindow | null = null;
115
- for (const window of input.aggregate) {
116
- if (isSessionWindow({ window })) continue;
117
- if (best == null || (window.windowSeconds ?? 0) > (best.windowSeconds ?? 0)) best = window;
118
- }
119
- return best;
120
- }
@@ -4,7 +4,7 @@ import { z } from "zod";
4
4
  import { writeFileAtomic } from "./atomic.ts";
5
5
  import * as kc from "./keychain.ts";
6
6
  import { credDir, keychain as kcNames, namespacedCredService, paths } from "./paths.ts";
7
- import { CredentialBlobSchema } from "./types.ts";
7
+ import { CredentialBlobSchema, ErrnoSchema } from "./types.ts";
8
8
 
9
9
  const CredTargetSchema = z.discriminatedUnion("kind", [
10
10
  z.object({ kind: z.literal("keychain"), service: z.string(), account: z.string() }),
@@ -16,16 +16,12 @@ const darwin = process.platform === "darwin";
16
16
 
17
17
  const BlobRecordSchema = z.record(z.string(), z.unknown());
18
18
 
19
- function isEnoent(e: unknown): boolean {
20
- return e instanceof Error && "code" in e && e.code === "ENOENT";
21
- }
22
-
23
19
  export async function readItem(t: CredTarget): Promise<string | null> {
24
20
  if (t.kind === "keychain") return kc.readItem(t);
25
21
  try {
26
22
  return readFileSync(t.path, "utf8");
27
23
  } catch (e) {
28
- if (isEnoent(e)) return null;
24
+ if (ErrnoSchema.safeParse(e).data?.code === "ENOENT") return null;
29
25
  throw e;
30
26
  }
31
27
  }
@@ -42,7 +38,7 @@ export async function deleteItem(t: CredTarget): Promise<boolean> {
42
38
  unlinkSync(t.path);
43
39
  return true;
44
40
  } catch (e) {
45
- if (isEnoent(e)) return false;
41
+ if (ErrnoSchema.safeParse(e).data?.code === "ENOENT") return false;
46
42
  throw e;
47
43
  }
48
44
  }
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 { paths } from "./paths.ts";
5
- import { POST_SWAP_COOLDOWN_MS, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsageSnapshot, saveAccounts, saveDepletedWait, writeUsage } from "./state.ts";
6
- import { readOAuthAccount } from "./claudejson.ts";
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 { AccountSchema, UsageStateSchema, type Account, type Config, type EnforcedLimit, type Thresholds, type UsageState, type UsageWindow } from "./types.ts";
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 liveUsed(input: { window: UsageWindow; windowMs: number; sampledAt: number; now: number }): number {
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.enforcedUntil != null && account.enforcedUntil > now) return true;
45
- if (!u || u.account !== account.accountUuid) return false;
46
- if (
47
- liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }) >= bars.session ||
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
- async function loadFreshSnapshots(cfg: Config, account: string | null, now: number, probe: boolean): Promise<Snapshots> {
81
- const snap = loadUsageSnapshot();
82
- let u = snap?.state ?? null;
83
- let uAt = snap?.at ?? null;
84
- const ttl = cfg.policy.usagePollTtlMs;
85
- const stored = loadAccounts().accounts.find((a) => a.accountUuid === account);
86
- const probeAttempted = stored?.lastProbeAt != null && now - stored.lastProbeAt <= ttl;
87
- const walled = stored?.enforcedUntil != null && stored.enforcedUntil > now;
88
- if (probe && account && stored && !walled && !probeAttempted && (!usageFresh(u, uAt, account, ttl, now) || needsPerModel(u, cfg))) {
89
- const startedAt = Date.now();
90
- const full = await probeUsage();
91
- const ts = Date.now();
92
- if (readOAuthAccount()?.accountUuid === account) {
93
- if (full) {
94
- const teed = loadUsageSnapshot();
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 activeAccount = readOAuthAccount()?.accountUuid ?? null;
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 { u: teeUsage, uAt } = await loadFreshSnapshots(cfg, activeAccount, now, enforced == null);
137
- const stored = loadAccounts().accounts.find((a) => a.accountUuid === activeAccount);
138
- const usage = freshest(teeUsage, uAt, stored);
139
-
140
- if (!enforced && !isOver(usage, stored, bars, cfg, now)) {
141
- const measured = usage != null && activeAccount != null && usage.account === activeAccount;
142
- if (!measured) {
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(paths.lockFile, async () => {
150
- const idx = loadAccounts();
151
- const account2 = readOAuthAccount()?.accountUuid ?? null;
152
- const tee = loadUsageSnapshot();
153
- const active = account2 ? idx.accounts.find((a) => a.accountUuid === account2) : undefined;
154
- const u2 = tee ? freshest(tee.state, tee.at, active) : usage;
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.accountUuid === enforced.account) : undefined;
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 (account2 != null && !active) {
88
+ if (id2 != null && !active) {
167
89
  return { swapped: false, account: null, reason: "live-credential-not-in-pool" };
168
90
  }
169
91
 
170
- if (active && tee && tee.state.account === account2 && (active.lastUsageAt == null || tee.at >= active.lastUsageAt)) {
171
- active.lastUsageAt = tee.state.sampledAt ?? tee.at;
172
- active.lastUsage = keepRows({ fiveHour: tee.state.fiveHour, sevenDay: tee.state.sevenDay, perModel: {} }, active.lastUsage, active.lastUsageAt);
173
- saveAccounts(idx);
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 gated = blindScreen ? cfg.policy.switchModels : gatedFamilies(u2?.model ?? null, cfg.policy.switchModels);
102
+ const screened = blindScreen && families != null ? cfg.policy.switchModels : families;
179
103
  const family = enforced?.family ?? null;
180
- const switchFamilies = family != null && !gated.includes(family) ? [...gated, family] : gated;
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, currentAccountUuid: account2, switchFamilies });
183
- if (!enforced2 && !isOver(u2, active, bars, cfg, now) && !(enforced && seatExhausted)) {
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 = (idx2: { activeAccountUuid: string | null; accounts: Account[] }): Account | null =>
188
- idx2.accounts.find((a) => a.accountUuid === account2) ??
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.accountUuid));
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 (!isSkippableSwapError(e)) throw e;
196
- rejected.add(candidate.accountUuid);
197
- log("decide.candidate_rejected", { account: candidate.accountUuid.slice(0, 8), error: e instanceof Error ? e.message : String(e) });
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, currentAccountUuid: seat?.accountUuid ?? null, switchFamilies };
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 performSwap(best);
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.accountUuid.slice(0, 8), enforced: enforced2 != null });
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, currentAccountUuid: current?.accountUuid ?? null, switchFamilies };
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.accountUuid === (current?.accountUuid ?? null);
161
+ const isCurrent = target.id === (current?.id ?? null);
234
162
  if (!isCurrent && !anticipatory) {
235
- log("decide.depleted_no_park", { account: target.accountUuid.slice(0, 8), waitUntil });
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 performSwap(target);
168
+ await p.swap(target);
241
169
  } catch (e) {
242
170
  skipOrThrow(e, target);
243
171
  continue;
244
172
  }
245
173
  }
246
- saveDepletedWait({ waitUntil, accountUuid: target.accountUuid, ts: now });
247
- log("decide.depleted_wait", { account: target.accountUuid.slice(0, 8), waitUntil });
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.accountUuid === rec.accountUuid) ?? null;
185
+ const account = loadAccounts(p.pool).accounts.find((a) => a.id === rec.id) ?? null;
257
186
  if (!account) return null;
258
- if (account.accountUuid !== (readOAuthAccount()?.accountUuid ?? null)) return null;
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/http.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import ky from "ky";
2
2
  import { z } from "zod";
3
+ import { JsonTextSchema } from "./types.ts";
3
4
 
4
5
  const OAuthErrorBodySchema = z.looseObject({
5
6
  error: z.string(),
@@ -20,12 +21,8 @@ const ErrorDetailSchema = z.object({ code: z.string().nullable(), fields: z.arra
20
21
  type ErrorDetail = z.infer<typeof ErrorDetailSchema>;
21
22
 
22
23
  function parseErrorBody(text: string): ErrorDetail | null {
23
- let json: unknown;
24
- try {
25
- json = JSON.parse(text);
26
- } catch {
27
- return null;
28
- }
24
+ const json = JsonTextSchema.safeParse(text).data;
25
+ if (json === undefined) return null;
29
26
  const oauth = OAuthErrorBodySchema.safeParse(json);
30
27
  if (oauth.success) return { code: oauth.data.error, fields: [oauth.data.error, oauth.data.error_description ?? ""] };
31
28
  const nested = NestedErrorBodySchema.safeParse(json);