tokenmaxxing 1.170.0 → 1.172.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.172.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,25 @@ 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; deadAccessToken?: string; expiring?: boolean; rejected?: 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, rejected: true, reason: `${rejected.reason}; refresh skipped: ${refreshFailure.reason}` };
51
+ }
52
+
53
+ function storeHolds(accountId: string, accessToken: string | undefined): boolean {
54
+ if (accessToken === undefined) return true;
55
+ try {
56
+ return readCodexStoreAuth(accountId)?.tokens.access_token === accessToken;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ async function freshCodexAuth(account: Account, now: number, refresh: boolean, holder?: string, rejectedToken?: string): Promise<{ ok: true; auth: CodexAuthJson } | CodexReadFailure> {
48
63
  let auth: CodexAuthJson | null;
49
64
  try {
50
65
  auth = readCodexStoreAuth(account.id);
@@ -52,7 +67,11 @@ async function freshCodexAuth(account: Account, now: number, refresh: boolean, h
52
67
  return { ok: false, reason: `store credential unreadable (${errorMessage(e).slice(0, 80)})`, deadGrant: false };
53
68
  }
54
69
  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 };
70
+ const expiring = isCodexAccessExpiring({ auth, now });
71
+ if (auth.tokens.access_token !== rejectedToken && !expiring) return { ok: true, auth };
72
+ if (!expiring && auth.last_refresh != null && now - Date.parse(auth.last_refresh) < REJECTED_REFRESH_BACKOFF_MS) {
73
+ return { ok: false, reason: "the store was refreshed within the last hour", deadGrant: false };
74
+ }
56
75
  if (!refresh) return { ok: false, reason: "stored access token is expiring and this read never refreshes a store", deadGrant: false, expiring: true };
57
76
  if (livingPresences(codexPaths.presenceDir).some((p) => p.accountId === account.id && p.id !== holder)) {
58
77
  return { ok: false, reason: "running in a live codex session (store refresh unsafe)", deadGrant: false };
@@ -60,7 +79,7 @@ async function freshCodexAuth(account: Account, now: number, refresh: boolean, h
60
79
  try {
61
80
  auth = await refreshCodexAuth({ auth, now });
62
81
  } catch (e) {
63
- if (e instanceof CodexInvalidGrantError) return { ok: false, reason: e.message, deadGrant: true };
82
+ if (e instanceof CodexInvalidGrantError) return { ok: false, reason: e.message, deadGrant: true, deadAccessToken: auth.tokens.access_token };
64
83
  if (e instanceof CodexRefreshFailedError) return { ok: false, reason: e.message, deadGrant: false };
65
84
  throw e;
66
85
  }
@@ -68,21 +87,32 @@ async function freshCodexAuth(account: Account, now: number, refresh: boolean, h
68
87
  return { ok: true, auth };
69
88
  }
70
89
 
71
- async function readCodexUsage(account: Account, now: number, refresh: boolean, holder?: string): Promise<CodexReadOutcome> {
72
- const fresh = await freshCodexAuth(account, now, refresh, holder);
90
+ async function readCodexUsageOnce(account: Account, now: number, refresh: boolean, holder?: string, rejectedToken?: string): Promise<CodexReadOutcome> {
91
+ const fresh = await freshCodexAuth(account, now, refresh, holder, rejectedToken);
73
92
  if (!fresh.ok) return fresh;
74
93
  try {
75
94
  const at = Date.now();
76
95
  return { ok: true, usage: await fetchCodexUsage({ auth: fresh.auth, at }), at };
77
96
  } catch (e) {
78
- if (e instanceof CodexUsageReadError) return { ok: false, reason: e.message, deadGrant: false };
97
+ if (e instanceof CodexUsageReadError) return { ok: false, reason: e.message, deadGrant: false, rejected: e.status === 401, rejectedToken: e.status === 401 ? fresh.auth.tokens.access_token : undefined };
79
98
  throw e;
80
99
  }
81
100
  }
82
101
 
83
- export async function observeCodex(account: Account, cfg: Config, now: number, opts: { probe: boolean; refresh: boolean; holder?: string }): Promise<Observation | null> {
102
+ async function readCodexUsage(account: Account, now: number, refresh: boolean, holder?: string): Promise<CodexReadOutcome> {
103
+ const outcome = await readCodexUsageOnce(account, now, refresh, holder);
104
+ if (outcome.ok || outcome.rejectedToken === undefined) return outcome;
105
+ const { rejectedToken } = outcome;
106
+ const fresh = await withLock(codexPool.lockFile, () => freshCodexAuth(account, now, true, holder, rejectedToken));
107
+ if (!fresh.ok) return afterRejection(outcome, fresh);
108
+ const retried = await readCodexUsageOnce(account, now, false);
109
+ return retried.ok ? retried : { ...retried, rejectedToken: undefined };
110
+ }
111
+
112
+ export async function observeCodex(account: Account, cfg: Config, now: number, opts: { probe: boolean; refresh: boolean; holder?: string; rejected?: Set<string> }): Promise<Observation | null> {
84
113
  if (opts.probe && (account.lastUsageAt == null || now - account.lastUsageAt > cfg.policy.usagePollTtlMs)) {
85
114
  const outcome = await readCodexUsage(account, now, opts.refresh, opts.holder);
115
+ if (!outcome.ok && outcome.rejected === true) opts.rejected?.add(account.id);
86
116
  await withLock(codexPool.lockFile, () => {
87
117
  const idx = loadAccounts(codexPool);
88
118
  const a = idx.accounts.find((x) => x.id === account.id);
@@ -91,7 +121,7 @@ export async function observeCodex(account: Account, cfg: Config, now: number, o
91
121
  if (a.lastUsageAt == null || outcome.at > a.lastUsageAt) {
92
122
  applyUsage(a, outcome.usage, outcome.at);
93
123
  }
94
- } else if (outcome.deadGrant) {
124
+ } else if (outcome.deadGrant && storeHolds(a.id, outcome.deadAccessToken)) {
95
125
  a.needsReauth = true;
96
126
  }
97
127
  saveAccounts(codexPool, idx);
@@ -108,21 +138,33 @@ async function samplePool(accounts: Account[], now: number): Promise<Map<string,
108
138
  applyUsage(account, outcome.usage, outcome.at);
109
139
  reports.set(account.id, { ok: true, source: "probe" });
110
140
  } else {
111
- if (outcome.deadGrant) account.needsReauth = true;
112
141
  reports.set(account.id, { ok: false, reason: outcome.reason });
113
142
  }
114
143
  };
115
- const expiring: Account[] = [];
144
+ const expiring: { account: Account; rejected?: CodexReadFailure }[] = [];
116
145
  await Promise.all(
117
146
  accounts.map(async (account) => {
118
- const outcome = await readCodexUsage(account, now, false);
119
- if (!outcome.ok && outcome.expiring === true) expiring.push(account);
147
+ const outcome = await readCodexUsageOnce(account, now, false);
148
+ if (!outcome.ok && (outcome.expiring === true || outcome.rejectedToken !== undefined)) expiring.push({ account, rejected: outcome.rejectedToken === undefined ? undefined : outcome });
120
149
  else record(account, outcome);
121
150
  }),
122
151
  );
123
152
  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)));
153
+ const rotated = await withLock(codexPool.lockFile, async () => {
154
+ const results = await Promise.all(expiring.map(async ({ account, rejected }) => ({ account, rejected, fresh: await freshCodexAuth(account, now, true, undefined, rejected?.rejectedToken) })));
155
+ const dead = new Set(results.filter(({ fresh }) => !fresh.ok && fresh.deadGrant).map(({ account }) => account.id));
156
+ if (dead.size > 0) {
157
+ const idx = loadAccounts(codexPool);
158
+ for (const a of idx.accounts) if (dead.has(a.id)) a.needsReauth = true;
159
+ saveAccounts(codexPool, idx);
160
+ }
161
+ return results;
162
+ });
163
+ await Promise.all(
164
+ rotated.map(async ({ account, rejected, fresh }) =>
165
+ record(account, fresh.ok ? await readCodexUsageOnce(account, now, false) : rejected ? afterRejection(rejected, fresh) : fresh),
166
+ ),
167
+ );
126
168
  }
127
169
  return reports;
128
170
  }
@@ -249,10 +291,11 @@ export async function borrowCodexSeat(pid: number): Promise<SeatBorrow> {
249
291
  const now = Date.now();
250
292
  const seatId = `seat-${pid}`;
251
293
  const cfg = loadConfig();
294
+ const rejected = new Set<string>();
252
295
  await Promise.all(
253
296
  loadAccounts(codexPool)
254
297
  .accounts.filter((a) => a.needsReauth !== true && codexStoreUsable(a.id))
255
- .map((a) => observeCodex(a, cfg, now, { probe: true, refresh: false })),
298
+ .map((a) => observeCodex(a, cfg, now, { probe: true, refresh: false, rejected })),
256
299
  );
257
300
  const granted = await withLock(codexPool.lockFile, (): SeatBorrow => {
258
301
  const ctx = codexPickCtx(now, null);
@@ -261,6 +304,9 @@ export async function borrowCodexSeat(pid: number): Promise<SeatBorrow> {
261
304
  const held = living.find((p) => p.id === seatId);
262
305
  const heldAccount = held ? (idx.accounts.find((x) => x.id === held.accountId) ?? null) : null;
263
306
  if (heldAccount) {
307
+ if (rejected.has(heldAccount.id)) {
308
+ return { denied: "the usage endpoint rejects the account this pid holds and its store could not be refreshed now - borrow again later" };
309
+ }
264
310
  if (heldAccount.needsReauth === true) {
265
311
  return { denied: "the account this pid holds needs reauthentication - run `tokenmaxxing auth --codex` and borrow again" };
266
312
  }
@@ -273,7 +319,7 @@ export async function borrowCodexSeat(pid: number): Promise<SeatBorrow> {
273
319
  const closed = new Set(living.filter((p) => p.id === parent || p.id.startsWith(PI_PRESENCE_PREFIX)).map((p) => p.accountId));
274
320
  const lent = countBy(living, (p) => p.accountId);
275
321
  const usable = idx.accounts.filter(
276
- (a) => !closed.has(a.id) && a.needsReauth !== true && !isExhausted(a, ctx) && codexStoreUsable(a.id)
322
+ (a) => !closed.has(a.id) && !rejected.has(a.id) && a.needsReauth !== true && !isExhausted(a, ctx) && codexStoreUsable(a.id)
277
323
  );
278
324
  const plan = usable.filter((a) => !onCredits(a, ctx));
279
325
  const open = plan.length > 0 ? plan : usable;
@@ -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) {