tokenmaxxing 1.18.0 → 1.19.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 +2 -0
- package/README.md +4 -3
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/switching-policy/SKILL.md +1 -0
- package/agent-plugin/skills/switching-policy/references/policy.md +2 -1
- package/package.json +1 -1
- package/src/cli/check.ts +3 -0
- package/src/cli/config.ts +1 -0
- package/src/cli/status.ts +13 -3
- package/src/cli/switch.ts +63 -38
- package/src/entries/codexstophook.ts +1 -0
- package/src/entries/stopfailurehook.ts +9 -6
- package/src/entries/stophook.ts +1 -0
- package/src/entries/supervisor.ts +2 -1
- package/src/lib/bankedreset.ts +234 -0
- package/src/lib/codexdecide.ts +25 -1
- package/src/lib/codexpick.ts +3 -3
- package/src/lib/codexreset.ts +122 -0
- package/src/lib/codexusage.ts +9 -0
- package/src/lib/decide.ts +75 -8
- package/src/lib/oauth.ts +17 -4
- package/src/lib/paths.ts +1 -0
- package/src/lib/sample.ts +62 -17
- package/src/lib/state.ts +93 -3
- package/src/lib/swap.ts +87 -12
- package/src/lib/types.ts +54 -0
package/src/lib/oauth.ts
CHANGED
|
@@ -29,11 +29,19 @@ export class RefreshRejectedError extends Error {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
export class IdentityUnavailableError extends Error {
|
|
33
|
+
constructor(public readonly status: number | null, public readonly detail: string) {
|
|
34
|
+
super(status == null ? `profile endpoint unreachable: ${detail}` : `identity check failed (HTTP ${status}): ${detail}`);
|
|
35
|
+
this.name = "IdentityUnavailableError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
32
39
|
export function isDeadCredential(creds: OAuthCreds): boolean {
|
|
33
40
|
return creds.refreshToken === "" || creds.accessToken === "";
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
export async function refreshCredential(creds: OAuthCreds, now = Date.now()): Promise<OAuthCreds> {
|
|
44
|
+
if (isDeadCredential(creds)) throw new InvalidGrantError("credential was cleared after a failed refresh");
|
|
37
45
|
const scope = (creds.scopes?.length ? creds.scopes : DEFAULT_SCOPES).join(" ");
|
|
38
46
|
const body = {
|
|
39
47
|
grant_type: "refresh_token",
|
|
@@ -93,14 +101,19 @@ export async function fetchTokenIdentity(accessToken: string): Promise<TokenIden
|
|
|
93
101
|
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
|
|
94
102
|
});
|
|
95
103
|
} catch (e) {
|
|
96
|
-
throw new
|
|
104
|
+
throw new IdentityUnavailableError(null, e instanceof Error ? e.message : String(e));
|
|
97
105
|
}
|
|
98
|
-
|
|
99
|
-
|
|
106
|
+
let text: string;
|
|
107
|
+
try {
|
|
108
|
+
text = await res.text();
|
|
109
|
+
} catch (e) {
|
|
110
|
+
throw new IdentityUnavailableError(res.status, `profile response body unreadable: ${e instanceof Error ? e.message : String(e)}`);
|
|
111
|
+
}
|
|
112
|
+
if (!res.ok) throw new IdentityUnavailableError(res.status, safeErrorDetail({ text }));
|
|
100
113
|
const parsed = ProfileResponseSchema.safeParse((() => {
|
|
101
114
|
try { return JSON.parse(text); } catch { return null; }
|
|
102
115
|
})());
|
|
103
|
-
if (!parsed.success) throw new
|
|
116
|
+
if (!parsed.success) throw new IdentityUnavailableError(res.status, `profile endpoint returned an unrecognized body (${text.length} bytes, withheld)`);
|
|
104
117
|
return TokenIdentitySchema.parse({
|
|
105
118
|
accountUuid: parsed.data.account.uuid,
|
|
106
119
|
email: parsed.data.account.email ?? null,
|
package/src/lib/paths.ts
CHANGED
|
@@ -19,6 +19,7 @@ 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"),
|
|
22
23
|
depletedJson: join(TM_HOME, "depleted.json"),
|
|
23
24
|
nextCheckJson: join(TM_HOME, "nextcheck.json"),
|
|
24
25
|
respawnDir: join(TM_HOME, "respawn"),
|
package/src/lib/sample.ts
CHANGED
|
@@ -4,9 +4,12 @@ import { z } from "zod";
|
|
|
4
4
|
import { readItem, writeItem, deleteItem, liveTarget, parkedTarget, isolatedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
|
|
5
5
|
import { credItemFor, paths } from "./paths.ts";
|
|
6
6
|
import { withClaudeRefreshLock } from "./claudelock.ts";
|
|
7
|
-
import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, InvalidGrantError } from "./oauth.ts";
|
|
7
|
+
import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, InvalidGrantError } from "./oauth.ts";
|
|
8
8
|
import { FullUsageSchema, pingSession, probeUsage } from "./usage.ts";
|
|
9
|
-
import {
|
|
9
|
+
import { loadAccounts } from "./state.ts";
|
|
10
|
+
import { keepRotatedPair } from "./swap.ts";
|
|
11
|
+
import { log } from "./log.ts";
|
|
12
|
+
import { CredentialBlobSchema, TokenIdentitySchema, type Account, type OAuthCreds, type TokenIdentity } from "./types.ts";
|
|
10
13
|
|
|
11
14
|
const SampleOutcomeSchema = z.discriminatedUnion("ok", [
|
|
12
15
|
z.object({ ok: z.literal(true), usage: FullUsageSchema, pingError: z.string().optional(), pingRejected: z.boolean().optional() }),
|
|
@@ -16,8 +19,8 @@ export type SampleOutcome = z.infer<typeof SampleOutcomeSchema>;
|
|
|
16
19
|
|
|
17
20
|
const IdentityCheckSchema = z.discriminatedUnion("status", [
|
|
18
21
|
z.object({ status: z.literal("match") }),
|
|
19
|
-
z.object({ status: z.literal("mismatch"), reason: z.string() }),
|
|
20
|
-
z.object({ status: z.literal("unavailable"), reason: z.string() }),
|
|
22
|
+
z.object({ status: z.literal("mismatch"), reason: z.string(), owner: TokenIdentitySchema }),
|
|
23
|
+
z.object({ status: z.literal("unavailable"), reason: z.string(), stale: z.boolean() }),
|
|
21
24
|
]);
|
|
22
25
|
type IdentityCheck = z.infer<typeof IdentityCheckSchema>;
|
|
23
26
|
|
|
@@ -26,10 +29,14 @@ async function checkIdentity(creds: OAuthCreds, account: Account): Promise<Ident
|
|
|
26
29
|
try {
|
|
27
30
|
identity = await fetchTokenIdentity(creds.accessToken);
|
|
28
31
|
} catch (e) {
|
|
29
|
-
return {
|
|
32
|
+
return {
|
|
33
|
+
status: "unavailable",
|
|
34
|
+
reason: `credential identity check failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
35
|
+
stale: e instanceof IdentityUnavailableError && e.status === 401,
|
|
36
|
+
};
|
|
30
37
|
}
|
|
31
38
|
if (identity.accountUuid === account.accountUuid) return { status: "match" };
|
|
32
|
-
return { status: "mismatch", reason: `credential actually belongs to ${describeIdentity(identity)}
|
|
39
|
+
return { status: "mismatch", reason: `credential actually belongs to ${describeIdentity(identity)}`, owner: identity };
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
function refreshPlanFields(account: Account, creds: OAuthCreds): void {
|
|
@@ -54,6 +61,8 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
const liveRaw = await readItem(liveTarget());
|
|
64
|
+
let liveAccount: string | null = null;
|
|
65
|
+
let liveToken: string | null = null;
|
|
57
66
|
if (liveRaw != null) {
|
|
58
67
|
let liveCreds: OAuthCreds;
|
|
59
68
|
try {
|
|
@@ -62,7 +71,7 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
62
71
|
return { ok: false, reason: `cannot read the live credential (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - refusing to sample a possibly-live account` };
|
|
63
72
|
}
|
|
64
73
|
if (!isDeadCredential(liveCreds)) {
|
|
65
|
-
|
|
74
|
+
liveToken = liveCreds.accessToken;
|
|
66
75
|
try {
|
|
67
76
|
liveAccount = (await fetchTokenIdentity(liveCreds.accessToken)).accountUuid;
|
|
68
77
|
} catch (e) {
|
|
@@ -74,28 +83,64 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
74
83
|
}
|
|
75
84
|
}
|
|
76
85
|
|
|
86
|
+
const relocate = async (owner: TokenIdentity): Promise<string> => {
|
|
87
|
+
const pooled = loadAccounts().accounts.find((a) => a.accountUuid === owner.accountUuid) ?? null;
|
|
88
|
+
if (pooled == null || pooled.accountUuid === liveAccount) return "was left in place";
|
|
89
|
+
await writeItem(parkedTarget(pooled.keychainItem), JSON.stringify({ claudeAiOauth: creds }));
|
|
90
|
+
log("swap.parked_relocated", { account: pooled.accountUuid.slice(0, 8) });
|
|
91
|
+
return "was copied to that account's slot";
|
|
92
|
+
};
|
|
93
|
+
|
|
77
94
|
if (isAccessTokenExpiring(creds, 300_000)) {
|
|
78
95
|
const owner = await checkIdentity(creds, account);
|
|
79
96
|
if (owner.status === "mismatch") {
|
|
97
|
+
const kept = await relocate(owner.owner);
|
|
80
98
|
account.needsReauth = true;
|
|
81
|
-
return { ok: false, reason: `${owner.reason} - refusing to spend another account's grant; re-auth with \`tokenmaxxing auth\`` };
|
|
99
|
+
return { ok: false, reason: `${owner.reason} - refusing to spend another account's grant; the pair ${kept}; re-auth with \`tokenmaxxing auth\`` };
|
|
82
100
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
101
|
+
if (owner.status === "unavailable" && !owner.stale) {
|
|
102
|
+
return { ok: false, reason: `${owner.reason} - refusing to refresh a parked credential whose owner cannot be verified` };
|
|
103
|
+
}
|
|
104
|
+
const rotate = async (): Promise<{ fresh: OAuthCreds } | { failed: SampleOutcome }> => {
|
|
105
|
+
try {
|
|
106
|
+
return { fresh: await refreshCredential(creds) };
|
|
107
|
+
} catch (e) {
|
|
108
|
+
if (e instanceof InvalidGrantError) {
|
|
109
|
+
account.needsReauth = true;
|
|
110
|
+
return { failed: { ok: false, reason: "refresh token dead - re-auth with `tokenmaxxing auth`" } };
|
|
111
|
+
}
|
|
112
|
+
return { failed: { ok: false, reason: `token refresh failed: ${e instanceof Error ? e.message : String(e)}` } };
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
let result: { fresh: OAuthCreds } | { failed: SampleOutcome };
|
|
116
|
+
if (owner.status === "match") {
|
|
117
|
+
result = await rotate();
|
|
118
|
+
} else {
|
|
119
|
+
try {
|
|
120
|
+
result = await withClaudeRefreshLock(async (lock) => {
|
|
121
|
+
const rotated = await rotate();
|
|
122
|
+
if ("failed" in rotated) return rotated;
|
|
123
|
+
const verified = await checkIdentity(rotated.fresh, account);
|
|
124
|
+
if (verified.status !== "mismatch") return rotated;
|
|
125
|
+
const trueOwner = loadAccounts().accounts.find((a) => a.accountUuid === verified.owner.accountUuid) ?? null;
|
|
126
|
+
const kept = await keepRotatedPair({ fresh: rotated.fresh, owner: trueOwner, fallback: account, liveOwnerUuid: liveAccount, expectedLiveToken: liveToken, lock });
|
|
127
|
+
account.needsReauth = true;
|
|
128
|
+
return { failed: { ok: false, reason: `${verified.reason}, whose grant this refresh rotated - the rotated token ${kept}; re-auth with \`tokenmaxxing auth\`` } };
|
|
129
|
+
});
|
|
130
|
+
} catch (e) {
|
|
131
|
+
return { ok: false, reason: `cannot arbitrate a stale parked credential: ${e instanceof Error ? e.message : String(e)}` };
|
|
90
132
|
}
|
|
91
|
-
return { ok: false, reason: `token refresh failed: ${e instanceof Error ? e.message : String(e)}` };
|
|
92
133
|
}
|
|
134
|
+
if ("failed" in result) return result.failed;
|
|
135
|
+
creds = result.fresh;
|
|
136
|
+
await writeItem(backup, JSON.stringify({ claudeAiOauth: creds }));
|
|
93
137
|
}
|
|
94
138
|
|
|
95
139
|
const identity = await checkIdentity(creds, account);
|
|
96
140
|
if (identity.status === "mismatch") {
|
|
141
|
+
const kept = await relocate(identity.owner);
|
|
97
142
|
account.needsReauth = true;
|
|
98
|
-
return { ok: false, reason: `${identity.reason} - this account's own credential is gone; re-auth with \`tokenmaxxing auth\`` };
|
|
143
|
+
return { ok: false, reason: `${identity.reason} - this account's own credential is gone; the pair ${kept}; re-auth with \`tokenmaxxing auth\`` };
|
|
99
144
|
}
|
|
100
145
|
if (identity.status === "unavailable") {
|
|
101
146
|
return { ok: false, reason: identity.reason };
|
package/src/lib/state.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { closeSync, existsSync, fstatSync, openSync, readFileSync, rmSync, utimesSync } from "node:fs";
|
|
2
|
-
import { isEqual } from "es-toolkit";
|
|
2
|
+
import { isEqual, uniq } 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,
|
|
8
9
|
ConfigSchema,
|
|
9
10
|
LastSwapSchema,
|
|
10
11
|
ModelUsageStateSchema,
|
|
@@ -13,8 +14,11 @@ import {
|
|
|
13
14
|
UsageStateSchema,
|
|
14
15
|
type AccountsIndex,
|
|
15
16
|
type Config,
|
|
17
|
+
type FamilyAnchor,
|
|
16
18
|
type ModelUsageState,
|
|
17
19
|
type UsageState,
|
|
20
|
+
type UsageWindow,
|
|
21
|
+
type WindowAnchor,
|
|
18
22
|
} from "./types.ts";
|
|
19
23
|
|
|
20
24
|
const DEFAULT_CONFIG: Config = {
|
|
@@ -30,6 +34,7 @@ const DEFAULT_CONFIG: Config = {
|
|
|
30
34
|
usagePollTtlMs: 90_000,
|
|
31
35
|
maxWaitMs: 3_600_000,
|
|
32
36
|
checkIntervalMs: 60_000,
|
|
37
|
+
preferToUseBankedReset: [],
|
|
33
38
|
},
|
|
34
39
|
};
|
|
35
40
|
|
|
@@ -50,6 +55,7 @@ export const ConfigFileSchema = z
|
|
|
50
55
|
usagePollTtlMs: z.number().int().positive(),
|
|
51
56
|
maxWaitMs: z.number().int().positive(),
|
|
52
57
|
checkIntervalMs: z.number().int().min(10_000),
|
|
58
|
+
preferToUseBankedReset: BankedResetProvidersSchema,
|
|
53
59
|
})
|
|
54
60
|
.partial(),
|
|
55
61
|
})
|
|
@@ -83,6 +89,9 @@ export function mergeConfigFile(p: z.infer<typeof ConfigFileSchema>): MergeOutco
|
|
|
83
89
|
if (p.policy?.switchModels) {
|
|
84
90
|
cfg.policy.switchModels = p.policy.switchModels.map((s) => s.toLowerCase());
|
|
85
91
|
}
|
|
92
|
+
if (p.policy?.preferToUseBankedReset) {
|
|
93
|
+
cfg.policy.preferToUseBankedReset = uniq(p.policy.preferToUseBankedReset);
|
|
94
|
+
}
|
|
86
95
|
const envBin = realClaudeBinFromEnv();
|
|
87
96
|
if (envBin) cfg.claudeBin = envBin;
|
|
88
97
|
const envCodexBin = realCodexBinFromEnv();
|
|
@@ -187,6 +196,29 @@ export function saveLastSwapAt(ts: number): void {
|
|
|
187
196
|
writeFileAtomic(paths.lastSwapJson, JSON.stringify(LastSwapSchema.parse({ ts })));
|
|
188
197
|
}
|
|
189
198
|
|
|
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
|
+
|
|
190
222
|
const DepletedWaitSchema = z.object({ waitUntil: z.number(), accountUuid: z.string(), ts: z.number() });
|
|
191
223
|
export type DepletedWait = z.infer<typeof DepletedWaitSchema>;
|
|
192
224
|
|
|
@@ -237,10 +269,68 @@ export function clearNextCheck(): void {
|
|
|
237
269
|
}
|
|
238
270
|
|
|
239
271
|
const USAGE_TS_REFRESH_MS = 10 * 60_000;
|
|
272
|
+
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
|
+
}
|
|
240
321
|
|
|
241
|
-
export function writeUsage(
|
|
322
|
+
export function writeUsage(input: UsageState, opts: { stamp?: boolean } = {}): boolean {
|
|
242
323
|
const prev = loadUsage();
|
|
243
|
-
|
|
324
|
+
const stamp = opts.stamp === true;
|
|
325
|
+
const carriedSampleAt = prev != null && prev.account === input.account ? (prev.sampledAt ?? prev.ts) : undefined;
|
|
326
|
+
const sampledAt = stamp ? (carriedSampleAt ?? input.sampledAt) : input.ts;
|
|
327
|
+
const next: UsageState = { ...input, ...measureSessionWindow(prev, input, stamp), ...(sampledAt != null ? { sampledAt } : {}) };
|
|
328
|
+
if (
|
|
329
|
+
prev &&
|
|
330
|
+
isEqual({ ...prev, ts: 0, sampledAt: 0 }, { ...next, ts: 0, sampledAt: 0 }) &&
|
|
331
|
+
next.ts - prev.ts < USAGE_TS_REFRESH_MS &&
|
|
332
|
+
(stamp || next.ts - (prev.sampledAt ?? prev.ts) < SAMPLED_AT_REFRESH_MS)
|
|
333
|
+
) {
|
|
244
334
|
try {
|
|
245
335
|
utimesSync(paths.usageJson, new Date(next.ts), new Date(next.ts));
|
|
246
336
|
} catch (e) {
|
package/src/lib/swap.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { clearDepletedWait, clearNextCheck, clearUsageSnapshots, loadAccounts, saveAccounts, saveLastSwapAt } from "./state.ts";
|
|
2
2
|
import { readItem, writeItem, liveTarget, parkedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
|
|
3
|
-
import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, InvalidGrantError, RefreshRejectedError } from "./oauth.ts";
|
|
3
|
+
import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, InvalidGrantError, RefreshRejectedError } from "./oauth.ts";
|
|
4
4
|
import { swapOAuthAccount } from "./claudejson.ts";
|
|
5
5
|
import { withClaudeRefreshLock } from "./claudelock.ts";
|
|
6
6
|
import { log } from "./log.ts";
|
|
7
7
|
import { pickBest, type PickCtx } from "./picker.ts";
|
|
8
|
-
import { CredentialBlobSchema, type Account, type OAuthCreds } from "./types.ts";
|
|
8
|
+
import { CredentialBlobSchema, type Account, type OAuthCreds, type TokenIdentity } from "./types.ts";
|
|
9
9
|
|
|
10
10
|
function parseBlob(raw: string) {
|
|
11
11
|
return CredentialBlobSchema.parse(JSON.parse(raw));
|
|
@@ -44,7 +44,12 @@ export async function performSwap(target: Account): Promise<void> {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
if (liveCreds != null) {
|
|
47
|
-
|
|
47
|
+
let identity: TokenIdentity;
|
|
48
|
+
try {
|
|
49
|
+
identity = await fetchTokenIdentity(liveCreds.accessToken);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
throw new Error(`cannot resolve the live credential's owner (${e instanceof Error ? e.message : String(e)}) - refusing to swap over it; the next check retries`);
|
|
52
|
+
}
|
|
48
53
|
liveOwner = idx.accounts.find((a) => a.accountUuid === identity.accountUuid) ?? null;
|
|
49
54
|
if (!liveOwner) {
|
|
50
55
|
throw new Error(
|
|
@@ -73,16 +78,51 @@ export async function performSwap(target: Account): Promise<void> {
|
|
|
73
78
|
return new InvalidGrantError(detail);
|
|
74
79
|
};
|
|
75
80
|
if (isDeadCredential(parked)) throw markDead("parked credential was cleared after a failed refresh - re-auth with `tokenmaxxing auth`");
|
|
76
|
-
|
|
77
|
-
if (parkedOwner != null && parkedOwner.accountUuid !== target.accountUuid) {
|
|
78
|
-
throw markDead(`parked credential belongs to ${describeIdentity(parkedOwner)} - refusing to spend another account's grant; re-auth with \`tokenmaxxing auth ${target.label}\``);
|
|
79
|
-
}
|
|
81
|
+
let parkedOwner: TokenIdentity | null = null;
|
|
80
82
|
try {
|
|
81
|
-
|
|
83
|
+
parkedOwner = await fetchTokenIdentity(parked.accessToken);
|
|
82
84
|
} catch (e) {
|
|
83
|
-
if (e instanceof
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
if (!(e instanceof IdentityUnavailableError && e.status === 401)) throw e;
|
|
86
|
+
log("swap.parked_token_stale", { account: target.accountUuid.slice(0, 8) });
|
|
87
|
+
}
|
|
88
|
+
if (parkedOwner != null && parkedOwner.accountUuid !== target.accountUuid) {
|
|
89
|
+
const owner = idx.accounts.find((a) => a.accountUuid === parkedOwner.accountUuid) ?? null;
|
|
90
|
+
let kept = "was left in place";
|
|
91
|
+
if (owner != null && owner.accountUuid !== liveOwner?.accountUuid) {
|
|
92
|
+
await writeItem(parkedTarget(owner.keychainItem), JSON.stringify({ claudeAiOauth: parked }));
|
|
93
|
+
log("swap.parked_relocated", { account: owner.accountUuid.slice(0, 8) });
|
|
94
|
+
kept = "was copied to that account's slot";
|
|
95
|
+
}
|
|
96
|
+
throw markDead(`parked credential belongs to ${describeIdentity(parkedOwner)} - refusing to spend another account's grant; the pair ${kept}; re-auth with \`tokenmaxxing auth ${target.label}\``);
|
|
97
|
+
}
|
|
98
|
+
const refreshParked = async (): Promise<OAuthCreds> => {
|
|
99
|
+
try {
|
|
100
|
+
return await refreshCredential(parked);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
if (e instanceof InvalidGrantError) throw markDead(e.detail);
|
|
103
|
+
if (e instanceof RefreshRejectedError) log("swap.refresh_rejected", { account: target.accountUuid.slice(0, 8), status: e.status, detail: e.detail });
|
|
104
|
+
throw e;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
if (parkedOwner != null) {
|
|
108
|
+
fresh = await refreshParked();
|
|
109
|
+
} else {
|
|
110
|
+
fresh = await withClaudeRefreshLock(async (lock) => {
|
|
111
|
+
const rotated = await refreshParked();
|
|
112
|
+
let freshOwner: TokenIdentity;
|
|
113
|
+
try {
|
|
114
|
+
freshOwner = await fetchTokenIdentity(rotated.accessToken);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
await writeItem(parkedTarget(target.keychainItem), JSON.stringify({ claudeAiOauth: rotated }));
|
|
117
|
+
throw new Error(`refreshed the parked credential but cannot verify its owner (${e instanceof Error ? e.message : String(e)}) - kept the rotated token parked; the next check retries`);
|
|
118
|
+
}
|
|
119
|
+
if (freshOwner.accountUuid !== target.accountUuid) {
|
|
120
|
+
const owner = idx.accounts.find((a) => a.accountUuid === freshOwner.accountUuid) ?? null;
|
|
121
|
+
const kept = await keepRotatedPair({ fresh: rotated, owner, fallback: target, liveOwnerUuid: liveOwner?.accountUuid ?? null, expectedLiveToken, lock });
|
|
122
|
+
throw markDead(`parked credential belongs to ${describeIdentity(freshOwner)}, whose grant this refresh rotated - the rotated token ${kept}; re-auth with \`tokenmaxxing auth ${target.label}\``);
|
|
123
|
+
}
|
|
124
|
+
return rotated;
|
|
125
|
+
});
|
|
86
126
|
}
|
|
87
127
|
await writeItem(parkedTarget(target.keychainItem), JSON.stringify({ claudeAiOauth: fresh }));
|
|
88
128
|
}
|
|
@@ -117,8 +157,43 @@ export async function performSwap(target: Account): Promise<void> {
|
|
|
117
157
|
log("swap.done", { account: target.accountUuid.slice(0, 8), email: target.email });
|
|
118
158
|
}
|
|
119
159
|
|
|
160
|
+
export async function keepRotatedPair(input: {
|
|
161
|
+
fresh: OAuthCreds;
|
|
162
|
+
owner: Account | null;
|
|
163
|
+
fallback: Account;
|
|
164
|
+
liveOwnerUuid: string | null;
|
|
165
|
+
expectedLiveToken: string | null;
|
|
166
|
+
lock: { compromised: () => boolean };
|
|
167
|
+
}): Promise<string> {
|
|
168
|
+
const { fresh, owner } = input;
|
|
169
|
+
if (owner == null) {
|
|
170
|
+
await writeItem(parkedTarget(input.fallback.keychainItem), JSON.stringify({ claudeAiOauth: fresh }));
|
|
171
|
+
log("swap.rotated_kept_in_stamped_slot", { account: input.fallback.accountUuid.slice(0, 8) });
|
|
172
|
+
return "was kept in this account's stamped slot because its owner is not in the pool";
|
|
173
|
+
}
|
|
174
|
+
const park = async (): Promise<void> => {
|
|
175
|
+
await writeItem(parkedTarget(owner.keychainItem), JSON.stringify({ claudeAiOauth: fresh }));
|
|
176
|
+
log("swap.rotated_parked_rescued", { account: owner.accountUuid.slice(0, 8) });
|
|
177
|
+
};
|
|
178
|
+
if (input.liveOwnerUuid !== owner.accountUuid) {
|
|
179
|
+
await park();
|
|
180
|
+
return "was parked under that account";
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const currentLive = await readItem(liveTarget());
|
|
184
|
+
if (input.lock.compromised()) throw new Error("refresh lock compromised");
|
|
185
|
+
if (currentLive == null || parseBlob(currentLive).claudeAiOauth.accessToken !== input.expectedLiveToken) throw new Error("live credential changed while unlocked");
|
|
186
|
+
await writeItem(liveTarget(), mergeIntoLive(currentLive, fresh));
|
|
187
|
+
log("swap.rotated_live_rescued", { account: owner.accountUuid.slice(0, 8) });
|
|
188
|
+
return "was installed into the live store so that account's sessions continue";
|
|
189
|
+
} catch (e) {
|
|
190
|
+
await park();
|
|
191
|
+
return `was parked under that account (${e instanceof Error ? e.message : String(e)})`;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
120
195
|
export function isSkippableSwapError(e: unknown): boolean {
|
|
121
|
-
return e instanceof InvalidGrantError || e instanceof RefreshRejectedError;
|
|
196
|
+
return e instanceof InvalidGrantError || e instanceof RefreshRejectedError || e instanceof IdentityUnavailableError;
|
|
122
197
|
}
|
|
123
198
|
|
|
124
199
|
export async function chooseAndSwap(ctx: PickCtx, exclude: ReadonlySet<string> = new Set()): Promise<Account | null> {
|
package/src/lib/types.ts
CHANGED
|
@@ -41,21 +41,51 @@ export type UsageWindows = z.infer<typeof UsageWindowsSchema>;
|
|
|
41
41
|
const ModelInfoSchema = z.object({ id: z.string(), display: z.string() });
|
|
42
42
|
export type ModelInfo = z.infer<typeof ModelInfoSchema>;
|
|
43
43
|
|
|
44
|
+
const WindowAnchorSchema = z.object({
|
|
45
|
+
fiveHourResetsAt: z.number().nullable(),
|
|
46
|
+
sevenDayResetsAt: z.number().nullable(),
|
|
47
|
+
session: z.number(),
|
|
48
|
+
weekly: z.number(),
|
|
49
|
+
});
|
|
50
|
+
export type WindowAnchor = z.infer<typeof WindowAnchorSchema>;
|
|
51
|
+
|
|
44
52
|
export const UsageStateSchema = UsageWindowsSchema.extend({
|
|
45
53
|
account: z.string().nullable(),
|
|
46
54
|
ts: z.number(),
|
|
47
55
|
model: ModelInfoSchema.nullable().default(null),
|
|
56
|
+
anchor: WindowAnchorSchema.optional(),
|
|
57
|
+
sessionWindowWeeklyCost: z.number().positive().optional(),
|
|
58
|
+
sampledAt: z.number().optional(),
|
|
48
59
|
});
|
|
49
60
|
export type UsageState = z.infer<typeof UsageStateSchema>;
|
|
50
61
|
|
|
62
|
+
const FamilyAnchorSchema = z.object({
|
|
63
|
+
fiveHourResetsAt: z.number().nullable(),
|
|
64
|
+
session: z.number(),
|
|
65
|
+
caps: z.record(z.string(), z.number()),
|
|
66
|
+
});
|
|
67
|
+
export type FamilyAnchor = z.infer<typeof FamilyAnchorSchema>;
|
|
68
|
+
|
|
51
69
|
export const ModelUsageStateSchema = z.object({
|
|
52
70
|
perModel: z.record(z.string(), UsageWindowSchema).default({}),
|
|
53
71
|
account: z.string().nullable(),
|
|
54
72
|
ts: z.number(),
|
|
55
73
|
sampledAt: z.number().optional(),
|
|
74
|
+
anchor: FamilyAnchorSchema.optional(),
|
|
75
|
+
familyCosts: z.record(z.string(), z.number()).optional(),
|
|
56
76
|
});
|
|
57
77
|
export type ModelUsageState = z.infer<typeof ModelUsageStateSchema>;
|
|
58
78
|
|
|
79
|
+
export const BankedResetOutcomeSchema = z.enum(["reset", "already_used", "not_limited", "ineligible", "unavailable", "rate_limited", "auth_error", "error"]);
|
|
80
|
+
export type BankedResetOutcome = z.infer<typeof BankedResetOutcomeSchema>;
|
|
81
|
+
|
|
82
|
+
export const BankedResetRecordSchema = z.object({
|
|
83
|
+
outcome: BankedResetOutcomeSchema,
|
|
84
|
+
at: z.number(),
|
|
85
|
+
nextAvailableAt: z.number().nullable(),
|
|
86
|
+
});
|
|
87
|
+
export type BankedResetRecord = z.infer<typeof BankedResetRecordSchema>;
|
|
88
|
+
|
|
59
89
|
export const AccountSchema = z.object({
|
|
60
90
|
accountUuid: z.string(),
|
|
61
91
|
email: z.string(),
|
|
@@ -72,6 +102,8 @@ export const AccountSchema = z.object({
|
|
|
72
102
|
needsReauth: z.boolean().optional(),
|
|
73
103
|
subscriptionType: z.string().optional(),
|
|
74
104
|
rateLimitTier: z.string().optional(),
|
|
105
|
+
sessionWindowWeeklyCost: z.number().positive().optional(),
|
|
106
|
+
bankedReset: BankedResetRecordSchema.optional(),
|
|
75
107
|
});
|
|
76
108
|
export type Account = z.infer<typeof AccountSchema>;
|
|
77
109
|
|
|
@@ -88,6 +120,7 @@ export const NextCheckSchema = z.object({ dueAt: z.number(), ts: z.number() });
|
|
|
88
120
|
|
|
89
121
|
export const EnforcedLimitSchema = z.object({
|
|
90
122
|
account: z.string(),
|
|
123
|
+
kind: z.enum(["session", "weekly", "model"]),
|
|
91
124
|
family: z.string().nullable(),
|
|
92
125
|
resetsAt: z.number().nullable(),
|
|
93
126
|
windowMs: z.number(),
|
|
@@ -112,6 +145,10 @@ export const ScreeningThresholdsSchema = z.object({
|
|
|
112
145
|
weekly: z.number().min(0).max(100),
|
|
113
146
|
});
|
|
114
147
|
|
|
148
|
+
export const BankedResetProviderSchema = z.enum(["claude", "codex"]);
|
|
149
|
+
export type BankedResetProvider = z.infer<typeof BankedResetProviderSchema>;
|
|
150
|
+
export const BankedResetProvidersSchema = z.array(BankedResetProviderSchema);
|
|
151
|
+
|
|
115
152
|
export const ConfigSchema = z
|
|
116
153
|
.object({
|
|
117
154
|
thresholds: ScreeningThresholdsSchema,
|
|
@@ -126,6 +163,7 @@ export const ConfigSchema = z
|
|
|
126
163
|
usagePollTtlMs: z.number().int().positive(),
|
|
127
164
|
maxWaitMs: z.number().int().positive(),
|
|
128
165
|
checkIntervalMs: z.number().int().min(10_000),
|
|
166
|
+
preferToUseBankedReset: BankedResetProvidersSchema,
|
|
129
167
|
}),
|
|
130
168
|
})
|
|
131
169
|
.refine((cfg) => cfg.policy.projectionMargin < Math.min(...cfg.thresholds.session, cfg.thresholds.weekly), {
|
|
@@ -143,6 +181,7 @@ export const RespawnMarkerSchema = z.object({
|
|
|
143
181
|
sessionId: z.string(),
|
|
144
182
|
prompt: z.string().optional(),
|
|
145
183
|
launchedAt: z.number().optional(),
|
|
184
|
+
reset: z.boolean().optional(),
|
|
146
185
|
});
|
|
147
186
|
|
|
148
187
|
export const RateLimitsStdinSchema = z.looseObject({
|
|
@@ -242,9 +281,21 @@ export const CodexUsageSchema = z.object({
|
|
|
242
281
|
planType: z.string().nullable(),
|
|
243
282
|
aggregate: z.array(CodexWindowSchema),
|
|
244
283
|
perLimit: z.record(z.string(), z.array(CodexWindowSchema)),
|
|
284
|
+
resetCredits: z.number().int().nullable(),
|
|
285
|
+
reachedType: z.string().nullable(),
|
|
245
286
|
});
|
|
246
287
|
export type CodexUsage = z.infer<typeof CodexUsageSchema>;
|
|
247
288
|
|
|
289
|
+
export const CodexBankedResetOutcomeSchema = z.enum(["reset", "nothing_to_reset", "no_credit", "already_redeemed", "auth_error", "error"]);
|
|
290
|
+
export type CodexBankedResetOutcome = z.infer<typeof CodexBankedResetOutcomeSchema>;
|
|
291
|
+
|
|
292
|
+
export const CodexBankedResetRecordSchema = z.object({
|
|
293
|
+
outcome: CodexBankedResetOutcomeSchema,
|
|
294
|
+
at: z.number(),
|
|
295
|
+
key: z.string().optional(),
|
|
296
|
+
});
|
|
297
|
+
export type CodexBankedResetRecord = z.infer<typeof CodexBankedResetRecordSchema>;
|
|
298
|
+
|
|
248
299
|
const BareFileNameSchema = z
|
|
249
300
|
.string()
|
|
250
301
|
.refine((s) => s.length > 0 && s !== "." && s !== ".." && !s.includes("/") && !s.includes("\\"), {
|
|
@@ -263,9 +314,12 @@ export const CodexAccountSchema = z.object({
|
|
|
263
314
|
.object({
|
|
264
315
|
aggregate: z.array(CodexWindowSchema),
|
|
265
316
|
perLimit: z.record(z.string(), z.array(CodexWindowSchema)),
|
|
317
|
+
resetCredits: z.number().int().nullable().optional(),
|
|
318
|
+
reachedType: z.string().nullable().optional(),
|
|
266
319
|
})
|
|
267
320
|
.optional(),
|
|
268
321
|
lastUsageAt: z.number().optional(),
|
|
322
|
+
bankedReset: CodexBankedResetRecordSchema.optional(),
|
|
269
323
|
});
|
|
270
324
|
export type CodexAccount = z.infer<typeof CodexAccountSchema>;
|
|
271
325
|
|