tokenmaxxing 1.59.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 +1 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/lib/grok.ts +85 -5
- package/src/lib/grokusage.ts +75 -0
package/DESIGN.md
CHANGED
|
@@ -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
|
@@ -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/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
|
+
}
|