tokenmaxxing 1.58.0 → 1.60.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 -2
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/cli/status.ts +8 -3
- package/src/entries/codexstophook.ts +1 -1
- package/src/entries/supervisor.ts +20 -10
- package/src/lib/claude.ts +9 -9
- package/src/lib/decide.ts +6 -5
- package/src/lib/grok.ts +85 -5
- package/src/lib/grokusage.ts +75 -0
- package/src/lib/provider.ts +1 -1
- package/src/lib/usage.ts +8 -0
package/DESIGN.md
CHANGED
|
@@ -90,7 +90,7 @@ The [switching policy](docs/content/docs/switching.mdx) is one rule: under both
|
|
|
90
90
|
|
|
91
91
|
The [profile](docs/content/docs/switching-profile.mdx) records observed cache rewrites across swaps and does not infer subscription quota savings.
|
|
92
92
|
|
|
93
|
-
**Model-aware trigger.** Claude subscriptions also enforce **per-model weekly caps** - currently only for Sonnet and Fable (there is no Opus-only quota), and Fable's tighter limit binds *before* the aggregate (e.g. 80% week-Fable at only 50% week-all-models). This cap isn't in statusLine stdin, so when the
|
|
93
|
+
**Model-aware trigger.** Claude subscriptions also enforce **per-model weekly caps** - currently only for Sonnet and Fable (there is no Opus-only quota), and Fable's tighter limit binds *before* the aggregate (e.g. 80% week-Fable at only 50% week-all-models). This cap isn't in statusLine stdin, so when the session's model is in `policy.switchModels`, or the seat's tee is older than the TTL, we read it from the `limits[]` rows of the direct no-spend usage read for the seat (free, 0 tokens, one attempt with backoff, with the `/usage` probe under the seat's store refreshing the token when the read needs it) and add `week(<activeModel>) >= threshold` to the trigger. A Fable session moves on the Fable cap; a Sonnet session rides the aggregate. Candidate screening applies the same per-model gate: a burnt Fable cap screens an account out of a move. A session's model is the `--model` value it was launched with: the supervisor reads it from the launch flags, uses it for launch placement and the seat watch, and hands it to the session's hooks in `TOKENMAXXING_MODEL`, which the hooks read ahead of the per-account tee. With the default `policy.switchModels`, a session launched as Opus, Sonnet, or Haiku gates no per-model cap, so it runs on an account whose Fable cap is spent while the session and weekly windows have headroom. A launch without `--model`, or with a value that names no model family (`best`, `opusplan`), is unknown: launch placement and the seat watch gate every configured family for it, and its hooks fall back to the model in the account's tee. A move off a walled seat, or one that follows a subagent's five-hour or weekly refusal, screens candidates on every configured family whatever the launch model.
|
|
94
94
|
|
|
95
95
|
---
|
|
96
96
|
|
|
@@ -115,7 +115,7 @@ The [profile](docs/content/docs/switching-profile.mdx) records observed cache re
|
|
|
115
115
|
|
|
116
116
|
**Removed (1.37.0, issue #79):** the shared live credential and everything that served it: the swap-time harvest, park, and install, tokenmaxxing's own OAuth refresh grant and its interlock with Claude Code's refresh lock, the `~/.claude.json` `oauthAccount` rewrite, the 45-second post-swap cooldown, the pool-wide depleted-wait record, the parked-credential slots (`creds/`, `tokenmaxxing-cred-*`), the ambient `CLAUDE_CONFIG_DIR` refusal, and manual `switch` for the Claude pool.
|
|
117
117
|
|
|
118
|
-
**Shipped since (0.13.0):** Codex as a second pool with its own state (`codex-accounts.json`, per-account `codex-stores/`, own flock). Since 1.35.0 the engine is one: a generic account record (`id`, `label`, `email`, `tier`, `windows`), one picker, one decision, and one set of account commands run against a `Provider` (`src/lib/provider.ts`); `src/lib/claude.ts` and `src/lib/codex.ts` hold only what differs per product (credential store, identity, usage source, presence, login and install flows), and the provider's `seats` capability (`shared` for Claude, `live` for Codex) is how the engine knows whether present accounts stay candidates and whether a move needs the caller's respawn. Codex differences that shaped it: restart IS the switch (a running codex refuses another account's credential), one store per pooled account with `CODEX_HOME` set per session (everything but `auth.json` symlinked to the shared home, so resume works across stores), usage is a free direct GET with epoch resets and a duration-classified window set (the weekly window is primary on current plans), the refresh token rotates with reuse punished (persisted into the account's own store the instant it returns, never refreshed for an account running in another session), and the codex Stop hook drives the auto-switch through a codex supervisor shim that respawns `codex resume <session-id>` under the target's store (hooks must be trusted once via `/hooks`). Since 1.44.0 two status-only pools, grok and opencode-go, share the engine's account commands and state layout with `statusOnly: true` and no supervisor
|
|
118
|
+
**Shipped since (0.13.0):** Codex as a second pool with its own state (`codex-accounts.json`, per-account `codex-stores/`, own flock). Since 1.35.0 the engine is one: a generic account record (`id`, `label`, `email`, `tier`, `windows`), one picker, one decision, and one set of account commands run against a `Provider` (`src/lib/provider.ts`); `src/lib/claude.ts` and `src/lib/codex.ts` hold only what differs per product (credential store, identity, usage source, presence, login and install flows), and the provider's `seats` capability (`shared` for Claude, `live` for Codex) is how the engine knows whether present accounts stay candidates and whether a move needs the caller's respawn. Codex differences that shaped it: restart IS the switch (a running codex refuses another account's credential), one store per pooled account with `CODEX_HOME` set per session (everything but `auth.json` symlinked to the shared home, so resume works across stores), usage is a free direct GET with epoch resets and a duration-classified window set (the weekly window is primary on current plans), the refresh token rotates with reuse punished (persisted into the account's own store the instant it returns, never refreshed for an account running in another session), and the codex Stop hook drives the auto-switch through a codex supervisor shim that respawns `codex resume <session-id>` under the target's store (hooks must be trusted once via `/hooks`). Since 1.44.0 two status-only pools, grok and opencode-go, share the engine's account commands and state layout with `statusOnly: true` and no supervisor or hooks ([Status-only pools](docs/content/docs/status-only-pools.mdx)). grok `status` reads the weekly subscription credit window from the credits billing GET; opencode-go has no usage source.
|
|
119
119
|
|
|
120
120
|
**Shipped since (1.29.0):** a Cursor Cloud relay. A cloud VM has no keychain, no pool state, and no supervisor, so the path uses what it does have (environment variables, a shell, repo files) and a different credential primitive, one-year inference-only setup tokens, minted once per pooled account by `tokenmaxxing setup-token` on the owner's machine and carried to the VM as one user-scoped Runtime Secret (`TOKENMAXXING_TOKENS`). `tokenmaxxing cursor init` writes `.cursor/environment.json` and a `claude` project subagent (`.cursor/agents/claude.md`) that forwards each task to `tokenmaxxing cloud run`, which spawns `claude -p --dangerously-skip-permissions --output-format json` under `CLAUDE_CODE_OAUTH_TOKEN` and pins the thread to that token (the API caches by exact prefix, so a rotation busts one thread, not all). A setup token carries no usage signal, so rotation is reactive. A run whose transcript gained a non-transient rate-limit row during that run (the structured row the StopFailure hook reads) walls the label until the reset the row names (five hours without one) and resumes the same session on the next token; the result's `api_error_status` alone never walls, so a transient 429 or an older row from a previous token does not cascade across the pool. The local switching engine never reads a setup token; `/usage` prints no percentages under one. Details in [Cursor Cloud](docs/content/docs/cursor-cloud.mdx).
|
|
121
121
|
|
package/README.md
CHANGED
|
@@ -84,7 +84,7 @@ claude # use claude as always; each session gets its own accoun
|
|
|
84
84
|
Each session runs on its own seat. One rule: while the seat is under its bars, nothing happens; once it is at or over a bar, the session moves to the usable account that ranks first. The session bar is `thresholds.session` (90) minus `policy.projectionMargin` (3), so **87%** of the 5-hour window by default, and the weekly bar is **98%**. The bars also screen candidates on any of:
|
|
85
85
|
|
|
86
86
|
- **Session** (5-hour) or **week (all models)** - the aggregate windows, fed free/push-based by the statusLine into one tee per account.
|
|
87
|
-
- **Per-model weekly cap** - the most capable model (Fable) has its own tighter weekly limit that binds *before* the aggregate (per-model caps currently exist only for Sonnet and Fable, and Sonnet's is generous). tokenmaxxing reads it from the direct no-spend usage read for the seat (free, 0 tokens, one attempt per interval) whenever the seat's tee is older than `policy.usagePollTtlMs` or the
|
|
87
|
+
- **Per-model weekly cap** - the most capable model (Fable) has its own tighter weekly limit that binds *before* the aggregate (per-model caps currently exist only for Sonnet and Fable, and Sonnet's is generous). tokenmaxxing reads it from the direct no-spend usage read for the seat (free, 0 tokens, one attempt per interval) whenever the seat's tee is older than `policy.usagePollTtlMs` or the session's model is one of `policy.switchModels`, backing off after a failed sample, so a Fable session moves on the Fable cap while a Sonnet session rides the aggregate. A session launched with `--model` on another family (`claude --model opus`) ignores the Fable cap from launch on: it is placed on, and moves to, an account whose Fable cap is spent as long as that account's session and weekly windows have headroom. The exception is a move off an account the server already refused (an enforced-limit wall), which screens every configured family.
|
|
88
88
|
|
|
89
89
|
The bars' headroom is deliberate: it's the budget to reach a clean turn boundary before the account's real limit. The session bar sits lower (90, minus the margin) because a 5-hour reset is cheap to sit out; weekly quota is use-it-or-lose-it, so it drains closer to the limit (98).
|
|
90
90
|
|
|
@@ -142,7 +142,7 @@ A Cursor Cloud Agent can hand substantial work to Claude Code running on your ow
|
|
|
142
142
|
- **One shared identity file.** `~/.claude.json` holds one `oauthAccount` that Claude Code rewrites after whichever session refreshed last, so `/status` can show another session's email; the statusline's `◆` marks the account a session actually uses.
|
|
143
143
|
- **Keychain (macOS).** The first keychain access for a new store happens inside the interactive `init`, `add`, or `auth` run, where a prompt can be answered, not inside a headless hook.
|
|
144
144
|
- **Plaintext credentials (Linux).** Claude Code itself stores Linux credentials as a 0600 plaintext file; a store follows the same model.
|
|
145
|
-
- **Status-only pools
|
|
145
|
+
- **Status-only pools do not switch.** grok `status` shows the weekly subscription credit window; opencode-go accounts still render with no bars. Neither pool moves.
|
|
146
146
|
|
|
147
147
|
## How it's built
|
|
148
148
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.60.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/cli/status.ts
CHANGED
|
@@ -88,9 +88,14 @@ async function collect(p: Provider, cfg: Config, now: number, cached: boolean):
|
|
|
88
88
|
if (!cached && idx.accounts.length > 0) {
|
|
89
89
|
await withLock(p.pool.lockFile, async () => {
|
|
90
90
|
idx = loadAccounts(p.pool);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
91
|
+
const notice = process.stderr.isTTY;
|
|
92
|
+
if (notice) process.stderr.write(c.dim(`sampling ${p.name} usage...`));
|
|
93
|
+
try {
|
|
94
|
+
liveId = p.liveId();
|
|
95
|
+
reports = await p.samplePool(idx.accounts, liveId, now);
|
|
96
|
+
} finally {
|
|
97
|
+
if (notice) process.stderr.write("\r\x1b[2K");
|
|
98
|
+
}
|
|
94
99
|
saveAccounts(p.pool, idx);
|
|
95
100
|
});
|
|
96
101
|
}
|
|
@@ -21,7 +21,7 @@ async function compactBeforeMove(input: { sessionId: string | null; now: number
|
|
|
21
21
|
const liveId = codex.liveId();
|
|
22
22
|
const live = liveId == null ? undefined : loadAccounts(codex.pool).accounts.find((a) => a.id === liveId);
|
|
23
23
|
if (!live || live.needsReauth === true || (live.enforcedUntil != null && live.enforcedUntil > now)) return;
|
|
24
|
-
const observed = await codex.observeLive(live, loadConfig(), now, { probe: true });
|
|
24
|
+
const observed = await codex.observeLive(live, loadConfig(), now, { probe: true, perModel: false });
|
|
25
25
|
if (!observed || !isExhausted({ ...live, windows: observed.windows }, codexPickCtx(now, live.id))) return;
|
|
26
26
|
const env: Record<string, string | undefined> = { ...process.env, TOKENMAXXING_PROBE: "1", [WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH) };
|
|
27
27
|
delete env[CODEX_SUPERVISOR_ID_ENV];
|
|
@@ -17,7 +17,8 @@ import { exitStatus, loopGuardTripped, raceMarkerOrExit, recordPresenceOrStop, r
|
|
|
17
17
|
import { saveTermios } from "../lib/tty.ts";
|
|
18
18
|
import { loadSessionFlags, pruneStaleSessions, saveSessionFlags, writeRespawnMarker } from "../lib/sessions.ts";
|
|
19
19
|
import { loadAccounts, loadConfig } from "../lib/state.ts";
|
|
20
|
-
import {
|
|
20
|
+
import { gatedFamilies, modelFromFlag } from "../lib/usage.ts";
|
|
21
|
+
import { RespawnMarkerSchema, type Account, type Config, type ModelInfo } from "../lib/types.ts";
|
|
21
22
|
import { errorMessage, log } from "../lib/log.ts";
|
|
22
23
|
|
|
23
24
|
const NONINTERACTIVE_SUBCMDS = new Set([
|
|
@@ -53,6 +54,7 @@ type Analysis = {
|
|
|
53
54
|
continueLatest: boolean;
|
|
54
55
|
streamInput: boolean;
|
|
55
56
|
streamOutput: boolean;
|
|
57
|
+
model: string | null;
|
|
56
58
|
};
|
|
57
59
|
|
|
58
60
|
export function analyzeArgs(argv: string[]): Analysis {
|
|
@@ -61,6 +63,7 @@ export function analyzeArgs(argv: string[]): Analysis {
|
|
|
61
63
|
let continueLatest = false;
|
|
62
64
|
let streamInput = false;
|
|
63
65
|
let streamOutput = false;
|
|
66
|
+
let model: string | null = null;
|
|
64
67
|
let printMode = false;
|
|
65
68
|
let invalidSessionArg = false;
|
|
66
69
|
let pickerResume = false;
|
|
@@ -98,6 +101,8 @@ export function analyzeArgs(argv: string[]): Analysis {
|
|
|
98
101
|
else if (a.startsWith("--input-format=")) streamInput = a.slice("--input-format=".length) === "stream-json";
|
|
99
102
|
else if (a === "--output-format") streamOutput = argv[++i] === "stream-json";
|
|
100
103
|
else if (a.startsWith("--output-format=")) streamOutput = a.slice("--output-format=".length) === "stream-json";
|
|
104
|
+
else if (a === "--model") model = argv[++i] ?? null;
|
|
105
|
+
else if (a.startsWith("--model=")) model = a.slice("--model=".length);
|
|
101
106
|
else if (VALUE_TAKING_ROOT_FLAGS.has(a)) i++;
|
|
102
107
|
else if (VARIADIC_ROOT_FLAGS.has(a)) {
|
|
103
108
|
while (i + 1 < argv.length && !argv[i + 1]!.startsWith("-")) i++;
|
|
@@ -113,7 +118,7 @@ export function analyzeArgs(argv: string[]): Analysis {
|
|
|
113
118
|
const isSubcmd = firstPositional !== null && NONINTERACTIVE_SUBCMDS.has(firstPositional);
|
|
114
119
|
const forkResume = forkSession && (resumeId !== null || continueLatest);
|
|
115
120
|
const manage = !printMode && !isSubcmd && !invalidSessionArg && !pickerResume && !forkResume && !process.env.TOKENMAXXING_PROBE;
|
|
116
|
-
return { manage, sessionId, resumeId, continueLatest, streamInput, streamOutput };
|
|
121
|
+
return { manage, sessionId, resumeId, continueLatest, streamInput, streamOutput, model };
|
|
117
122
|
}
|
|
118
123
|
|
|
119
124
|
export function stripSessionFlags(argv: string[]): string[] {
|
|
@@ -201,22 +206,23 @@ function consumableMarker(marker: string, gate: MarkerGate): z.infer<typeof Resp
|
|
|
201
206
|
const SEAT_POLL_MS = 10_000;
|
|
202
207
|
const SEAT_RETRY_MS = 60_000;
|
|
203
208
|
|
|
204
|
-
function seatBlockedUntil(seatId: string, now: number): number | null {
|
|
209
|
+
function seatBlockedUntil(seatId: string, now: number, families: string[], cfg: Config): number | null {
|
|
205
210
|
const account = loadAccounts(claudePool).accounts.find((a) => a.id === seatId);
|
|
206
211
|
if (!account) return null;
|
|
207
|
-
const cfg = loadConfig();
|
|
208
212
|
const observed = teeObservation(account);
|
|
209
213
|
const current = observed ? { ...account, windows: observed.windows } : account;
|
|
210
|
-
const until = usableAt(current, { now, thresholds: thresholdBars(cfg), currentId: seatId, families
|
|
214
|
+
const until = usableAt(current, { now, thresholds: thresholdBars(cfg), currentId: seatId, families, seats: null });
|
|
211
215
|
return until > now ? until : null;
|
|
212
216
|
}
|
|
213
217
|
|
|
214
|
-
async function moveExhaustedSeat(seat: Account, sid: string, gate: MarkerGate): Promise<boolean> {
|
|
218
|
+
async function moveExhaustedSeat(seat: Account, sid: string, gate: MarkerGate, model: ModelInfo | null): Promise<boolean> {
|
|
215
219
|
try {
|
|
216
220
|
const now = Date.now();
|
|
217
|
-
const
|
|
221
|
+
const cfg = loadConfig();
|
|
222
|
+
const families = gatedFamilies(model, cfg.policy.switchModels);
|
|
223
|
+
const until = seatBlockedUntil(seat.id, now, families, cfg);
|
|
218
224
|
if (until == null || until <= gate.overriddenUntil) return false;
|
|
219
|
-
const decision = await evaluateAndMaybeSwap(claude, now, true, null, seat.id);
|
|
225
|
+
const decision = await evaluateAndMaybeSwap(claude, now, true, null, seat.id, families);
|
|
220
226
|
log("supervisor.seat_exhausted", { seat: seat.id.slice(0, 8), until, reason: decision.reason, account: decision.account?.id.slice(0, 8), waitUntil: decision.waitUntil });
|
|
221
227
|
if (decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
|
|
222
228
|
writeRespawnMarker({ session: { sid, launchedAt: gate.launchedAt }, sessionId: sid, accountId: decision.account.id, waitUntil: decision.waitUntil ?? now, compact: true });
|
|
@@ -342,6 +348,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
342
348
|
const passthroughEnv: Record<string, string | undefined> = { ...childEnv };
|
|
343
349
|
delete passthroughEnv.TOKENMAXXING_SUPERVISED;
|
|
344
350
|
delete passthroughEnv.TOKENMAXXING_SESSION_ID;
|
|
351
|
+
delete passthroughEnv.TOKENMAXXING_MODEL;
|
|
345
352
|
return runPassthrough({ real, argv, env: passthroughEnv, onSpawn: (p) => { child = p; } });
|
|
346
353
|
}
|
|
347
354
|
|
|
@@ -380,6 +387,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
380
387
|
const effective = analyzeArgs(base);
|
|
381
388
|
const relay = effective.streamInput ? new StdinRelay() : null;
|
|
382
389
|
const stream = effective.streamOutput;
|
|
390
|
+
const model = modelFromFlag(effective.model);
|
|
383
391
|
let noticeSid = sid;
|
|
384
392
|
const say: Say = (terminal, text) => {
|
|
385
393
|
if (stream) process.stdout.write(systemLine(noticeSid, text));
|
|
@@ -394,7 +402,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
394
402
|
|
|
395
403
|
const gate: MarkerGate = { launchedAt: Date.now(), overriddenUntil };
|
|
396
404
|
const { child: proc, seat } = await withLock(claudePool.lockFile, async () => {
|
|
397
|
-
const picked = (wanted == null ? null : (loadAccounts(claudePool).accounts.find((a) => a.id === wanted) ?? null)) ?? pickSeat(gate.launchedAt);
|
|
405
|
+
const picked = (wanted == null ? null : (loadAccounts(claudePool).accounts.find((a) => a.id === wanted) ?? null)) ?? pickSeat(gate.launchedAt, model);
|
|
398
406
|
log("supervisor.launch", { sid, respawns, seat: picked?.id.slice(0, 8) ?? null, args: launchArgs.join(" "), injected: firstLine !== null });
|
|
399
407
|
const spawned = Bun.spawn([real, ...launchArgs], {
|
|
400
408
|
stdin: relay === null ? "inherit" : "pipe",
|
|
@@ -405,6 +413,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
405
413
|
TOKENMAXXING_SUPERVISED: "1",
|
|
406
414
|
TOKENMAXXING_SESSION_ID: sid,
|
|
407
415
|
TOKENMAXXING_LAUNCHED_AT: String(gate.launchedAt),
|
|
416
|
+
TOKENMAXXING_MODEL: effective.model ?? "",
|
|
408
417
|
...(picked ? { CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(picked.id) } : {}),
|
|
409
418
|
},
|
|
410
419
|
});
|
|
@@ -431,7 +440,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
431
440
|
tick: async () => {
|
|
432
441
|
if (existsSync(marker) && consumableMarker(marker, gate) != null) return true;
|
|
433
442
|
if (seat && !terminating && Date.now() >= seatCheckAt) {
|
|
434
|
-
const decided = await moveExhaustedSeat(seat, sid, gate);
|
|
443
|
+
const decided = await moveExhaustedSeat(seat, sid, gate, model);
|
|
435
444
|
seatCheckAt = Date.now() + (decided ? SEAT_RETRY_MS : SEAT_POLL_MS);
|
|
436
445
|
}
|
|
437
446
|
return false;
|
|
@@ -459,6 +468,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
459
468
|
delete compactEnv.TOKENMAXXING_SUPERVISED;
|
|
460
469
|
delete compactEnv.TOKENMAXXING_SESSION_ID;
|
|
461
470
|
delete compactEnv.TOKENMAXXING_LAUNCHED_AT;
|
|
471
|
+
delete compactEnv.TOKENMAXXING_MODEL;
|
|
462
472
|
const outcome = await compactClaudeSession({ real, sid: m.sessionId, env: compactEnv });
|
|
463
473
|
log("supervisor.compact", { sid: m.sessionId.slice(0, 8), seat: seat.id.slice(0, 8), ok: outcome.ok, reason: outcome.ok ? undefined : outcome.reason });
|
|
464
474
|
if (!outcome.ok) say(`\x1b[33m compaction did not land (${outcome.reason}) - resuming with the full context\x1b[0m\n`, `tokenmaxxing: compaction did not land; resuming with the full context. (${outcome.reason})`);
|
package/src/lib/claude.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { ensurePathInRc, installSupervisor, managedShellRcSkipLines, shellRcPath
|
|
|
8
8
|
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
|
-
import { claudePool, paths, sampleDirFor, seatFromEnv, storeDirFor } from "./paths.ts";
|
|
11
|
+
import { claudePool, env, paths, sampleDirFor, seatFromEnv, storeDirFor } from "./paths.ts";
|
|
12
12
|
import { pickBest, pickEarliestReset, thresholdBars, type PickCtx } from "./picker.ts";
|
|
13
13
|
import { seatCounts } from "./presence.ts";
|
|
14
14
|
import type { Observation, Provider, SampleReport } from "./provider.ts";
|
|
@@ -16,8 +16,8 @@ import { foldTee, sampleAccountUsage, teeObservation } from "./sample.ts";
|
|
|
16
16
|
import { clearUsageSnapshot, loadAccounts, loadConfig, loadUsageSnapshot, pinBinOverride, saveAccounts, type Harvest } from "./state.ts";
|
|
17
17
|
import { loadSetupTokens, saveSetupTokens } from "./setuptokens.ts";
|
|
18
18
|
import { saveTermios, restoreTermios } from "./tty.ts";
|
|
19
|
-
import { fetchUsageDirect, gatedFamilies, mergeWindows, scrubCredentialEnv, windowsOf } from "./usage.ts";
|
|
20
|
-
import { CredentialBlobSchema, JsonTextSchema, OAuthAccountSchema, type Account, type Config } from "./types.ts";
|
|
19
|
+
import { fetchUsageDirect, gatedFamilies, mergeWindows, modelFromFlag, scrubCredentialEnv, windowsOf } from "./usage.ts";
|
|
20
|
+
import { CredentialBlobSchema, JsonTextSchema, OAuthAccountSchema, type Account, type Config, type ModelInfo } from "./types.ts";
|
|
21
21
|
import { c } from "../cli/render.ts";
|
|
22
22
|
|
|
23
23
|
class StoreUnusableError extends Error {
|
|
@@ -37,14 +37,13 @@ function presence(): Map<string, number> {
|
|
|
37
37
|
|
|
38
38
|
const PROBE_BACKOFF_CAP_MS = 30 * 60 * 1000;
|
|
39
39
|
|
|
40
|
-
async function observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean }): Promise<Observation | null> {
|
|
40
|
+
async function observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean; perModel: boolean }): Promise<Observation | null> {
|
|
41
41
|
const ttl = cfg.policy.usagePollTtlMs;
|
|
42
42
|
const interval = Math.min(ttl * 2 ** (account.probeFails ?? 0), PROBE_BACKOFF_CAP_MS);
|
|
43
43
|
const probeAttempted = account.lastProbeAt != null && now - account.lastProbeAt <= interval;
|
|
44
44
|
const snap = loadUsageSnapshot(account.id);
|
|
45
45
|
const fresh = snap != null && now - snap.at <= ttl;
|
|
46
|
-
|
|
47
|
-
if (opts.probe && !probeAttempted && (!fresh || needsPerModel)) {
|
|
46
|
+
if (opts.probe && !probeAttempted && (!fresh || opts.perModel)) {
|
|
48
47
|
const startedAt = Date.now();
|
|
49
48
|
const outcome = await sampleAccountUsage(account);
|
|
50
49
|
await withLock(claudePool.lockFile, () => {
|
|
@@ -125,13 +124,13 @@ async function storeUsable(a: Account): Promise<boolean> {
|
|
|
125
124
|
}
|
|
126
125
|
}
|
|
127
126
|
|
|
128
|
-
export function pickSeat(now: number): Account | null {
|
|
127
|
+
export function pickSeat(now: number, model: ModelInfo | null): Account | null {
|
|
129
128
|
const cfg = loadConfig();
|
|
130
129
|
const idx = loadAccounts(claudePool);
|
|
131
130
|
let dirty = false;
|
|
132
131
|
for (const a of idx.accounts) dirty = foldTee(a) || dirty;
|
|
133
132
|
if (dirty) saveAccounts(claudePool, idx);
|
|
134
|
-
const ctx: PickCtx = { now, thresholds: thresholdBars(cfg), currentId: null, families: cfg.policy.switchModels, seats: presence() };
|
|
133
|
+
const ctx: PickCtx = { now, thresholds: thresholdBars(cfg), currentId: null, families: gatedFamilies(model, cfg.policy.switchModels), seats: presence() };
|
|
135
134
|
return pickBest(idx.accounts, ctx) ?? pickEarliestReset(idx.accounts, ctx)?.account ?? null;
|
|
136
135
|
}
|
|
137
136
|
|
|
@@ -286,7 +285,8 @@ export const claude: Provider = {
|
|
|
286
285
|
presence,
|
|
287
286
|
gatedFamilies: (cfg) => {
|
|
288
287
|
const seat = liveId();
|
|
289
|
-
|
|
288
|
+
const model = modelFromFlag(env("TOKENMAXXING_MODEL", "")) ?? (seat == null ? null : (loadUsageSnapshot(seat)?.state.model ?? null));
|
|
289
|
+
return gatedFamilies(model, cfg.policy.switchModels);
|
|
290
290
|
},
|
|
291
291
|
observeLive,
|
|
292
292
|
samplePool,
|
package/src/lib/decide.ts
CHANGED
|
@@ -36,16 +36,17 @@ function enforcedWall(limit: EnforcedLimit, account: Account, now: number): numb
|
|
|
36
36
|
return limit.resetsAt ?? cachedReset ?? now + (limit.kind === "session" ? FIVE_HOURS_MS : WEEK_MS);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRespawn = false, enforced: EnforcedLimit | null = null, seatId: string | null = p.liveId()): Promise<SwapDecision> {
|
|
39
|
+
export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRespawn = false, enforced: EnforcedLimit | null = null, seatId: string | null = p.liveId(), sessionFamilies?: string[]): Promise<SwapDecision> {
|
|
40
40
|
const activeId = seatId;
|
|
41
41
|
const cfg = loadConfig();
|
|
42
42
|
const bars = thresholdBars(cfg);
|
|
43
43
|
const stored0 = loadAccounts(p.pool).accounts.find((a) => a.id === activeId);
|
|
44
44
|
const walled0 = stored0?.enforcedUntil != null && stored0.enforcedUntil > now;
|
|
45
|
-
const
|
|
45
|
+
const gated = sessionFamilies ?? p.gatedFamilies(cfg);
|
|
46
|
+
const observed = stored0 ? await p.observeLive(stored0, cfg, now, { probe: enforced == null && !walled0, perModel: gated == null || gated.length > 0 }) : null;
|
|
46
47
|
const stored = loadAccounts(p.pool).accounts.find((a) => a.id === activeId);
|
|
47
48
|
|
|
48
|
-
if (!enforced && !isOver(stored, observed, { now, thresholds: bars, currentId: activeId, families:
|
|
49
|
+
if (!enforced && !isOver(stored, observed, { now, thresholds: bars, currentId: activeId, families: gated, seats: null })) {
|
|
49
50
|
return { swapped: false, account: null, reason: "under-threshold-or-stale" };
|
|
50
51
|
}
|
|
51
52
|
|
|
@@ -70,7 +71,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
|
|
|
70
71
|
|
|
71
72
|
let obs2: Observation | null = null;
|
|
72
73
|
for (const a of idx.accounts) {
|
|
73
|
-
const obs = await p.observeLive(a, cfg, now, { probe: false });
|
|
74
|
+
const obs = await p.observeLive(a, cfg, now, { probe: false, perModel: false });
|
|
74
75
|
if (a === active) obs2 = obs;
|
|
75
76
|
if (obs && (a.lastUsageAt == null || obs.at > a.lastUsageAt)) {
|
|
76
77
|
a.windows = p.mergeWindows(obs.windows, a.windows);
|
|
@@ -79,7 +80,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
|
|
|
79
80
|
}
|
|
80
81
|
saveAccounts(p.pool, idx);
|
|
81
82
|
|
|
82
|
-
const families = p.gatedFamilies(cfg);
|
|
83
|
+
const families = sessionFamilies ?? p.gatedFamilies(cfg);
|
|
83
84
|
const walled = active?.enforcedUntil != null && active.enforcedUntil > now;
|
|
84
85
|
const blindScreen = (enforced != null && (prior || enforced.blind)) || (walled && !enforced2);
|
|
85
86
|
const screened = blindScreen && families != null ? cfg.policy.switchModels : families;
|
package/src/lib/grok.ts
CHANGED
|
@@ -2,10 +2,13 @@ import { mkdirSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { writeFileAtomic } from "./atomic.ts";
|
|
5
|
+
import { fetchGrokUsage, GrokUsageReadError } from "./grokusage.ts";
|
|
6
|
+
import { errorMessage } from "./log.ts";
|
|
5
7
|
import { grokAuthJsonFor, grokPaths, grokPool, grokSeatFromEnv, grokStoreDirFor } from "./paths.ts";
|
|
6
|
-
import type { Provider } from "./provider.ts";
|
|
8
|
+
import type { Provider, SampleReport } from "./provider.ts";
|
|
7
9
|
import { loadAccounts, type Harvest } from "./state.ts";
|
|
8
10
|
import { statusOnlyProvider, type AuthEntry } from "./statusonly.ts";
|
|
11
|
+
import type { Account } from "./types.ts";
|
|
9
12
|
import { c } from "../cli/render.ts";
|
|
10
13
|
|
|
11
14
|
const GrokAuthEntrySchema = z.looseObject({
|
|
@@ -13,11 +16,14 @@ const GrokAuthEntrySchema = z.looseObject({
|
|
|
13
16
|
principal_id: z.string().optional(),
|
|
14
17
|
email: z.string().nullish(),
|
|
15
18
|
first_name: z.string().nullish(),
|
|
19
|
+
key: z.string().optional(),
|
|
16
20
|
refresh_token: z.string().optional(),
|
|
17
21
|
expires_at: z.string().nullish(),
|
|
18
22
|
});
|
|
19
23
|
type GrokAuthEntry = z.infer<typeof GrokAuthEntrySchema>;
|
|
20
24
|
|
|
25
|
+
type GrokCred = { id: string; token: string; expiresAt: number | null };
|
|
26
|
+
|
|
21
27
|
function harvestOf(id: string, key: string, entry: GrokAuthEntry): Harvest {
|
|
22
28
|
return {
|
|
23
29
|
id,
|
|
@@ -31,7 +37,7 @@ function harvestOf(id: string, key: string, entry: GrokAuthEntry): Harvest {
|
|
|
31
37
|
};
|
|
32
38
|
}
|
|
33
39
|
|
|
34
|
-
function
|
|
40
|
+
function readMap(path: string): [string, GrokAuthEntry][] {
|
|
35
41
|
let raw: unknown;
|
|
36
42
|
try {
|
|
37
43
|
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
@@ -40,18 +46,87 @@ function readAuth(path: string): AuthEntry[] {
|
|
|
40
46
|
}
|
|
41
47
|
const map = z.record(z.string(), z.unknown()).safeParse(raw);
|
|
42
48
|
if (!map.success) return [];
|
|
43
|
-
const out:
|
|
49
|
+
const out: [string, GrokAuthEntry][] = [];
|
|
44
50
|
for (const [key, value] of Object.entries(map.data)) {
|
|
45
51
|
const parsed = GrokAuthEntrySchema.safeParse(value);
|
|
46
52
|
if (!parsed.success) continue;
|
|
47
|
-
|
|
53
|
+
out.push([key, parsed.data]);
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readAuth(path: string): AuthEntry[] {
|
|
59
|
+
const out: AuthEntry[] = [];
|
|
60
|
+
for (const [key, entry] of readMap(path)) {
|
|
48
61
|
const id = entry.user_id ?? entry.principal_id ?? key;
|
|
49
62
|
out.push({ id, usable: (entry.refresh_token ?? "") !== "", harvest: id === "" ? null : harvestOf(id, key, entry) });
|
|
50
63
|
}
|
|
51
64
|
return out;
|
|
52
65
|
}
|
|
53
66
|
|
|
54
|
-
|
|
67
|
+
function credsIn(path: string): GrokCred[] {
|
|
68
|
+
const out: GrokCred[] = [];
|
|
69
|
+
for (const [key, entry] of readMap(path)) {
|
|
70
|
+
const id = entry.user_id ?? entry.principal_id ?? key;
|
|
71
|
+
const token = entry.key ?? "";
|
|
72
|
+
if (id === "" || token === "") continue;
|
|
73
|
+
const expiresAt = entry.expires_at != null ? Date.parse(entry.expires_at) : null;
|
|
74
|
+
out.push({ id, token, expiresAt: expiresAt != null && Number.isFinite(expiresAt) ? expiresAt : null });
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function liveCred(accountId: string): GrokCred | null {
|
|
80
|
+
return credsIn(join(grokPaths.home, "auth.json")).find((c) => c.id === accountId) ?? null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function bearerFor(accountId: string, now: number): { token: string; from: "store" | "live" } | null {
|
|
84
|
+
const store = credsIn(grokAuthJsonFor(accountId)).find((c) => c.id === accountId) ?? null;
|
|
85
|
+
const live = liveCred(accountId);
|
|
86
|
+
if (store != null && (store.expiresAt == null || store.expiresAt > now + 60_000)) return { token: store.token, from: "store" };
|
|
87
|
+
if (live != null) return { token: live.token, from: "live" };
|
|
88
|
+
if (store != null) return { token: store.token, from: "store" };
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function readGrokUsage(account: Account, now: number): Promise<{ ok: true; at: number } | { ok: false; reason: string }> {
|
|
93
|
+
const first = bearerFor(account.id, now);
|
|
94
|
+
if (first == null) return { ok: false, reason: "no usable credential in this account's store - run `tokenmaxxing auth --grok`" };
|
|
95
|
+
try {
|
|
96
|
+
const usage = await fetchGrokUsage({ token: first.token, at: now });
|
|
97
|
+
account.windows = usage.windows;
|
|
98
|
+
account.lastUsageAt = usage.at;
|
|
99
|
+
return { ok: true, at: usage.at };
|
|
100
|
+
} catch (e) {
|
|
101
|
+
if (e instanceof GrokUsageReadError && e.status === 401 && first.from === "store") {
|
|
102
|
+
const live = liveCred(account.id);
|
|
103
|
+
if (live != null && live.token !== first.token) {
|
|
104
|
+
try {
|
|
105
|
+
const usage = await fetchGrokUsage({ token: live.token, at: now });
|
|
106
|
+
account.windows = usage.windows;
|
|
107
|
+
account.lastUsageAt = usage.at;
|
|
108
|
+
return { ok: true, at: usage.at };
|
|
109
|
+
} catch (retry) {
|
|
110
|
+
return { ok: false, reason: retry instanceof GrokUsageReadError ? retry.message : errorMessage(retry) };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { ok: false, reason: e instanceof GrokUsageReadError ? e.message : errorMessage(e) };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function samplePool(accounts: Account[], _liveId: string | null, now: number): Promise<Map<string, SampleReport>> {
|
|
119
|
+
const reports = new Map<string, SampleReport>();
|
|
120
|
+
await Promise.all(
|
|
121
|
+
accounts.map(async (account) => {
|
|
122
|
+
const outcome = await readGrokUsage(account, now);
|
|
123
|
+
reports.set(account.id, outcome.ok ? { ok: true, source: "probe" } : { ok: false, reason: outcome.reason });
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
return reports;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const base = statusOnlyProvider({
|
|
55
130
|
name: "grok",
|
|
56
131
|
flag: " --grok",
|
|
57
132
|
pool: grokPool,
|
|
@@ -72,3 +147,8 @@ export const grok: Provider = statusOnlyProvider({
|
|
|
72
147
|
installNotice: "grok pool is status-only: no supervisor or hooks installed. Use `tokenmaxxing status` to view pooled grok accounts.",
|
|
73
148
|
loginStep: () => `Sign in in the browser session that opens (or run ${c.bold("grok login --device-auth")} on headless hosts first).`,
|
|
74
149
|
});
|
|
150
|
+
|
|
151
|
+
export const grok: Provider = {
|
|
152
|
+
...base,
|
|
153
|
+
samplePool,
|
|
154
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { http, safeErrorDetail } from "./http.ts";
|
|
3
|
+
import { errorMessage } from "./log.ts";
|
|
4
|
+
import { env } from "./paths.ts";
|
|
5
|
+
import { JsonTextSchema, type Window } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export class GrokUsageReadError extends Error {
|
|
8
|
+
status: number | null;
|
|
9
|
+
constructor(detail: string, status: number | null = null) {
|
|
10
|
+
super(`grok usage read failed: ${detail}`);
|
|
11
|
+
this.name = "GrokUsageReadError";
|
|
12
|
+
this.status = status;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const InstantSchema = z.iso.datetime({ offset: true });
|
|
17
|
+
|
|
18
|
+
const CreditsPeriodSchema = z.looseObject({
|
|
19
|
+
start: InstantSchema,
|
|
20
|
+
end: InstantSchema,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const CreditsConfigSchema = z.looseObject({
|
|
24
|
+
currentPeriod: CreditsPeriodSchema,
|
|
25
|
+
creditUsagePercent: z.number(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const CreditsBillingSchema = z.looseObject({
|
|
29
|
+
config: CreditsConfigSchema,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export type GrokUsage = { windows: Window[]; at: number };
|
|
33
|
+
|
|
34
|
+
function usageUrl(): string {
|
|
35
|
+
return env("TOKENMAXXING_GROK_USAGE_URL", "https://cli-chat-proxy.grok.com/v1/billing?format=credits");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function fetchGrokUsage(input: { token: string; at: number }): Promise<GrokUsage> {
|
|
39
|
+
const { token, at } = input;
|
|
40
|
+
let res: Response;
|
|
41
|
+
try {
|
|
42
|
+
res = await http.get(usageUrl(), {
|
|
43
|
+
headers: {
|
|
44
|
+
Authorization: `Bearer ${token}`,
|
|
45
|
+
Accept: "application/json",
|
|
46
|
+
"User-Agent": "grok",
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
} catch (e) {
|
|
50
|
+
throw new GrokUsageReadError(`endpoint unreachable: ${errorMessage(e)}`);
|
|
51
|
+
}
|
|
52
|
+
const text = await res.text();
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
throw new GrokUsageReadError(`HTTP ${res.status}: ${safeErrorDetail({ text })}`, res.status);
|
|
55
|
+
}
|
|
56
|
+
const parsed = CreditsBillingSchema.safeParse(JsonTextSchema.safeParse(text).data);
|
|
57
|
+
if (!parsed.success) {
|
|
58
|
+
throw new GrokUsageReadError("endpoint returned an unexpected body shape (withheld)");
|
|
59
|
+
}
|
|
60
|
+
const period = parsed.data.config.currentPeriod;
|
|
61
|
+
const start = Date.parse(period.start);
|
|
62
|
+
const end = Date.parse(period.end);
|
|
63
|
+
return {
|
|
64
|
+
at,
|
|
65
|
+
windows: [
|
|
66
|
+
{
|
|
67
|
+
name: null,
|
|
68
|
+
usedPercentage: parsed.data.config.creditUsagePercent,
|
|
69
|
+
resetsAt: Number.isFinite(end) ? end : null,
|
|
70
|
+
windowSeconds: Number.isFinite(start) && Number.isFinite(end) && end > start ? Math.round((end - start) / 1000) : null,
|
|
71
|
+
sampledAt: at,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
}
|
package/src/lib/provider.ts
CHANGED
|
@@ -18,7 +18,7 @@ export type Provider = {
|
|
|
18
18
|
liveId(): string | null;
|
|
19
19
|
presence(): Map<string, number>;
|
|
20
20
|
gatedFamilies(cfg: Config): string[] | null;
|
|
21
|
-
observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean }): Promise<Observation | null>;
|
|
21
|
+
observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean; perModel: boolean }): Promise<Observation | null>;
|
|
22
22
|
samplePool(accounts: Account[], liveId: string | null, now: number): Promise<Map<string, SampleReport>>;
|
|
23
23
|
mergeWindows(next: Window[], prev: Window[]): Window[];
|
|
24
24
|
swap(target: Account): Promise<void>;
|
package/src/lib/usage.ts
CHANGED
|
@@ -44,6 +44,14 @@ export function gatedFamilies(model: ModelInfo | null, families: string[]): stri
|
|
|
44
44
|
return family ? [family] : [];
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
const MODEL_FAMILIES = ["sonnet", "opus", "haiku", "fable"];
|
|
48
|
+
|
|
49
|
+
export function modelFromFlag(value: string | null): ModelInfo | null {
|
|
50
|
+
const name = value?.split("[")[0]?.trim() ?? "";
|
|
51
|
+
const tokens = familyTokens(name);
|
|
52
|
+
return MODEL_FAMILIES.some((f) => tokens.includes(f)) ? { id: name, display: name } : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
47
55
|
const TranscriptBlockSchema = z.looseObject({ type: z.string().optional(), text: z.string().optional() });
|
|
48
56
|
export const TranscriptRowSchema = z.looseObject({
|
|
49
57
|
type: z.string().optional(),
|