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 +1 -1
- package/src/lib/codex.ts +63 -17
- package/src/lib/codexusage.ts +4 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
125
|
-
|
|
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;
|
package/src/lib/codexusage.ts
CHANGED
|
@@ -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
|
-
|
|
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) {
|