tokenmaxxing 1.123.0 → 1.125.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 -2
- package/src/cli/check.ts +3 -1
- package/src/cli/status.ts +6 -1
- package/src/lib/claude.ts +17 -9
- package/src/lib/claudebin.ts +17 -2
- package/src/lib/codex.ts +7 -2
- package/src/lib/codexusage.ts +41 -10
- package/src/lib/decide.ts +51 -5
- package/src/lib/picker.ts +15 -1
- package/src/lib/provider.ts +3 -0
- package/src/lib/sample.ts +8 -3
- package/src/lib/statusonly.ts +1 -0
- package/src/lib/types.ts +7 -1
- package/src/lib/usage.ts +44 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.125.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",
|
|
@@ -33,7 +33,6 @@
|
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/bun": "1.3.14",
|
|
36
|
-
"lefthook": "^2.1.14",
|
|
37
36
|
"typescript": "^5.6.0"
|
|
38
37
|
},
|
|
39
38
|
"dependencies": {
|
package/src/cli/check.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { claude } from "../lib/claude.ts";
|
|
2
|
-
import {
|
|
2
|
+
import { codex } from "../lib/codex.ts";
|
|
3
|
+
import { evaluateAndMaybeSwap, useBankedResets } from "../lib/decide.ts";
|
|
3
4
|
import { sampleOldest } from "../lib/sample.ts";
|
|
4
5
|
import { pruneStaleSessions } from "../lib/sessions.ts";
|
|
5
6
|
import { loadConfig } from "../lib/state.ts";
|
|
@@ -13,6 +14,7 @@ export async function cmdCheck(json = false): Promise<number> {
|
|
|
13
14
|
try {
|
|
14
15
|
pruneStaleSessions(now);
|
|
15
16
|
d = await evaluateAndMaybeSwap(claude, now);
|
|
17
|
+
await useBankedResets(codex, loadConfig(), now);
|
|
16
18
|
await sampleOldest(loadConfig());
|
|
17
19
|
await maybeAutoUpdate();
|
|
18
20
|
} catch (e) {
|
package/src/cli/status.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { opencodeGo } from "../lib/opencodego.ts";
|
|
|
6
6
|
import { loadAccounts, loadConfig, saveAccounts } from "../lib/state.ts";
|
|
7
7
|
import { withLock } from "../lib/lock.ts";
|
|
8
8
|
import { codexPool, grokPool, opencodeGoPool } from "../lib/paths.ts";
|
|
9
|
-
import { barFor, earliestReset, gatedWindows, landWindows, isExhausted, isSessionWindow, limitWindows, liveUsed, nextWeeklyReset, sessionWindow, thresholdBars, weeklyWindow } from "../lib/picker.ts";
|
|
9
|
+
import { barFor, earliestReset, gatedWindows, landGrant, landWindows, isExhausted, isSessionWindow, limitWindows, liveUsed, nextWeeklyReset, sessionWindow, thresholdBars, weeklyWindow } from "../lib/picker.ts";
|
|
10
10
|
import type { Provider, SampleReport } from "../lib/provider.ts";
|
|
11
11
|
import { bar, c, count, emitJson, fmtAgo } from "./render.ts";
|
|
12
12
|
import type { Account, AccountThresholds, Bars, Config, Thresholds, Window } from "../lib/types.ts";
|
|
@@ -121,6 +121,11 @@ async function collect(p: Provider, cfg: Config, now: number, cached: boolean):
|
|
|
121
121
|
if (a.tier != null) stored.tier = a.tier;
|
|
122
122
|
dirty = true;
|
|
123
123
|
}
|
|
124
|
+
const report = reports.get(a.id);
|
|
125
|
+
if (report?.ok === true && report.source === "probe" && a.bankedResetAt != null) {
|
|
126
|
+
landGrant(stored, a.bankedReset, a.bankedResetAt);
|
|
127
|
+
dirty = true;
|
|
128
|
+
}
|
|
124
129
|
}
|
|
125
130
|
if (dirty) saveAccounts(p.pool, fresh);
|
|
126
131
|
idx = fresh;
|
package/src/lib/claude.ts
CHANGED
|
@@ -9,14 +9,14 @@ import { withLock } from "./lock.ts";
|
|
|
9
9
|
import { errorMessage, log } from "./log.ts";
|
|
10
10
|
import { claudeTierLabel, describeIdentity, fetchTokenIdentity, isDeadCredential, InvalidGrantError } from "./oauth.ts";
|
|
11
11
|
import { claudePool, env, paths, seatFromEnv, storeDirFor } from "./paths.ts";
|
|
12
|
-
import { MAX_WAITERS_PER_ACCOUNT,
|
|
12
|
+
import { MAX_WAITERS_PER_ACCOUNT, pickBest, pickEarliestReset, pickWaitTarget, thresholdBars, type PickCtx } from "./picker.ts";
|
|
13
13
|
import { deletePiStore } from "./piauth.ts";
|
|
14
14
|
import { livingPresences, writePresence } from "./presence.ts";
|
|
15
|
-
import { StoreUnusableError, type Observation, type Provider, type SampleReport, type SeatBorrow } from "./provider.ts";
|
|
16
|
-
import { claimSample, foldTee, readyToSample, runSample, sampleAccountUsage, sampleDue, sampleIntervalMs, teeObservation, usageBlockedUntil } from "./sample.ts";
|
|
15
|
+
import { StoreUnusableError, type Observation, type Provider, type ResetClaim, type SampleReport, type SeatBorrow } from "./provider.ts";
|
|
16
|
+
import { claimSample, foldTee, landRead, readyToSample, runSample, sampleAccountUsage, sampleDue, sampleIntervalMs, teeObservation, usageBlockedUntil } from "./sample.ts";
|
|
17
17
|
import { clearUsageSnapshot, liveWaitClaims, loadAccounts, loadConfig, loadUsageSnapshot, pinBinOverride, readJsonFile, saveAccounts, type Harvest } from "./state.ts";
|
|
18
18
|
import { saveTermios, restoreTermios } from "./tty.ts";
|
|
19
|
-
import { fetchUsageDirect, gatedFamilies, mergeWindows, modelFromFlag, scrubCredEnv, windowsOf } from "./usage.ts";
|
|
19
|
+
import { claimResetGrant, fetchUsageDirect, gatedFamilies, mergeWindows, modelFromFlag, scrubCredEnv, windowsOf } from "./usage.ts";
|
|
20
20
|
import { CredentialBlobSchema, JsonTextSchema, OAuthAccountSchema, type Account, type Config, type ModelInfo } from "./types.ts";
|
|
21
21
|
import { c } from "../cli/render.ts";
|
|
22
22
|
|
|
@@ -48,9 +48,7 @@ async function observeLive(account: Account, cfg: Config, now: number, opts: { p
|
|
|
48
48
|
if (account.needsReauth) a.needsReauth = true;
|
|
49
49
|
if (outcome.ok) {
|
|
50
50
|
a.probeFails = 0;
|
|
51
|
-
|
|
52
|
-
landWindows(a, mergeWindows(windowsOf(outcome.usage, startedAt), a.windows), startedAt, thresholdBars(cfg));
|
|
53
|
-
}
|
|
51
|
+
landRead(a, outcome.usage, startedAt, thresholdBars(cfg));
|
|
54
52
|
} else {
|
|
55
53
|
a.probeFails = (a.probeFails ?? 0) + 1;
|
|
56
54
|
if (outcome.retryAt != null) a.usageRetryAt = outcome.retryAt;
|
|
@@ -87,14 +85,14 @@ async function samplePool(accounts: Account[], _liveId: string | null, now: numb
|
|
|
87
85
|
return;
|
|
88
86
|
}
|
|
89
87
|
a.lastProbeAt = now;
|
|
88
|
+
const startedAt = Date.now();
|
|
90
89
|
const outcome = await runSample(a, ready);
|
|
91
90
|
if (!outcome.ok) {
|
|
92
91
|
if (outcome.retryAt != null) a.usageRetryAt = outcome.retryAt;
|
|
93
92
|
reports.set(a.id, { ok: false, reason: outcome.reason });
|
|
94
93
|
return;
|
|
95
94
|
}
|
|
96
|
-
|
|
97
|
-
landWindows(a, mergeWindows(windowsOf(outcome.usage, at), a.windows), at, bars);
|
|
95
|
+
landRead(a, outcome.usage, startedAt, bars);
|
|
98
96
|
reports.set(a.id, { ok: true, source: "probe" });
|
|
99
97
|
}),
|
|
100
98
|
);
|
|
@@ -122,6 +120,15 @@ async function prepareMove(target: Account): Promise<void> {
|
|
|
122
120
|
log("move.prepared", { account: target.id.slice(0, 8), label: target.label });
|
|
123
121
|
}
|
|
124
122
|
|
|
123
|
+
async function useBankedReset(a: Account, signal: AbortSignal): Promise<ResetClaim> {
|
|
124
|
+
const grant = a.bankedReset?.grant;
|
|
125
|
+
const organizationUuid = a.oauthAccount?.organizationUuid;
|
|
126
|
+
if (grant == null || organizationUuid == null) return { reset: false, detail: "no grant id or organization on the record" };
|
|
127
|
+
const creds = await readStore(a.id);
|
|
128
|
+
if (creds == null || isDeadCredential(creds) || creds.expiresAt <= Date.now()) return { reset: false, detail: "no unexpired access token in the store" };
|
|
129
|
+
return claimResetGrant({ accessToken: creds.accessToken, organizationUuid, grant }, signal);
|
|
130
|
+
}
|
|
131
|
+
|
|
125
132
|
async function storeUsable(a: Account): Promise<boolean> {
|
|
126
133
|
try {
|
|
127
134
|
const creds = await readStore(a.id);
|
|
@@ -339,6 +346,7 @@ export const claude: Provider = {
|
|
|
339
346
|
samplePool,
|
|
340
347
|
mergeWindows,
|
|
341
348
|
swap: prepareMove,
|
|
349
|
+
useBankedReset,
|
|
342
350
|
classifySwapError: (e) => (e instanceof InvalidGrantError ? "dead-grant" : e instanceof StoreUnusableError ? "skip" : "fatal"),
|
|
343
351
|
removeCredentials,
|
|
344
352
|
storeUsable,
|
package/src/lib/claudebin.ts
CHANGED
|
@@ -85,11 +85,15 @@ export function resolveRealBin(input: { name: string; key: BinKey }): string {
|
|
|
85
85
|
throw new Error(`could not locate the real \`${input.name}\` binary (set ${input.key} in config.json)`);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
function spawnVersion(bin: string) {
|
|
89
89
|
const env = { ...process.env, [WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH), TOKENMAXXING_PROBE: "1" };
|
|
90
|
+
return Bun.spawnSync([bin, "--version"], { env, stdout: "pipe", stderr: "pipe", timeout: 15_000, killSignal: "SIGKILL" });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function verifyRealBin(input: { bin: string; name: string; versionOk: (versionOutput: string) => boolean }): string | null {
|
|
90
94
|
let p;
|
|
91
95
|
try {
|
|
92
|
-
p =
|
|
96
|
+
p = spawnVersion(input.bin);
|
|
93
97
|
} catch (e) {
|
|
94
98
|
return errorMessage(e);
|
|
95
99
|
}
|
|
@@ -115,6 +119,17 @@ function isBareVersion(out: string): boolean {
|
|
|
115
119
|
|
|
116
120
|
export const PI_BIN = { name: "pi", key: "piBin", versionOk: isBareVersion } as const;
|
|
117
121
|
|
|
122
|
+
let userAgent: string | undefined;
|
|
123
|
+
|
|
124
|
+
export function claudeUserAgent(): string {
|
|
125
|
+
if (userAgent != null) return userAgent;
|
|
126
|
+
const bin = resolveRealBin(CLAUDE_BIN);
|
|
127
|
+
const version = spawnVersion(bin).stdout.toString().trim().split(" ")[0] ?? "";
|
|
128
|
+
if (!isBareVersion(version)) throw new Error(`\`${bin} --version\` printed no version`);
|
|
129
|
+
userAgent = `claude-cli/${version} (external, cli)`;
|
|
130
|
+
return userAgent;
|
|
131
|
+
}
|
|
132
|
+
|
|
118
133
|
export function resolveVerifiedClaude(): string {
|
|
119
134
|
const candidates: string[] = [];
|
|
120
135
|
try {
|
package/src/lib/codex.ts
CHANGED
|
@@ -8,12 +8,12 @@ import { codexIdentityOf, codexStoreUsable, deleteCodexStoreAuth, ensureCodexSto
|
|
|
8
8
|
import { CodexInvalidGrantError, CodexRefreshFailedError, refreshCodexAuth } from "./codexoauth.ts";
|
|
9
9
|
import { deletePiStore } from "./piauth.ts";
|
|
10
10
|
import { PI_PRESENCE_PREFIX, livingPresences, seatCounts, writePresence } from "./presence.ts";
|
|
11
|
-
import { CodexUsageReadError, codexLimitLabel, fetchCodexUsage } from "./codexusage.ts";
|
|
11
|
+
import { CodexUsageReadError, codexLimitLabel, consumeCodexReset, fetchCodexUsage } from "./codexusage.ts";
|
|
12
12
|
import { codexSupervisorLink, ensurePathInRc, installCodexSupervisor, managedShellRcSkipLines, shellRcPath } from "./install.ts";
|
|
13
13
|
import { withLock } from "./lock.ts";
|
|
14
14
|
import { errorMessage, log } from "./log.ts";
|
|
15
15
|
import { codexPaths, codexPool, codexSeatFromEnv, optionalEnv } from "./paths.ts";
|
|
16
|
-
import { isExhausted, onCredits, pickBest, pickEarliestReset, thresholdBars, type PickCtx } from "./picker.ts";
|
|
16
|
+
import { isExhausted, landGrant, onCredits, pickBest, pickEarliestReset, thresholdBars, type PickCtx } from "./picker.ts";
|
|
17
17
|
import { StoreUnusableError, type Observation, type Provider, type SampleReport, type SeatBorrow } from "./provider.ts";
|
|
18
18
|
import { loadAccounts, loadConfig, pinBinOverride, saveAccounts, type Harvest } from "./state.ts";
|
|
19
19
|
import { restoreTermios, saveTermios } from "./tty.ts";
|
|
@@ -34,6 +34,7 @@ function applyUsage(account: Account, usage: CodexUsage, at: number): void {
|
|
|
34
34
|
account.windows = usage.windows;
|
|
35
35
|
account.lastUsageAt = at;
|
|
36
36
|
account.hasCredits = usage.hasCredits ?? undefined;
|
|
37
|
+
landGrant(account, usage.bankedReset, at);
|
|
37
38
|
if (usage.email != null) account.email = usage.email;
|
|
38
39
|
if (usage.planType != null) account.tier = usage.planType;
|
|
39
40
|
}
|
|
@@ -300,6 +301,10 @@ export const codex: Provider = {
|
|
|
300
301
|
samplePool,
|
|
301
302
|
mergeWindows: (next) => next,
|
|
302
303
|
swap: prepareMove,
|
|
304
|
+
useBankedReset: async (a, signal) => {
|
|
305
|
+
const fresh = await freshCodexAuth(a, Date.now(), false);
|
|
306
|
+
return fresh.ok ? consumeCodexReset(fresh.auth, signal) : { reset: false, detail: fresh.reason };
|
|
307
|
+
},
|
|
303
308
|
classifySwapError: (e) => (e instanceof CodexInvalidGrantError ? "dead-grant" : e instanceof StoreUnusableError ? "skip" : "fatal"),
|
|
304
309
|
removeCredentials: async (a) => {
|
|
305
310
|
deleteCodexStoreAuth(a.id);
|
package/src/lib/codexusage.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { http, safeErrorDetail } from "./http.ts";
|
|
3
|
-
import { errorMessage } from "./log.ts";
|
|
3
|
+
import { errorMessage, log } from "./log.ts";
|
|
4
4
|
import { env } from "./paths.ts";
|
|
5
|
-
import { EpochSecondsSchema, JsonTextSchema, type CodexAuthJson, type CodexUsage, type Window } from "./types.ts";
|
|
5
|
+
import { EpochSecondsSchema, JsonTextSchema, type BankedReset, type CodexAuthJson, type CodexUsage, type Window } from "./types.ts";
|
|
6
6
|
import { codexIdentityOf } from "./codexauth.ts";
|
|
7
|
+
import type { ResetClaim } from "./provider.ts";
|
|
7
8
|
import { familyTokens } from "./usage.ts";
|
|
8
9
|
|
|
9
10
|
export const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
@@ -33,6 +34,7 @@ const WireUsageSchema = z.looseObject({
|
|
|
33
34
|
plan_type: z.string().nullish(),
|
|
34
35
|
rate_limit: WireRateLimitSchema.nullish(),
|
|
35
36
|
credits: z.looseObject({ has_credits: z.boolean().nullish() }).nullish(),
|
|
37
|
+
rate_limit_reset_credits: z.unknown().optional(),
|
|
36
38
|
additional_rate_limits: z
|
|
37
39
|
.array(z.looseObject({ limit_name: z.string(), rate_limit: WireRateLimitSchema.nullish() }))
|
|
38
40
|
.nullish(),
|
|
@@ -53,18 +55,19 @@ function toWindows(rateLimit: z.infer<typeof WireRateLimitSchema> | null | undef
|
|
|
53
55
|
return out;
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
function codexHeaders(auth: CodexAuthJson): Record<string, string> {
|
|
59
|
+
return {
|
|
60
|
+
Authorization: `Bearer ${auth.tokens.access_token}`,
|
|
61
|
+
"ChatGPT-Account-Id": codexIdentityOf({ auth }).accountId,
|
|
62
|
+
"User-Agent": "codex-cli",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
56
66
|
export async function fetchCodexUsage(input: { auth: CodexAuthJson; at: number }): Promise<CodexUsage> {
|
|
57
67
|
const { auth, at } = input;
|
|
58
|
-
const identity = codexIdentityOf({ auth });
|
|
59
68
|
let res: Response;
|
|
60
69
|
try {
|
|
61
|
-
res = await http.get(USAGE_URL, {
|
|
62
|
-
headers: {
|
|
63
|
-
Authorization: `Bearer ${auth.tokens.access_token}`,
|
|
64
|
-
"ChatGPT-Account-Id": identity.accountId,
|
|
65
|
-
"User-Agent": "codex-cli",
|
|
66
|
-
},
|
|
67
|
-
});
|
|
70
|
+
res = await http.get(USAGE_URL, { headers: codexHeaders(auth) });
|
|
68
71
|
} catch (e) {
|
|
69
72
|
throw new CodexUsageReadError(`endpoint unreachable: ${errorMessage(e)}`);
|
|
70
73
|
}
|
|
@@ -83,6 +86,7 @@ export async function fetchCodexUsage(input: { auth: CodexAuthJson; at: number }
|
|
|
83
86
|
email: wire.email ?? null,
|
|
84
87
|
planType: wire.plan_type ?? null,
|
|
85
88
|
hasCredits: wire.credits?.has_credits ?? null,
|
|
89
|
+
bankedReset: resetCredits(wire.rate_limit_reset_credits),
|
|
86
90
|
windows: [
|
|
87
91
|
...toWindows(wire.rate_limit, null, at),
|
|
88
92
|
...(wire.additional_rate_limits ?? []).flatMap((row) => toWindows(row.rate_limit, row.limit_name, at)),
|
|
@@ -90,6 +94,33 @@ export async function fetchCodexUsage(input: { auth: CodexAuthJson; at: number }
|
|
|
90
94
|
};
|
|
91
95
|
}
|
|
92
96
|
|
|
97
|
+
const ResetCreditsSchema = z.looseObject({ available_count: z.number() });
|
|
98
|
+
|
|
99
|
+
function resetCredits(raw: unknown): BankedReset | undefined {
|
|
100
|
+
if (raw == null) return undefined;
|
|
101
|
+
const block = ResetCreditsSchema.safeParse(raw);
|
|
102
|
+
if (!block.success) {
|
|
103
|
+
log("codexusage.reset_credits_unreadable", { issue: z.prettifyError(block.error).slice(0, 200) });
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
return block.data.available_count > 0 ? { grant: null } : undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const RESET_URL = env("TOKENMAXXING_CODEX_RESET_URL", "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume");
|
|
110
|
+
const ConsumeResponseSchema = z.looseObject({ code: z.string() });
|
|
111
|
+
|
|
112
|
+
export async function consumeCodexReset(auth: CodexAuthJson, signal: AbortSignal): Promise<ResetClaim> {
|
|
113
|
+
const res = await http.post(RESET_URL, {
|
|
114
|
+
headers: codexHeaders(auth),
|
|
115
|
+
json: { redeem_request_id: crypto.randomUUID() },
|
|
116
|
+
signal,
|
|
117
|
+
});
|
|
118
|
+
const text = await res.text();
|
|
119
|
+
if (!res.ok) return { reset: false, detail: `HTTP ${res.status}: ${safeErrorDetail({ text })}` };
|
|
120
|
+
const code = ConsumeResponseSchema.safeParse(JsonTextSchema.safeParse(text).data).data?.code;
|
|
121
|
+
return { reset: code === "reset", detail: code ?? "unreadable body" };
|
|
122
|
+
}
|
|
123
|
+
|
|
93
124
|
const LIMIT_LABEL_ABBREVIATIONS = new Map([["reserve", "rsrv"]]);
|
|
94
125
|
|
|
95
126
|
export function codexLimitLabel(limitName: string): string {
|
package/src/lib/decide.ts
CHANGED
|
@@ -2,11 +2,11 @@ import { countBy } from "es-toolkit";
|
|
|
2
2
|
import { CLAUDE_COMPACT_KILL_MS } from "./compact.ts";
|
|
3
3
|
import { withLock } from "./lock.ts";
|
|
4
4
|
import { loadAccounts, loadConfig, liveWaitClaims, releaseWaitClaim, replaceWaitClaim, saveAccounts } from "./state.ts";
|
|
5
|
-
import { MAX_WAITERS_PER_ACCOUNT, isExhausted, landWindows, limitWindows, liveUsed, nextWeeklyReset, onCredits, pickBest, pickWaitTarget, screensUntilReset, sessionWindow, thresholdBars, weeklyWindow, type PickCtx } from "./picker.ts";
|
|
5
|
+
import { MAX_WAITERS_PER_ACCOUNT, isExhausted, landWindows, limitWindows, liveUsed, nextWeeklyReset, onCredits, pickBest, pickWaitTarget, screensUntilReset, sessionWindow, thresholdBars, wantsBankedReset, weeklyWindow, type PickCtx } from "./picker.ts";
|
|
6
6
|
import { familyTokens } from "./usage.ts";
|
|
7
7
|
import { errorMessage, log } from "./log.ts";
|
|
8
|
-
import type { Observation, Provider } from "./provider.ts";
|
|
9
|
-
import type { Account, Bars, EnforcedLimit } from "./types.ts";
|
|
8
|
+
import type { Observation, Provider, ResetClaim } from "./provider.ts";
|
|
9
|
+
import type { Account, Bars, Config, EnforcedLimit } from "./types.ts";
|
|
10
10
|
|
|
11
11
|
export type SwapDecision = { swapped: boolean; account: Account | null; reason: string; waitUntil?: number };
|
|
12
12
|
|
|
@@ -58,11 +58,58 @@ function enforcedWall(limit: EnforcedLimit, account: Account, now: number): numb
|
|
|
58
58
|
return limit.resetsAt ?? cachedReset ?? now + (span === "session" ? FIVE_HOURS_MS : WEEK_MS);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
async function foldLive(p: Provider, a: Account, cfg: Config, now: number, bars: Bars): Promise<Observation | null> {
|
|
62
|
+
const obs = await p.observeLive(a, cfg, now, { probe: false, perModel: false });
|
|
63
|
+
if (obs && (a.lastUsageAt == null || obs.at > a.lastUsageAt)) landWindows(a, p.mergeWindows(obs.windows, a.windows), obs.at, bars);
|
|
64
|
+
return obs;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const CLAIM_DEADLINE_MS = 25_000;
|
|
68
|
+
const CLAIM_HOLD_MS = 60_000;
|
|
69
|
+
|
|
70
|
+
export async function useBankedResets(p: Provider, cfg: Config, now: number): Promise<void> {
|
|
71
|
+
const bars = thresholdBars(cfg);
|
|
72
|
+
const wanted = async (accounts: Account[]): Promise<Account[]> => {
|
|
73
|
+
const held = accounts.filter((a) => a.bankedReset != null);
|
|
74
|
+
for (const a of held) await foldLive(p, a, cfg, now, bars);
|
|
75
|
+
return held.filter((a) => wantsBankedReset(a, bars, now));
|
|
76
|
+
};
|
|
77
|
+
if ((await wanted(loadAccounts(p.pool).accounts)).length === 0) return;
|
|
78
|
+
const due = await withLock(p.pool.lockFile, async () => {
|
|
79
|
+
const idx = loadAccounts(p.pool);
|
|
80
|
+
const picked = await wanted(idx.accounts);
|
|
81
|
+
const claims = picked.map((a) => ({ ...a }));
|
|
82
|
+
const until = Date.now() + CLAIM_HOLD_MS;
|
|
83
|
+
for (const a of picked) {
|
|
84
|
+
a.bankedReset = undefined;
|
|
85
|
+
a.bankedResetAt = until;
|
|
86
|
+
}
|
|
87
|
+
if (picked.length > 0) saveAccounts(p.pool, idx);
|
|
88
|
+
return claims;
|
|
89
|
+
});
|
|
90
|
+
const signal = AbortSignal.timeout(CLAIM_DEADLINE_MS);
|
|
91
|
+
await Promise.all(
|
|
92
|
+
due.map(async (a) => {
|
|
93
|
+
const claim = await p.useBankedReset(a, signal).catch((e: unknown): ResetClaim => ({ reset: false, detail: errorMessage(e) }));
|
|
94
|
+
log("decide.banked_reset", { account: a.id.slice(0, 8), reset: claim.reset, detail: claim.detail.slice(0, 200) });
|
|
95
|
+
if (!claim.reset) return;
|
|
96
|
+
await withLock(p.pool.lockFile, () => {
|
|
97
|
+
const idx = loadAccounts(p.pool);
|
|
98
|
+
const stored = idx.accounts.find((x) => x.id === a.id);
|
|
99
|
+
if (!stored) return;
|
|
100
|
+
landWindows(stored, p.mergeWindows([], stored.windows), Date.now(), bars);
|
|
101
|
+
saveAccounts(p.pool, idx);
|
|
102
|
+
});
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
61
107
|
export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRespawn = false, enforced: EnforcedLimit | null = null, opts: EvalOpts = {}): Promise<SwapDecision> {
|
|
62
108
|
const activeId = opts.seatId === undefined ? p.liveId() : opts.seatId;
|
|
63
109
|
const waiterId = canRespawn ? opts.waiterId : undefined;
|
|
64
110
|
const cfg = loadConfig();
|
|
65
111
|
const bars = thresholdBars(cfg);
|
|
112
|
+
if (enforced == null) await useBankedResets(p, cfg, now);
|
|
66
113
|
const stored0 = loadAccounts(p.pool).accounts.find((a) => a.id === activeId);
|
|
67
114
|
const walled0 = stored0?.enforcedUntil != null && stored0.enforcedUntil > now;
|
|
68
115
|
const gated = opts.sessionFamilies ?? p.gatedFamilies(cfg);
|
|
@@ -106,9 +153,8 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
|
|
|
106
153
|
|
|
107
154
|
let obs2: Observation | null = null;
|
|
108
155
|
for (const a of idx.accounts) {
|
|
109
|
-
const obs = await p
|
|
156
|
+
const obs = await foldLive(p, a, cfg, now, bars);
|
|
110
157
|
if (a === active) obs2 = obs;
|
|
111
|
-
if (obs && (a.lastUsageAt == null || obs.at > a.lastUsageAt)) landWindows(a, p.mergeWindows(obs.windows, a.windows), obs.at, bars);
|
|
112
158
|
}
|
|
113
159
|
saveAccounts(p.pool, idx);
|
|
114
160
|
|
package/src/lib/picker.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { maxBy, minBy, sortBy } from "es-toolkit";
|
|
2
2
|
import { familyTokens } from "./usage.ts";
|
|
3
|
-
import type { Account, Bars, Config, Thresholds, Window } from "./types.ts";
|
|
3
|
+
import type { Account, BankedReset, Bars, Config, Thresholds, Window } from "./types.ts";
|
|
4
4
|
|
|
5
5
|
export function thresholdBars(cfg: Config): Bars {
|
|
6
6
|
const bars = (t: Thresholds): Thresholds => ({ session: t.session - cfg.policy.projectionMargin, weekly: t.weekly });
|
|
@@ -114,6 +114,20 @@ export function weeklyExpiry(a: Account, now: number): number {
|
|
|
114
114
|
return nextWeeklyReset(weeklyWindow(a)?.resetsAt ?? null, now) ?? Number.POSITIVE_INFINITY;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
const BANKED_RESET_LEAD_MS = 3 * 24 * 60 * 60 * 1000;
|
|
118
|
+
|
|
119
|
+
export function wantsBankedReset(a: Account, bars: Bars, now: number): boolean {
|
|
120
|
+
const weekly = weeklyWindow(a);
|
|
121
|
+
const left = weeklyExpiry(a, now) - now;
|
|
122
|
+
return a.bankedReset != null && weekly != null && liveUsed(weekly, now) >= barFor(a, weekly, bars, now) && Number.isFinite(left) && left > BANKED_RESET_LEAD_MS;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function landGrant(a: Account, grant: BankedReset | undefined, sentAt: number): void {
|
|
126
|
+
if (sentAt <= (a.bankedResetAt ?? 0)) return;
|
|
127
|
+
a.bankedReset = grant;
|
|
128
|
+
a.bankedResetAt = sentAt;
|
|
129
|
+
}
|
|
130
|
+
|
|
117
131
|
export function earliestReset(a: Account, now: number): number {
|
|
118
132
|
const session = sessionWindow(a)?.resetsAt;
|
|
119
133
|
return Math.min(session != null && session > now ? session : Number.POSITIVE_INFINITY, weeklyExpiry(a, now));
|
package/src/lib/provider.ts
CHANGED
|
@@ -8,6 +8,8 @@ export type SampleReport = { ok: true; source: "statusline" | "probe" | "cached"
|
|
|
8
8
|
|
|
9
9
|
export type SwapFailure = "dead-grant" | "skip" | "fatal";
|
|
10
10
|
|
|
11
|
+
export type ResetClaim = { reset: boolean; detail: string };
|
|
12
|
+
|
|
11
13
|
export type SeatBorrow = { store: string; id: string; reused: boolean } | { denied: string } | null;
|
|
12
14
|
|
|
13
15
|
export class StoreUnusableError extends Error {
|
|
@@ -31,6 +33,7 @@ export type Provider = {
|
|
|
31
33
|
samplePool(accounts: Account[], liveId: string | null, now: number): Promise<Map<string, SampleReport>>;
|
|
32
34
|
mergeWindows(next: Window[], prev: Window[]): Window[];
|
|
33
35
|
swap(target: Account): Promise<void>;
|
|
36
|
+
useBankedReset(account: Account, signal: AbortSignal): Promise<ResetClaim>;
|
|
34
37
|
classifySwapError(e: unknown): SwapFailure;
|
|
35
38
|
removeCredentials(account: Account): Promise<void>;
|
|
36
39
|
storeUsable(account: Account): Promise<boolean>;
|
package/src/lib/sample.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { withLock } from "./lock.ts";
|
|
|
7
7
|
import { errorMessage, log } from "./log.ts";
|
|
8
8
|
import { claudeTierLabel, isDeadCredential } from "./oauth.ts";
|
|
9
9
|
import { claudePool, paths, storeDirFor } from "./paths.ts";
|
|
10
|
-
import { barFor, gatedWindows, landWindows, liveUsed, thresholdBars } from "./picker.ts";
|
|
10
|
+
import { barFor, gatedWindows, landGrant, landWindows, liveUsed, thresholdBars } from "./picker.ts";
|
|
11
11
|
import { seatCounts } from "./presence.ts";
|
|
12
12
|
import type { Observation } from "./provider.ts";
|
|
13
13
|
import { loadAccounts, loadUsageSnapshot, saveAccounts } from "./state.ts";
|
|
@@ -60,6 +60,11 @@ export function teeObservation(account: Account): Observation | null {
|
|
|
60
60
|
return { windows: mergeWindows(aggregate, account.windows), at };
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
export function landRead(account: Account, usage: UsageWindows, at: number, thresholds: Bars): void {
|
|
64
|
+
if (account.lastUsageAt == null || at > account.lastUsageAt) landWindows(account, mergeWindows(windowsOf(usage, at), account.windows), at, thresholds);
|
|
65
|
+
landGrant(account, usage.bankedReset, at);
|
|
66
|
+
}
|
|
67
|
+
|
|
63
68
|
export function foldTee(account: Account, thresholds: Bars): boolean {
|
|
64
69
|
const observed = teeObservation(account);
|
|
65
70
|
if (!observed || (account.lastUsageAt != null && observed.at <= account.lastUsageAt)) return false;
|
|
@@ -197,8 +202,8 @@ export async function sampleOldest(cfg: Config): Promise<void> {
|
|
|
197
202
|
stored.usageRetryAt = outcome.retryAt;
|
|
198
203
|
dirty = true;
|
|
199
204
|
}
|
|
200
|
-
if (stored && outcome.ok
|
|
201
|
-
|
|
205
|
+
if (stored && outcome.ok) {
|
|
206
|
+
landRead(stored, outcome.usage, startedAt, thresholdBars(cfg));
|
|
202
207
|
dirty = true;
|
|
203
208
|
}
|
|
204
209
|
if (dirty) saveAccounts(claudePool, idx);
|
package/src/lib/statusonly.ts
CHANGED
|
@@ -110,6 +110,7 @@ export function statusOnlyProvider(spec: StatusOnlySpec): Provider {
|
|
|
110
110
|
swap: async () => {
|
|
111
111
|
throw new Error(`${spec.name} moves are not supported yet (status-only pool)`);
|
|
112
112
|
},
|
|
113
|
+
useBankedReset: async () => ({ reset: false, detail: `${spec.name} has no banked reset` }),
|
|
113
114
|
classifySwapError: () => "fatal",
|
|
114
115
|
removeCredentials: async (a) => {
|
|
115
116
|
rmSync(spec.storeDirFor(a.id), { recursive: true, force: true });
|
package/src/lib/types.ts
CHANGED
|
@@ -36,7 +36,10 @@ const AggregateWindowsSchema = z.object({
|
|
|
36
36
|
sevenDay: UsageWindowSchema,
|
|
37
37
|
});
|
|
38
38
|
|
|
39
|
-
export
|
|
39
|
+
export const BankedResetSchema = z.object({ grant: z.string().nullable() });
|
|
40
|
+
export type BankedReset = z.infer<typeof BankedResetSchema>;
|
|
41
|
+
|
|
42
|
+
export type UsageWindows = z.infer<typeof AggregateWindowsSchema> & { perModel: Record<string, UsageWindow>; bankedReset?: BankedReset };
|
|
40
43
|
|
|
41
44
|
export const WindowSchema = z.object({
|
|
42
45
|
name: z.string().nullable(),
|
|
@@ -73,6 +76,8 @@ export const AccountSchema = z.object({
|
|
|
73
76
|
enforcedUntil: z.number().optional(),
|
|
74
77
|
needsReauth: z.boolean().optional(),
|
|
75
78
|
hasCredits: z.boolean().optional(),
|
|
79
|
+
bankedReset: BankedResetSchema.optional(),
|
|
80
|
+
bankedResetAt: z.number().optional(),
|
|
76
81
|
oauthAccount: OAuthAccountSchema.optional(),
|
|
77
82
|
});
|
|
78
83
|
export type Account = z.infer<typeof AccountSchema>;
|
|
@@ -252,6 +257,7 @@ export type CodexUsage = {
|
|
|
252
257
|
email: string | null;
|
|
253
258
|
planType: string | null;
|
|
254
259
|
hasCredits: boolean | null;
|
|
260
|
+
bankedReset: BankedReset | undefined;
|
|
255
261
|
windows: Window[];
|
|
256
262
|
};
|
|
257
263
|
|
package/src/lib/usage.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { omit } from "es-toolkit";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
3
|
+
import { claudeUserAgent } from "./claudebin.ts";
|
|
4
|
+
import { http, safeErrorDetail } from "./http.ts";
|
|
4
5
|
import { errorMessage, log } from "./log.ts";
|
|
5
6
|
import { env } from "./paths.ts";
|
|
6
|
-
import
|
|
7
|
+
import type { ResetClaim } from "./provider.ts";
|
|
8
|
+
import { EpochSecondsSchema, InstantSchema, JsonTextSchema, RateLimitsStdinSchema, type BankedReset, type ModelInfo, type UsageWindow, type UsageWindows, type Window } from "./types.ts";
|
|
7
9
|
|
|
8
10
|
const win = (w: { used_percentage: number; resets_at?: number | null }): UsageWindow => ({
|
|
9
11
|
usedPercentage: w.used_percentage,
|
|
@@ -198,12 +200,32 @@ const UsageScopedLimitSchema = z.looseObject({
|
|
|
198
200
|
resets_at: ResetsAtSchema,
|
|
199
201
|
scope: z.looseObject({ model: z.looseObject({ display_name: z.string() }) }),
|
|
200
202
|
});
|
|
203
|
+
const ResetGrantSchema = z.looseObject({ id: z.string(), resets_left: z.number(), clears: z.array(z.string()), paused: z.boolean(), usable_now: z.boolean() });
|
|
204
|
+
const CedarEmberSchema = z.looseObject({ next_grant_id: z.string().nullish(), grants: z.array(z.looseObject({ id: z.string() })) });
|
|
201
205
|
const UsageResponseSchema = z.looseObject({
|
|
202
206
|
five_hour: UsageLimitSchema.nullish(),
|
|
203
207
|
seven_day: UsageLimitSchema.nullish(),
|
|
204
208
|
limits: z.array(z.unknown()).nullish(),
|
|
209
|
+
cedar_ember: z.unknown().optional(),
|
|
205
210
|
});
|
|
206
211
|
|
|
212
|
+
function unreadableGrant(error: z.ZodError): undefined {
|
|
213
|
+
log("usage.cedar_ember_unreadable", { issue: z.prettifyError(error).slice(0, 200) });
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function weeklyGrant(raw: unknown): BankedReset | undefined {
|
|
218
|
+
if (raw == null) return undefined;
|
|
219
|
+
const block = CedarEmberSchema.safeParse(raw);
|
|
220
|
+
if (!block.success) return unreadableGrant(block.error);
|
|
221
|
+
const named = block.data.grants.find((g) => g.id === block.data.next_grant_id);
|
|
222
|
+
if (named == null) return undefined;
|
|
223
|
+
const next = ResetGrantSchema.safeParse(named);
|
|
224
|
+
if (!next.success) return unreadableGrant(next.error);
|
|
225
|
+
const g = next.data;
|
|
226
|
+
return g.usable_now && !g.paused && g.resets_left > 0 && g.clears.includes("seven_day") ? { grant: g.id } : undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
207
229
|
function scopedRows(limits: unknown[]): Record<string, UsageWindow> {
|
|
208
230
|
const perModel: Record<string, UsageWindow> = {};
|
|
209
231
|
for (const raw of limits) {
|
|
@@ -223,7 +245,7 @@ export async function fetchUsageDirect(accessToken: string): Promise<UsageRead>
|
|
|
223
245
|
try {
|
|
224
246
|
res = await http.get(USAGE_URL, {
|
|
225
247
|
searchParams: { at_wall: 1, skip_spend: 1 },
|
|
226
|
-
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
|
|
248
|
+
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "User-Agent": claudeUserAgent() },
|
|
227
249
|
signal: AbortSignal.timeout(USAGE_DEADLINE_MS),
|
|
228
250
|
});
|
|
229
251
|
} catch (e) {
|
|
@@ -241,5 +263,23 @@ export async function fetchUsageDirect(accessToken: string): Promise<UsageRead>
|
|
|
241
263
|
return { ok: false, retryAt: null };
|
|
242
264
|
}
|
|
243
265
|
const win = (w: z.infer<typeof UsageLimitSchema>): UsageWindow => ({ usedPercentage: w.utilization, resetsAt: w.resets_at ?? null });
|
|
244
|
-
return {
|
|
266
|
+
return {
|
|
267
|
+
ok: true,
|
|
268
|
+
usage: { fiveHour: win(parsed.data.five_hour), sevenDay: win(parsed.data.seven_day), perModel: scopedRows(parsed.data.limits ?? []), bankedReset: weeklyGrant(parsed.data.cedar_ember) },
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const ClaimResponseSchema = z.looseObject({ result: z.string(), reason: z.string().nullish() });
|
|
273
|
+
|
|
274
|
+
export async function claimResetGrant(input: { accessToken: string; organizationUuid: string; grant: string }, signal: AbortSignal): Promise<ResetClaim> {
|
|
275
|
+
const res = await http.post(new URL(`/api/organizations/${input.organizationUuid}/reset_rate_limits`, USAGE_URL), {
|
|
276
|
+
headers: { Authorization: `Bearer ${input.accessToken}`, "anthropic-beta": "oauth-2025-04-20", "User-Agent": claudeUserAgent() },
|
|
277
|
+
json: { program: "cedar_ember", grant_id: input.grant, request_id: crypto.randomUUID() },
|
|
278
|
+
signal,
|
|
279
|
+
});
|
|
280
|
+
const text = await res.text();
|
|
281
|
+
if (!res.ok) return { reset: false, detail: `HTTP ${res.status}: ${safeErrorDetail({ text })}` };
|
|
282
|
+
const claim = ClaimResponseSchema.safeParse(JsonTextSchema.safeParse(text).data).data;
|
|
283
|
+
if (claim == null) return { reset: false, detail: "unreadable body" };
|
|
284
|
+
return { reset: claim.result === "reset", detail: claim.reason != null ? `${claim.result}: ${claim.reason}` : claim.result };
|
|
245
285
|
}
|