tokenmaxxing 1.170.0 → 1.171.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.170.0",
3
+ "version": "1.171.0",
4
4
  "description": "Automatic Claude Code account switching: pool multiple accounts, start each session on its own account, and resume a session on a fresher account when quota fills.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/lib/codex.ts CHANGED
@@ -41,10 +41,16 @@ function applyUsage(account: Account, usage: CodexUsage, at: number): void {
41
41
  if (usage.planType != null) account.tier = usage.planType;
42
42
  }
43
43
 
44
- type CodexReadFailure = { ok: false; reason: string; deadGrant: boolean; expiring?: boolean };
44
+ type CodexReadFailure = { ok: false; reason: string; deadGrant: boolean; expiring?: boolean; rejectedToken?: string };
45
45
  type CodexReadOutcome = { ok: true; usage: CodexUsage; at: number } | CodexReadFailure;
46
46
 
47
- async function freshCodexAuth(account: Account, now: number, refresh: boolean, holder?: string): Promise<{ ok: true; auth: CodexAuthJson } | CodexReadFailure> {
47
+ const REJECTED_REFRESH_BACKOFF_MS = 60 * 60 * 1000;
48
+
49
+ function afterRejection(rejected: CodexReadFailure, refreshFailure: CodexReadFailure): CodexReadFailure {
50
+ return { ...refreshFailure, reason: `${rejected.reason}; refresh skipped: ${refreshFailure.reason}` };
51
+ }
52
+
53
+ async function freshCodexAuth(account: Account, now: number, refresh: boolean, holder?: string, rejectedToken?: string): Promise<{ ok: true; auth: CodexAuthJson } | CodexReadFailure> {
48
54
  let auth: CodexAuthJson | null;
49
55
  try {
50
56
  auth = readCodexStoreAuth(account.id);
@@ -52,7 +58,11 @@ async function freshCodexAuth(account: Account, now: number, refresh: boolean, h
52
58
  return { ok: false, reason: `store credential unreadable (${errorMessage(e).slice(0, 80)})`, deadGrant: false };
53
59
  }
54
60
  if (!auth) return { ok: false, reason: "no credential in this account's store - run `tokenmaxxing auth --codex`", deadGrant: false };
55
- if (!isCodexAccessExpiring({ auth, now })) return { ok: true, auth };
61
+ const expiring = isCodexAccessExpiring({ auth, now });
62
+ if (auth.tokens.access_token !== rejectedToken && !expiring) return { ok: true, auth };
63
+ if (!expiring && auth.last_refresh != null && now - Date.parse(auth.last_refresh) < REJECTED_REFRESH_BACKOFF_MS) {
64
+ return { ok: false, reason: "the store was refreshed within the last hour", deadGrant: false };
65
+ }
56
66
  if (!refresh) return { ok: false, reason: "stored access token is expiring and this read never refreshes a store", deadGrant: false, expiring: true };
57
67
  if (livingPresences(codexPaths.presenceDir).some((p) => p.accountId === account.id && p.id !== holder)) {
58
68
  return { ok: false, reason: "running in a live codex session (store refresh unsafe)", deadGrant: false };
@@ -68,18 +78,26 @@ async function freshCodexAuth(account: Account, now: number, refresh: boolean, h
68
78
  return { ok: true, auth };
69
79
  }
70
80
 
71
- async function readCodexUsage(account: Account, now: number, refresh: boolean, holder?: string): Promise<CodexReadOutcome> {
72
- const fresh = await freshCodexAuth(account, now, refresh, holder);
81
+ async function readCodexUsageOnce(account: Account, now: number, refresh: boolean, holder?: string, rejectedToken?: string): Promise<CodexReadOutcome> {
82
+ const fresh = await freshCodexAuth(account, now, refresh, holder, rejectedToken);
73
83
  if (!fresh.ok) return fresh;
74
84
  try {
75
85
  const at = Date.now();
76
86
  return { ok: true, usage: await fetchCodexUsage({ auth: fresh.auth, at }), at };
77
87
  } catch (e) {
78
- if (e instanceof CodexUsageReadError) return { ok: false, reason: e.message, deadGrant: false };
88
+ if (e instanceof CodexUsageReadError) return { ok: false, reason: e.message, deadGrant: false, rejectedToken: e.status === 401 ? fresh.auth.tokens.access_token : undefined };
79
89
  throw e;
80
90
  }
81
91
  }
82
92
 
93
+ async function readCodexUsage(account: Account, now: number, refresh: boolean, holder?: string): Promise<CodexReadOutcome> {
94
+ const outcome = await readCodexUsageOnce(account, now, refresh, holder);
95
+ if (outcome.ok || outcome.rejectedToken === undefined || !refresh) return outcome;
96
+ const { rejectedToken } = outcome;
97
+ const fresh = await withLock(codexPool.lockFile, () => freshCodexAuth(account, now, true, holder, rejectedToken));
98
+ return fresh.ok ? readCodexUsageOnce(account, now, false) : afterRejection(outcome, fresh);
99
+ }
100
+
83
101
  export async function observeCodex(account: Account, cfg: Config, now: number, opts: { probe: boolean; refresh: boolean; holder?: string }): Promise<Observation | null> {
84
102
  if (opts.probe && (account.lastUsageAt == null || now - account.lastUsageAt > cfg.policy.usagePollTtlMs)) {
85
103
  const outcome = await readCodexUsage(account, now, opts.refresh, opts.holder);
@@ -112,17 +130,21 @@ async function samplePool(accounts: Account[], now: number): Promise<Map<string,
112
130
  reports.set(account.id, { ok: false, reason: outcome.reason });
113
131
  }
114
132
  };
115
- const expiring: Account[] = [];
133
+ const expiring: { account: Account; rejected?: CodexReadFailure }[] = [];
116
134
  await Promise.all(
117
135
  accounts.map(async (account) => {
118
136
  const outcome = await readCodexUsage(account, now, false);
119
- if (!outcome.ok && outcome.expiring === true) expiring.push(account);
137
+ if (!outcome.ok && (outcome.expiring === true || outcome.rejectedToken !== undefined)) expiring.push({ account, rejected: outcome.rejectedToken === undefined ? undefined : outcome });
120
138
  else record(account, outcome);
121
139
  }),
122
140
  );
123
141
  if (expiring.length > 0) {
124
- const rotated = await withLock(codexPool.lockFile, () => Promise.all(expiring.map(async (account) => ({ account, fresh: await freshCodexAuth(account, now, true) }))));
125
- await Promise.all(rotated.map(async ({ account, fresh }) => record(account, fresh.ok ? await readCodexUsage(account, now, false) : fresh)));
142
+ const rotated = await withLock(codexPool.lockFile, () => Promise.all(expiring.map(async ({ account, rejected }) => ({ account, rejected, fresh: await freshCodexAuth(account, now, true, undefined, rejected?.rejectedToken) }))));
143
+ await Promise.all(
144
+ rotated.map(async ({ account, rejected, fresh }) =>
145
+ record(account, fresh.ok ? await readCodexUsage(account, now, false) : rejected ? afterRejection(rejected, fresh) : fresh),
146
+ ),
147
+ );
126
148
  }
127
149
  return reports;
128
150
  }
@@ -11,9 +11,11 @@ export const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
11
11
  const USAGE_URL = env("TOKENMAXXING_CODEX_USAGE_URL", CODEX_USAGE_URL);
12
12
 
13
13
  export class CodexUsageReadError extends Error {
14
- constructor(detail: string) {
14
+ readonly status: number | null;
15
+ constructor(detail: string, status: number | null = null) {
15
16
  super(`codex usage read failed: ${detail}`);
16
17
  this.name = "CodexUsageReadError";
18
+ this.status = status;
17
19
  }
18
20
  }
19
21
 
@@ -73,7 +75,7 @@ export async function fetchCodexUsage(input: { auth: CodexAuthJson; at: number }
73
75
  }
74
76
  const text = await res.text();
75
77
  if (!res.ok) {
76
- throw new CodexUsageReadError(`HTTP ${res.status}: ${safeErrorDetail({ text })}`);
78
+ throw new CodexUsageReadError(`HTTP ${res.status}: ${safeErrorDetail({ text })}`, res.status);
77
79
  }
78
80
  const parsed = WireUsageSchema.safeParse(JsonTextSchema.safeParse(text).data);
79
81
  if (!parsed.success) {