tokenmaxxing 1.131.0 → 1.132.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/key.ts +147 -0
- package/src/cli/status.ts +48 -3
- package/src/entries/statusline.ts +10 -0
- package/src/entries/stopfailurehook.ts +24 -6
- package/src/entries/stophook.ts +7 -6
- package/src/entries/supervisor.ts +105 -34
- package/src/lib/apikeys.ts +142 -0
- package/src/lib/claude.ts +25 -7
- package/src/lib/compact.ts +3 -4
- package/src/lib/decide.ts +27 -6
- package/src/lib/paths.ts +3 -0
- package/src/lib/pi.ts +1 -0
- package/src/lib/presence.ts +6 -5
- package/src/lib/provider.ts +8 -1
- package/src/lib/sessions.ts +7 -2
- package/src/lib/supervise.ts +2 -2
- package/src/lib/types.ts +30 -0
- package/src/lib/usage.ts +1 -1
- package/src/main.ts +7 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.132.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/key.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { keySessions, loadApiKeys, removeApiKeySecret, saveApiKeys, writeApiKeySecret } from "../lib/apikeys.ts";
|
|
3
|
+
import { withLock } from "../lib/lock.ts";
|
|
4
|
+
import { claudePool } from "../lib/paths.ts";
|
|
5
|
+
import { readStdin } from "../lib/proc.ts";
|
|
6
|
+
import type { ApiKey } from "../lib/types.ts";
|
|
7
|
+
import { c, emitError, plain } from "./render.ts";
|
|
8
|
+
|
|
9
|
+
const USAGE = "usage: tokenmaxxing key add <label> [credit-usd] [workspace-id] (the key on stdin) | key credit <label> <usd> | key rm <label>";
|
|
10
|
+
|
|
11
|
+
const UsdSchema = z.coerce.number().finite().min(0);
|
|
12
|
+
|
|
13
|
+
const WorkspaceIdSchema = z
|
|
14
|
+
.string()
|
|
15
|
+
.min(1)
|
|
16
|
+
.refine((id) => id.trim() === id && !id.includes("\n") && !id.includes("\r"), "a workspace id is one line with no surrounding whitespace");
|
|
17
|
+
|
|
18
|
+
export function usd(amount: number): string {
|
|
19
|
+
return `$${amount.toFixed(2)}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function findKey(keys: ApiKey[], label: string): ApiKey | undefined {
|
|
23
|
+
return keys.find((k) => k.label.toLowerCase() === label.toLowerCase());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseUsd(raw: string): number | null {
|
|
27
|
+
const parsed = UsdSchema.safeParse(raw);
|
|
28
|
+
if (parsed.success) return parsed.data;
|
|
29
|
+
emitError({ message: `"${raw}" is not a dollar amount (a number at or above 0)` });
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function add(args: string[]): Promise<number> {
|
|
34
|
+
const [label, creditRaw, workspaceRaw] = args;
|
|
35
|
+
if (!label) {
|
|
36
|
+
emitError({ message: USAGE, paint: plain });
|
|
37
|
+
return 2;
|
|
38
|
+
}
|
|
39
|
+
const credit = creditRaw == null ? undefined : parseUsd(creditRaw);
|
|
40
|
+
if (credit === null) return 2;
|
|
41
|
+
const workspace = workspaceRaw == null ? undefined : WorkspaceIdSchema.safeParse(workspaceRaw);
|
|
42
|
+
if (workspace != null && !workspace.success) {
|
|
43
|
+
emitError({ message: `"${workspaceRaw}" is not a workspace id: ${workspace.error.issues[0]?.message ?? "invalid"}` });
|
|
44
|
+
return 2;
|
|
45
|
+
}
|
|
46
|
+
const workspaceId = workspace?.data;
|
|
47
|
+
if (process.stdin.isTTY) {
|
|
48
|
+
emitError({ message: "refusing to read the API key from a terminal, which would echo it - pipe it in: read -rs KEY && printf '%s' \"$KEY\" | tokenmaxxing key add <label> ...; unset KEY" });
|
|
49
|
+
return 2;
|
|
50
|
+
}
|
|
51
|
+
const secret = (await readStdin()).trim();
|
|
52
|
+
if (secret === "") {
|
|
53
|
+
emitError({ message: "no API key on stdin - pipe the key in, for example from your password manager" });
|
|
54
|
+
return 2;
|
|
55
|
+
}
|
|
56
|
+
if (secret.includes("\n") || secret.includes("\r")) {
|
|
57
|
+
emitError({ message: "stdin holds more than one line - pass one API key per `key add`" });
|
|
58
|
+
return 2;
|
|
59
|
+
}
|
|
60
|
+
if (secret.startsWith("sk-ant-admin")) {
|
|
61
|
+
emitError({ message: "that is an Admin API key, which cannot run inference - create a regular API key in the Console" });
|
|
62
|
+
return 2;
|
|
63
|
+
}
|
|
64
|
+
return withLock(claudePool.lockFile, () => {
|
|
65
|
+
const idx = loadApiKeys();
|
|
66
|
+
const taken = findKey(idx.keys, label);
|
|
67
|
+
if (taken) {
|
|
68
|
+
emitError({ message: `label "${label}" is already used by another API key - labels must be unique` });
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
let id = crypto.randomUUID().slice(0, 8);
|
|
72
|
+
while (idx.keys.some((k) => k.id === id)) id = crypto.randomUUID().slice(0, 8);
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
writeApiKeySecret(id, secret);
|
|
75
|
+
idx.keys.push({
|
|
76
|
+
id,
|
|
77
|
+
label,
|
|
78
|
+
...(workspaceId != null ? { workspaceId } : {}),
|
|
79
|
+
...(credit != null ? { creditUsd: credit } : {}),
|
|
80
|
+
spentUsd: 0,
|
|
81
|
+
creditSetAt: now,
|
|
82
|
+
addedAt: now,
|
|
83
|
+
});
|
|
84
|
+
saveApiKeys(idx);
|
|
85
|
+
console.log(`added API key ${c.bold(label)} (${credit != null ? `credit ${usd(credit)}` : "credit unknown, ranks after keys with a balance"}${workspaceId != null ? `, workspace ${workspaceId}` : ""})`);
|
|
86
|
+
return 0;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function credit(args: string[]): Promise<number> {
|
|
91
|
+
const [label, amountRaw] = args;
|
|
92
|
+
if (!label || amountRaw == null || args.length > 2) {
|
|
93
|
+
emitError({ message: USAGE, paint: plain });
|
|
94
|
+
return 2;
|
|
95
|
+
}
|
|
96
|
+
const amount = parseUsd(amountRaw);
|
|
97
|
+
if (amount === null) return 2;
|
|
98
|
+
return withLock(claudePool.lockFile, () => {
|
|
99
|
+
const idx = loadApiKeys();
|
|
100
|
+
const key = findKey(idx.keys, label);
|
|
101
|
+
if (!key) {
|
|
102
|
+
emitError({ message: `no API key is labeled "${label}"` });
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
key.creditUsd = amount;
|
|
106
|
+
key.spentUsd = 0;
|
|
107
|
+
key.creditSetAt = Date.now();
|
|
108
|
+
key.refusedAt = undefined;
|
|
109
|
+
saveApiKeys(idx);
|
|
110
|
+
console.log(`set ${c.bold(key.label)} credit to ${usd(amount)}`);
|
|
111
|
+
return 0;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function rm(args: string[]): Promise<number> {
|
|
116
|
+
const [label] = args;
|
|
117
|
+
if (!label || args.length > 1) {
|
|
118
|
+
emitError({ message: USAGE, paint: plain });
|
|
119
|
+
return 2;
|
|
120
|
+
}
|
|
121
|
+
return withLock(claudePool.lockFile, () => {
|
|
122
|
+
const idx = loadApiKeys();
|
|
123
|
+
const key = findKey(idx.keys, label);
|
|
124
|
+
if (!key) {
|
|
125
|
+
emitError({ message: `no API key is labeled "${label}"` });
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
if (keySessions().has(key.id)) {
|
|
129
|
+
emitError({ message: `${key.label} is running in a live session - close that session before removing the key.` });
|
|
130
|
+
return 1;
|
|
131
|
+
}
|
|
132
|
+
removeApiKeySecret(key.id);
|
|
133
|
+
idx.keys = idx.keys.filter((k) => k.id !== key.id);
|
|
134
|
+
saveApiKeys(idx);
|
|
135
|
+
console.log(`removed API key ${c.bold(key.label)} (${idx.keys.length} left)`);
|
|
136
|
+
return 0;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function cmdKey(args: string[]): Promise<number> {
|
|
141
|
+
const [action, ...rest] = args;
|
|
142
|
+
if (action === "add") return add(rest);
|
|
143
|
+
if (action === "credit") return credit(rest);
|
|
144
|
+
if (action === "rm") return rm(rest);
|
|
145
|
+
emitError({ message: USAGE, paint: plain });
|
|
146
|
+
return 2;
|
|
147
|
+
}
|
package/src/cli/status.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { chunk, sortBy } from "es-toolkit";
|
|
2
|
+
import { creditLeft, keySessions, loadApiKeys } from "../lib/apikeys.ts";
|
|
2
3
|
import { claude } from "../lib/claude.ts";
|
|
3
4
|
import { codex } from "../lib/codex.ts";
|
|
4
5
|
import { grok } from "../lib/grok.ts";
|
|
@@ -7,6 +8,7 @@ import { withLock } from "../lib/lock.ts";
|
|
|
7
8
|
import { codexPool, grokPool } from "../lib/paths.ts";
|
|
8
9
|
import { barFor, bufferedUsed, earliestReset, gatedWindows, landGrant, landWindows, isExhausted, isSessionWindow, limitWindows, liveUsed, nextWeeklyReset, sessionWindow, thresholdBars, weeklyWindow } from "../lib/picker.ts";
|
|
9
10
|
import type { Provider, SampleReport } from "../lib/provider.ts";
|
|
11
|
+
import { usd } from "./key.ts";
|
|
10
12
|
import { bar, c, count, emitJson, fmtAgo } from "./render.ts";
|
|
11
13
|
import type { Account, AccountThresholds, Bars, Config, Thresholds, Window } from "../lib/types.ts";
|
|
12
14
|
|
|
@@ -38,7 +40,48 @@ type PoolReport = {
|
|
|
38
40
|
accounts: StatusAccount[];
|
|
39
41
|
};
|
|
40
42
|
|
|
41
|
-
|
|
43
|
+
type StatusApiKey = {
|
|
44
|
+
label: string;
|
|
45
|
+
id: string;
|
|
46
|
+
workspaceId: string | null;
|
|
47
|
+
creditUsd: number | null;
|
|
48
|
+
spentUsd: number;
|
|
49
|
+
creditLeftUsd: number | null;
|
|
50
|
+
refused: boolean;
|
|
51
|
+
refusedAt: number | null;
|
|
52
|
+
sessions: number;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type StatusReport = { now: number; claude: PoolReport; codex: PoolReport; grok: PoolReport; apiKeys?: StatusApiKey[] };
|
|
56
|
+
|
|
57
|
+
function apiKeyReport(): StatusApiKey[] {
|
|
58
|
+
const sessions = keySessions();
|
|
59
|
+
return loadApiKeys().keys.map((k) => ({
|
|
60
|
+
label: k.label,
|
|
61
|
+
id: k.id,
|
|
62
|
+
workspaceId: k.workspaceId ?? null,
|
|
63
|
+
creditUsd: k.creditUsd ?? null,
|
|
64
|
+
spentUsd: k.spentUsd,
|
|
65
|
+
creditLeftUsd: creditLeft(k),
|
|
66
|
+
refused: k.refusedAt != null,
|
|
67
|
+
refusedAt: k.refusedAt ?? null,
|
|
68
|
+
sessions: sessions.get(k.id) ?? 0,
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function renderApiKeys(keys: StatusApiKey[]): void {
|
|
73
|
+
console.log(c.dim(`api keys (${count({ n: keys.length, noun: "key" })}, used only while every claude account is at its limit)`));
|
|
74
|
+
console.log();
|
|
75
|
+
for (const k of keys) {
|
|
76
|
+
const badges: string[] = [];
|
|
77
|
+
if (k.sessions > 0) badges.push(c.green(count({ n: k.sessions, noun: "session" })));
|
|
78
|
+
if (k.refused) badges.push(c.red("refused"));
|
|
79
|
+
else if (k.creditLeftUsd != null && k.creditLeftUsd <= 0) badges.push(c.yellow("spent"));
|
|
80
|
+
const credit = k.creditLeftUsd == null ? c.dim("credit unknown") : `credit left ${usd(k.creditLeftUsd)}`;
|
|
81
|
+
console.log(`${k.sessions > 0 ? c.green("●") : c.dim("○")} ${c.bold(k.label)} ${credit}${badges.length ? ` ${badges.join(" ")}` : ""}`);
|
|
82
|
+
}
|
|
83
|
+
console.log();
|
|
84
|
+
}
|
|
42
85
|
|
|
43
86
|
function currentWindow(a: Account, w: Window, bars: Bars | null, now: number): WindowReport {
|
|
44
87
|
const passed = w.resetsAt != null && w.resetsAt <= now;
|
|
@@ -259,11 +302,12 @@ export async function cmdStatus(opts: { json?: boolean; cached?: boolean } = {})
|
|
|
259
302
|
const cfg = loadConfig();
|
|
260
303
|
const now = Date.now();
|
|
261
304
|
const claudeReport = await collect(claude, cfg, now, cached);
|
|
305
|
+
const apiKeys = apiKeyReport();
|
|
262
306
|
if (!json) {
|
|
263
307
|
const codexPooled = loadAccounts(codexPool).accounts.length > 0;
|
|
264
308
|
const grokPooled = loadAccounts(grokPool).accounts.length > 0;
|
|
265
309
|
if (claudeReport.accounts.length === 0) {
|
|
266
|
-
if (!codexPooled && !grokPooled) {
|
|
310
|
+
if (!codexPooled && !grokPooled && apiKeys.length === 0) {
|
|
267
311
|
console.log(c.dim("no accounts yet, run `tokenmaxxing init` (or `tokenmaxxing init --codex`, `--grok`)"));
|
|
268
312
|
return 0;
|
|
269
313
|
}
|
|
@@ -273,11 +317,12 @@ export async function cmdStatus(opts: { json?: boolean; cached?: boolean } = {})
|
|
|
273
317
|
const header = `thresholds 5h ${claudeReport.thresholds.session}% weekly ${claudeReport.thresholds.weekly}% bars 5h ${claudeReport.bars.session}% weekly ${claudeReport.bars.weekly}% (${count({ n: claudeReport.accounts.length, noun: "claude account" })})`;
|
|
274
318
|
renderPool(claude, claudeReport, header, Date.now(), cfg.policy.usagePollTtlMs);
|
|
275
319
|
}
|
|
320
|
+
if (apiKeys.length > 0) renderApiKeys(apiKeys);
|
|
276
321
|
}
|
|
277
322
|
const codexReport = await collect(codex, cfg, now, cached);
|
|
278
323
|
const grokReport = await collect(grok, cfg, now, cached);
|
|
279
324
|
if (json) {
|
|
280
|
-
const report: StatusReport = { now, claude: claudeReport, codex: codexReport, grok: grokReport };
|
|
325
|
+
const report: StatusReport = { now, claude: claudeReport, codex: codexReport, grok: grokReport, ...(apiKeys.length > 0 ? { apiKeys } : {}) };
|
|
281
326
|
emitJson({ ok: true, ...report });
|
|
282
327
|
return 0;
|
|
283
328
|
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
1
2
|
import { sortBy } from "es-toolkit";
|
|
3
|
+
import { writeSessionCost } from "../lib/apikeys.ts";
|
|
2
4
|
import { claudePool, seatFromEnv } from "../lib/paths.ts";
|
|
5
|
+
import { supervisedSession } from "../lib/sessions.ts";
|
|
3
6
|
import { errorMessage } from "../lib/log.ts";
|
|
4
7
|
import { loadAccounts, loadConfig, writeUsage } from "../lib/state.ts";
|
|
5
8
|
import { familyTokens, matchedFamily, parseStatusLineStdin, parseStatusLineModel } from "../lib/usage.ts";
|
|
@@ -8,6 +11,7 @@ import { readStdin } from "../lib/proc.ts";
|
|
|
8
11
|
import { worktreeName } from "../lib/worktree.ts";
|
|
9
12
|
import { fmtResetShort, makeColors, makeUsagePaint, statuslineColor } from "../cli/render.ts";
|
|
10
13
|
import {
|
|
14
|
+
CostLineSchema,
|
|
11
15
|
JsonTextSchema,
|
|
12
16
|
StatusLineStdinSchema,
|
|
13
17
|
type Account,
|
|
@@ -127,6 +131,12 @@ export async function runStatusline(): Promise<number> {
|
|
|
127
131
|
}
|
|
128
132
|
} catch {
|
|
129
133
|
}
|
|
134
|
+
try {
|
|
135
|
+
const session = existsSync(claudePool.apiKeysJson) ? supervisedSession() : null;
|
|
136
|
+
const cost = session == null ? null : (CostLineSchema.safeParse(obj).data ?? null);
|
|
137
|
+
if (session != null && cost != null && cost.sessionId === session.live) writeSessionCost(session.sid, cost);
|
|
138
|
+
} catch {
|
|
139
|
+
}
|
|
130
140
|
|
|
131
141
|
let line: string;
|
|
132
142
|
try {
|
|
@@ -2,9 +2,10 @@ import { existsSync, readdirSync } from "node:fs";
|
|
|
2
2
|
import { basename, dirname, join } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { claude } from "../lib/claude.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { liveApiKeyId, refuseApiKey } from "../lib/apikeys.ts";
|
|
6
|
+
import { evaluateAndMaybeSwap, moveTarget } from "../lib/decide.ts";
|
|
6
7
|
import { readStdin } from "../lib/proc.ts";
|
|
7
|
-
import { adoptLiveSession, refusedAccounts, supervisedSession, writeRespawnMarker } from "../lib/sessions.ts";
|
|
8
|
+
import { adoptLiveSession, keyMovesSupported, refusedAccounts, supervisedSession, writeRespawnMarker } from "../lib/sessions.ts";
|
|
8
9
|
import { loadConfig } from "../lib/state.ts";
|
|
9
10
|
import { classifyEnforcedLimit, ENFORCED_ERRORS, findEnforcedRow, parseErrorBody, readTranscriptTail, type TranscriptRow } from "../lib/usage.ts";
|
|
10
11
|
import { paths } from "../lib/paths.ts";
|
|
@@ -60,6 +61,22 @@ export async function runStopFailureHook(): Promise<number> {
|
|
|
60
61
|
return 0;
|
|
61
62
|
}
|
|
62
63
|
const canRespawn = session != null && mainLoop;
|
|
64
|
+
const keyId = account == null ? liveApiKeyId() : null;
|
|
65
|
+
if (keyId != null) {
|
|
66
|
+
if (error !== "billing_error") return 0;
|
|
67
|
+
const known = await refuseApiKey(keyId, now);
|
|
68
|
+
log("stopfailure.key_refused", { key: keyId, known, subagent: !mainLoop });
|
|
69
|
+
const decision = await evaluateAndMaybeSwap(claude, now, canRespawn, null, { waiterId: canRespawn ? session?.sid : undefined, refused: refusedAccounts(), apiKeys: true, apiKeyId: keyId });
|
|
70
|
+
const target = moveTarget(decision);
|
|
71
|
+
if (session && canRespawn && target) {
|
|
72
|
+
writeRespawnMarker({ session, ...target, waitUntil: target.waitUntil ?? now, compact: false, origin: "stopfailure" });
|
|
73
|
+
log("stopfailure.marker", { session: session.sid.slice(0, 8), live: session.live.slice(0, 8), account: target.accountId.slice(0, 8), key: target.apiKeyId, waitUntil: target.waitUntil ?? now });
|
|
74
|
+
} else {
|
|
75
|
+
log("stopfailure.decision", { reason: decision.reason, swapped: decision.swapped, account: decision.account?.id.slice(0, 8), waitUntil: decision.waitUntil });
|
|
76
|
+
}
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
if (error === "billing_error") return 0;
|
|
63
80
|
const cfg = loadConfig();
|
|
64
81
|
const row = stdin.transcript_path
|
|
65
82
|
? await awaitEnforcedRow({ transcriptPath: stdin.transcript_path, agentId: stdin.agent_id, error, lastAssistantMessage: stdin.last_assistant_message })
|
|
@@ -92,10 +109,11 @@ export async function runStopFailureHook(): Promise<number> {
|
|
|
92
109
|
}
|
|
93
110
|
|
|
94
111
|
const refused = enforced?.kind === "credits" ? [...new Set([...refusedAccounts(), enforced.account])] : undefined;
|
|
95
|
-
const decision = await evaluateAndMaybeSwap(claude, now, canRespawn, enforced, { waiterId: canRespawn ? session?.sid : undefined, exclude: refused });
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
112
|
+
const decision = await evaluateAndMaybeSwap(claude, now, canRespawn, enforced, { waiterId: canRespawn ? session?.sid : undefined, exclude: refused, apiKeys: canRespawn && keyMovesSupported() });
|
|
113
|
+
const target = moveTarget(decision);
|
|
114
|
+
if (session && canRespawn && target) {
|
|
115
|
+
writeRespawnMarker({ session, ...target, waitUntil: target.waitUntil ?? now, compact: false, origin: "stopfailure", refused });
|
|
116
|
+
log("stopfailure.marker", { session: session.sid.slice(0, 8), live: session.live.slice(0, 8), account: target.accountId.slice(0, 8), key: target.apiKeyId, waitUntil: target.waitUntil ?? now });
|
|
99
117
|
} else {
|
|
100
118
|
log("stopfailure.decision", { reason: decision.reason, swapped: decision.swapped, account: decision.account?.id.slice(0, 8), waitUntil: decision.waitUntil });
|
|
101
119
|
}
|
package/src/entries/stophook.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { claude } from "../lib/claude.ts";
|
|
3
|
-
import { evaluateAndMaybeSwap } from "../lib/decide.ts";
|
|
3
|
+
import { evaluateAndMaybeSwap, moveTarget } from "../lib/decide.ts";
|
|
4
4
|
import { readStdin } from "../lib/proc.ts";
|
|
5
|
-
import { adoptLiveSession, refusedAccounts, supervisedSession, writeRespawnMarker } from "../lib/sessions.ts";
|
|
5
|
+
import { adoptLiveSession, keyMovesSupported, refusedAccounts, supervisedSession, writeRespawnMarker } from "../lib/sessions.ts";
|
|
6
6
|
import { JsonTextSchema } from "../lib/types.ts";
|
|
7
7
|
import { errorMessage, log } from "../lib/log.ts";
|
|
8
8
|
|
|
@@ -24,10 +24,11 @@ export async function runBoundaryHook(event: "stop" | "sessionstart"): Promise<n
|
|
|
24
24
|
log(`${event}.nested_session`, { stdin: stdin.session_id?.slice(0, 8), supervised: supervised.live.slice(0, 8), source: stdin.source });
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
|
-
const decision = await evaluateAndMaybeSwap(claude, Date.now(), session != null, null, { waiterId: session?.sid, refused: refusedAccounts() });
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
const decision = await evaluateAndMaybeSwap(claude, Date.now(), session != null, null, { waiterId: session?.sid, refused: refusedAccounts(), apiKeys: session != null && keyMovesSupported() });
|
|
28
|
+
const target = moveTarget(decision);
|
|
29
|
+
if (session && target) {
|
|
30
|
+
writeRespawnMarker({ session, ...target, waitUntil: target.waitUntil ?? Date.now(), compact: true, origin: event });
|
|
31
|
+
log(`${event}.${target.waitUntil !== undefined ? "wait" : "move"}`, { source: stdin.source, account: target.accountId.slice(0, 8), key: target.apiKeyId, waitUntil: target.waitUntil, session: session.sid.slice(0, 8), live: session.live.slice(0, 8) });
|
|
31
32
|
}
|
|
32
33
|
} catch (e) {
|
|
33
34
|
log(`${event}.error`, { err: errorMessage(e) });
|
|
@@ -6,9 +6,10 @@ import { maxBy, omit } from "es-toolkit";
|
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { claudePool, paths, storeDirFor } from "../lib/paths.ts";
|
|
8
8
|
import { CLAUDE_BIN, UNMANAGED_ENV, WRAP_DEPTH_ENV, resolveRealBin, wrapDepth } from "../lib/claudebin.ts";
|
|
9
|
-
import {
|
|
9
|
+
import { apiKeyEnv, clearSessionCost, foldSessionCost, loadApiKeys, readApiKey, sendApiKey, usableApiKey, writeSessionCost } from "../lib/apikeys.ts";
|
|
10
|
+
import { claude, pickLaunch, type LaunchTarget } from "../lib/claude.ts";
|
|
10
11
|
import { compactClaudeSession } from "../lib/compact.ts";
|
|
11
|
-
import { evaluateAndMaybeSwap } from "../lib/decide.ts";
|
|
12
|
+
import { evaluateAndMaybeSwap, moveTarget } from "../lib/decide.ts";
|
|
12
13
|
import { withLock } from "../lib/lock.ts";
|
|
13
14
|
import { thresholdBars, usableAt } from "../lib/picker.ts";
|
|
14
15
|
import { clearPresence } from "../lib/presence.ts";
|
|
@@ -19,7 +20,7 @@ import { saveTermios } from "../lib/tty.ts";
|
|
|
19
20
|
import { liveSessionId, loadSessionFlags, pruneStaleSessions, saveSessionFlags, writeRespawnMarker } from "../lib/sessions.ts";
|
|
20
21
|
import { loadAccounts, loadConfig, readJsonFile, releaseWaitClaim, writeUsage } from "../lib/state.ts";
|
|
21
22
|
import { gatedFamilies, modelFromFlag, parseStreamLine, scrubCredEnv } from "../lib/usage.ts";
|
|
22
|
-
import { JsonTextSchema, RespawnMarkerSchema, type Account, type Config, type ModelInfo, type UsageWindows } from "../lib/types.ts";
|
|
23
|
+
import { CostLineSchema, JsonTextSchema, RespawnMarkerSchema, type Account, type ApiKey, type Config, type ModelInfo, type UsageWindows } from "../lib/types.ts";
|
|
23
24
|
import { errorMessage, log } from "../lib/log.ts";
|
|
24
25
|
|
|
25
26
|
const SUBCOMMANDS = new Set([
|
|
@@ -254,10 +255,11 @@ async function moveExhaustedSeat(seat: Account, sid: string, gate: MarkerGate, m
|
|
|
254
255
|
const families = gatedFamilies(model, cfg.policy.switchModels);
|
|
255
256
|
const until = seatBlockedUntil(seat.id, now, families, cfg);
|
|
256
257
|
if (until == null || until <= gate.overriddenUntil) return false;
|
|
257
|
-
const decision = await evaluateAndMaybeSwap(claude, now, true, null, { seatId: seat.id, sessionFamilies: families, waiterId: sid, refused });
|
|
258
|
-
log("supervisor.seat_exhausted", { seat: seat.id.slice(0, 8), until, reason: decision.reason, account: decision.account?.id.slice(0, 8), waitUntil: decision.waitUntil });
|
|
259
|
-
|
|
260
|
-
|
|
258
|
+
const decision = await evaluateAndMaybeSwap(claude, now, true, null, { seatId: seat.id, sessionFamilies: families, waiterId: sid, refused, apiKeys: true });
|
|
259
|
+
log("supervisor.seat_exhausted", { seat: seat.id.slice(0, 8), until, reason: decision.reason, account: decision.account?.id.slice(0, 8), key: decision.apiKey?.id, waitUntil: decision.waitUntil });
|
|
260
|
+
const target = moveTarget(decision);
|
|
261
|
+
if (target) {
|
|
262
|
+
writeRespawnMarker({ session: { sid, launchedAt: gate.launchedAt, live: liveSessionId(sid) }, ...target, waitUntil: target.waitUntil ?? now, compact: true, origin: "seatwatch" });
|
|
261
263
|
}
|
|
262
264
|
} catch (e) {
|
|
263
265
|
log("supervisor.seat_watch_error", { err: errorMessage(e) });
|
|
@@ -265,14 +267,54 @@ async function moveExhaustedSeat(seat: Account, sid: string, gate: MarkerGate, m
|
|
|
265
267
|
return true;
|
|
266
268
|
}
|
|
267
269
|
|
|
270
|
+
async function moveKeySession(key: ApiKey, sid: string, gate: MarkerGate, model: ModelInfo | null, refused: string[], resumed: Set<string>): Promise<boolean> {
|
|
271
|
+
await foldCost(sid, key.id, resumed);
|
|
272
|
+
try {
|
|
273
|
+
if (usableApiKey(key.id) != null) return false;
|
|
274
|
+
const now = Date.now();
|
|
275
|
+
const cfg = loadConfig();
|
|
276
|
+
const families = gatedFamilies(model, cfg.policy.switchModels);
|
|
277
|
+
const decision = await evaluateAndMaybeSwap(claude, now, true, null, { seatId: null, sessionFamilies: families, waiterId: sid, refused, apiKeys: true, apiKeyId: key.id });
|
|
278
|
+
log("supervisor.key_watch", { key: key.id, reason: decision.reason, account: decision.account?.id.slice(0, 8), next: decision.apiKey?.id, waitUntil: decision.waitUntil });
|
|
279
|
+
const target = moveTarget(decision);
|
|
280
|
+
if (target && (target.waitUntil === undefined || target.waitUntil > gate.overriddenUntil)) {
|
|
281
|
+
writeRespawnMarker({ session: { sid, launchedAt: gate.launchedAt, live: liveSessionId(sid) }, ...target, waitUntil: target.waitUntil ?? now, compact: true, origin: "seatwatch" });
|
|
282
|
+
}
|
|
283
|
+
} catch (e) {
|
|
284
|
+
log("supervisor.key_watch_error", { err: errorMessage(e) });
|
|
285
|
+
}
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function resolveLaunch(wanted: { accountId: string | null; apiKeyId: string | null } | null, now: number, model: ModelInfo | null): LaunchTarget | null {
|
|
290
|
+
if (wanted?.apiKeyId != null) {
|
|
291
|
+
const key = usableApiKey(wanted.apiKeyId);
|
|
292
|
+
if (key) return { account: null, apiKey: key };
|
|
293
|
+
} else if (wanted?.accountId != null) {
|
|
294
|
+
const id = wanted.accountId;
|
|
295
|
+
const account = loadAccounts(claudePool).accounts.find((a) => a.id === id);
|
|
296
|
+
if (account) return { account, apiKey: null };
|
|
297
|
+
}
|
|
298
|
+
return pickLaunch(now, model);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function foldCost(sid: string, keyId: string | null, resumed: Set<string>): Promise<void> {
|
|
302
|
+
try {
|
|
303
|
+
await foldSessionCost(sid, keyId, resumed);
|
|
304
|
+
} catch (e) {
|
|
305
|
+
log("supervisor.cost_fold_failed", { err: errorMessage(e) });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
268
309
|
function systemLine(sid: string, text: string): string {
|
|
269
310
|
return `${JSON.stringify({ type: "system", subtype: "informational", content: text, level: "warning", uuid: crypto.randomUUID(), session_id: sid })}\n`;
|
|
270
311
|
}
|
|
271
312
|
|
|
272
|
-
function resumePrompt(input: { compacted: boolean; origin: z.infer<typeof RespawnMarkerSchema>["origin"] }): string {
|
|
313
|
+
function resumePrompt(input: { compacted: boolean; origin: z.infer<typeof RespawnMarkerSchema>["origin"]; onKey: boolean }): string {
|
|
314
|
+
const where = input.onKey ? "on an Anthropic API key, because every pooled account is at its limit or the previous key ran out" : "on an account with quota headroom";
|
|
273
315
|
const moved = input.compacted
|
|
274
|
-
?
|
|
275
|
-
:
|
|
316
|
+
? `tokenmaxxing compacted this conversation and resumed the session ${where}.`
|
|
317
|
+
: `tokenmaxxing resumed this session ${where}.`;
|
|
276
318
|
if (input.origin === "stop") {
|
|
277
319
|
return `${moved} The previous turn finished before the move. If it waited on the user or completed the task, end this turn without restating it. If it waited on a background Bash task, Monitor, or Workflow run, relaunch that work, because the move ended it.`;
|
|
278
320
|
}
|
|
@@ -304,11 +346,14 @@ function stoppedTaskLines(open: OpenTasks): string[] {
|
|
|
304
346
|
return [...open].map(([id, task]) => `${JSON.stringify({ type: "system", subtype: "task_notification", task_id: id, status: "stopped", reason: "worker_restart", output_file: "", summary: `Stopped by a tokenmaxxing restart: ${task.description || id}`, uuid: crypto.randomUUID(), session_id: task.sessionId })}\n`);
|
|
305
347
|
}
|
|
306
348
|
|
|
307
|
-
function seatTee(seatId: string, model: ModelInfo | null, open: OpenTasks): (line: string) => void {
|
|
349
|
+
function seatTee(seatId: string | null, model: ModelInfo | null, open: OpenTasks, costSid: string | null): (line: string) => void {
|
|
308
350
|
let last: UsageWindows | null = null;
|
|
309
351
|
return (line) => {
|
|
310
352
|
const value = JsonTextSchema.safeParse(line).data;
|
|
311
353
|
trackTask(open, value);
|
|
354
|
+
const cost = costSid == null ? null : (CostLineSchema.safeParse(value).data ?? null);
|
|
355
|
+
if (costSid != null && cost != null) writeSessionCost(costSid, cost);
|
|
356
|
+
if (seatId == null) return;
|
|
312
357
|
const msg = parseStreamLine(value);
|
|
313
358
|
if (msg?.windows) last = msg.windows;
|
|
314
359
|
else if (msg?.type !== "assistant") return;
|
|
@@ -508,7 +553,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
508
553
|
});
|
|
509
554
|
|
|
510
555
|
if (!info.manage || process.env[UNMANAGED_ENV]) {
|
|
511
|
-
const passthroughEnv = omit(childEnv, ["TOKENMAXXING_SUPERVISED", "TOKENMAXXING_SESSION_ID", "TOKENMAXXING_MODEL", "TOKENMAXXING_REFUSED"]);
|
|
556
|
+
const passthroughEnv = omit(childEnv, ["TOKENMAXXING_SUPERVISED", "TOKENMAXXING_SESSION_ID", "TOKENMAXXING_MODEL", "TOKENMAXXING_REFUSED", "TOKENMAXXING_API_KEY_ID"]);
|
|
512
557
|
return runPassthrough({ real, argv, env: passthroughEnv, onSpawn: (p) => { child = p; } });
|
|
513
558
|
}
|
|
514
559
|
|
|
@@ -536,6 +581,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
536
581
|
pruneStaleSessions(Date.now());
|
|
537
582
|
|
|
538
583
|
let launchArgs = resume ? ["--resume", sid, ...base] : ["--session-id", sid, ...base];
|
|
584
|
+
const resumed = new Set<string>(resume ? [sid] : []);
|
|
539
585
|
let pendingTranscript = resume ? null : transcriptPath(sid);
|
|
540
586
|
|
|
541
587
|
mkdirSync(paths.respawnDir, { recursive: true });
|
|
@@ -557,7 +603,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
557
603
|
let firstLine: string | null = null;
|
|
558
604
|
let respawns = 0;
|
|
559
605
|
let overriddenUntil = 0;
|
|
560
|
-
let wanted: string | null = null;
|
|
606
|
+
let wanted: { accountId: string | null; apiKeyId: string | null } | null = null;
|
|
561
607
|
let refused: string[] = [];
|
|
562
608
|
let drainStdout: (() => Promise<void>) | null = null;
|
|
563
609
|
try {
|
|
@@ -567,55 +613,62 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
567
613
|
const gate: MarkerGate = { launchedAt: Date.now(), overriddenUntil };
|
|
568
614
|
const launched = await withLock(claudePool.lockFile, async () => {
|
|
569
615
|
if (terminating) return null;
|
|
570
|
-
const
|
|
571
|
-
|
|
616
|
+
const target = resolveLaunch(wanted, gate.launchedAt, model);
|
|
617
|
+
const picked = target?.account ?? null;
|
|
618
|
+
const key = target?.apiKey ?? null;
|
|
619
|
+
log("supervisor.launch", { sid, respawns, seat: picked?.id.slice(0, 8) ?? null, key: key?.id, args: launchArgs.join(" "), injected: firstLine !== null });
|
|
572
620
|
const open: OpenTasks = new Map();
|
|
573
|
-
const
|
|
621
|
+
const costSid = existsSync(claudePool.apiKeysJson) ? sid : null;
|
|
622
|
+
const tee = stream && (picked || costSid != null) ? seatTee(picked?.id ?? null, model, open, costSid) : null;
|
|
623
|
+
const secret = key ? readApiKey(key) : null;
|
|
624
|
+
const extra: "pipe"[] = secret == null ? [] : ["pipe"];
|
|
625
|
+
const scrubbed = scrubCredEnv(childEnv);
|
|
574
626
|
const spawned = Bun.spawn([real, ...launchArgs], {
|
|
575
|
-
|
|
576
|
-
stdout: tee === null ? "inherit" : "pipe",
|
|
577
|
-
stderr: "inherit",
|
|
627
|
+
stdio: [relay === null ? "inherit" : "pipe", tee === null ? "inherit" : "pipe", "inherit", ...extra],
|
|
578
628
|
env: {
|
|
579
|
-
...(picked ? { ...
|
|
629
|
+
...(picked ? { ...scrubbed, CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(picked.id) } : key ? apiKeyEnv(scrubbed, key) : childEnv),
|
|
580
630
|
TOKENMAXXING_SUPERVISED: "1",
|
|
581
631
|
TOKENMAXXING_SESSION_ID: sid,
|
|
582
632
|
TOKENMAXXING_LAUNCHED_AT: String(gate.launchedAt),
|
|
583
633
|
TOKENMAXXING_MODEL: effective.model ?? "",
|
|
584
634
|
TOKENMAXXING_REFUSED: refused.join(","),
|
|
635
|
+
TOKENMAXXING_API_KEY_ID: key?.id ?? "",
|
|
585
636
|
},
|
|
586
637
|
});
|
|
587
638
|
child = spawned;
|
|
639
|
+
if (secret != null) sendApiKey(spawned, secret);
|
|
588
640
|
const drain = tee !== null && spawned.stdout instanceof ReadableStream ? relayStdout(spawned.stdout, tee) : null;
|
|
589
641
|
if (relay !== null) relay.attach(spawned.stdin!, firstLine, pendingTranscript);
|
|
590
642
|
firstLine = null;
|
|
591
|
-
if (picked) {
|
|
643
|
+
if (picked || key) {
|
|
592
644
|
await recordPresenceOrStop({
|
|
593
645
|
child: spawned,
|
|
594
646
|
dir: paths.presenceDir,
|
|
595
647
|
id: sid,
|
|
596
|
-
accountId: picked
|
|
648
|
+
accountId: picked?.id ?? "",
|
|
649
|
+
...(key ? { apiKeyId: key.id } : {}),
|
|
597
650
|
event: "supervisor.presence_failed",
|
|
598
651
|
message: "could not write the session presence file - refusing to run a session whose seat placement cannot see",
|
|
599
652
|
savedTermios,
|
|
600
653
|
});
|
|
601
654
|
}
|
|
602
655
|
releaseClaimLocked(sid);
|
|
603
|
-
return { child: spawned, seat: picked, drain, open };
|
|
656
|
+
return { child: spawned, seat: picked, key, drain, open };
|
|
604
657
|
});
|
|
605
658
|
if (launched == null) {
|
|
606
659
|
await releaseClaim();
|
|
607
660
|
return 143;
|
|
608
661
|
}
|
|
609
662
|
|
|
610
|
-
const { child: proc, seat, open } = launched;
|
|
663
|
+
const { child: proc, seat, key, open } = launched;
|
|
611
664
|
drainStdout = launched.drain;
|
|
612
665
|
let seatCheckAt = 0;
|
|
613
666
|
await raceMarkerOrExit({
|
|
614
667
|
child: proc,
|
|
615
668
|
tick: async () => {
|
|
616
669
|
if (existsSync(marker) && (await consumableMarker(marker, gate)) != null) return true;
|
|
617
|
-
if (seat && !terminating && Date.now() >= seatCheckAt) {
|
|
618
|
-
const decided = await moveExhaustedSeat(seat, sid, gate, model, refused);
|
|
670
|
+
if ((seat || key) && !terminating && Date.now() >= seatCheckAt) {
|
|
671
|
+
const decided = seat ? await moveExhaustedSeat(seat, sid, gate, model, refused) : key ? await moveKeySession(key, sid, gate, model, refused, resumed) : false;
|
|
619
672
|
seatCheckAt = Date.now() + (decided ? SEAT_RETRY_MS : SEAT_POLL_MS);
|
|
620
673
|
}
|
|
621
674
|
return false;
|
|
@@ -630,6 +683,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
630
683
|
});
|
|
631
684
|
await drainStdout?.();
|
|
632
685
|
drainStdout = null;
|
|
686
|
+
await foldCost(sid, key?.id ?? null, resumed);
|
|
633
687
|
|
|
634
688
|
const m = !terminating && existsSync(marker) ? await consumableMarker(marker, gate) : null;
|
|
635
689
|
if (m) {
|
|
@@ -638,7 +692,8 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
638
692
|
noticeSid = m.sessionId;
|
|
639
693
|
for (const line of stoppedTaskLines(open)) process.stdout.write(line);
|
|
640
694
|
const accounts = loadAccounts(claudePool).accounts;
|
|
641
|
-
const
|
|
695
|
+
const nextKey = m.apiKeyId == null ? null : (loadApiKeys().keys.find((k) => k.id === m.apiKeyId) ?? null);
|
|
696
|
+
const label = m.apiKeyId != null ? `API key ${nextKey?.label ?? m.apiKeyId}` : (accounts.find((a) => a.id === m.accountId)?.label ?? m.accountId.slice(0, 8));
|
|
642
697
|
const walledUntil = accounts.find((a) => a.id === seat?.id)?.enforcedUntil ?? 0;
|
|
643
698
|
const transcript = findTranscript(m.sessionId);
|
|
644
699
|
const resumable = transcript !== null;
|
|
@@ -646,34 +701,50 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
646
701
|
for (const id of relay.replay()) process.stdout.write(controlErrorLine(id));
|
|
647
702
|
}
|
|
648
703
|
let compacted = false;
|
|
704
|
+
const compactKey = key != null && usableApiKey(key.id) != null ? key : null;
|
|
705
|
+
const compactOn = seat?.label ?? (compactKey ? `API key ${compactKey.label}` : null);
|
|
649
706
|
if (m.compact && seat && resumable && walledUntil > Date.now()) {
|
|
650
707
|
log("supervisor.compact_skipped", { sid: m.sessionId.slice(0, 8), seat: seat.id.slice(0, 8), until: walledUntil });
|
|
651
|
-
} else if (m.compact &&
|
|
652
|
-
say(`\n\x1b[36m↻ tokenmaxxing: compacting the conversation on ${
|
|
653
|
-
const
|
|
654
|
-
const
|
|
708
|
+
} else if (m.compact && compactOn != null && resumable) {
|
|
709
|
+
say(`\n\x1b[36m↻ tokenmaxxing: compacting the conversation on ${compactOn} before the move...\x1b[0m\n`, `tokenmaxxing: compacting the conversation on ${compactOn} before the move.`);
|
|
710
|
+
const baseEnv = { ...omit(scrubCredEnv(childEnv), ["TOKENMAXXING_SUPERVISED", "TOKENMAXXING_SESSION_ID", "TOKENMAXXING_LAUNCHED_AT", "TOKENMAXXING_MODEL", "TOKENMAXXING_REFUSED", "TOKENMAXXING_API_KEY_ID"]), TOKENMAXXING_PROBE: "1" };
|
|
711
|
+
const compactEnv = seat ? { ...baseEnv, CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(seat.id) } : compactKey ? apiKeyEnv(baseEnv, compactKey) : baseEnv;
|
|
712
|
+
const compactSecret = seat == null && compactKey ? readApiKey(compactKey) : null;
|
|
713
|
+
const outcome = await compactClaudeSession({
|
|
714
|
+
real,
|
|
715
|
+
sid: m.sessionId,
|
|
716
|
+
transcript,
|
|
717
|
+
env: compactEnv,
|
|
718
|
+
keyPipe: compactSecret != null,
|
|
719
|
+
onSpawn: (p) => {
|
|
720
|
+
child = p;
|
|
721
|
+
if (compactSecret != null) sendApiKey(p, compactSecret);
|
|
722
|
+
},
|
|
723
|
+
});
|
|
655
724
|
child = null;
|
|
656
725
|
if (terminating) {
|
|
657
726
|
await releaseClaim();
|
|
658
727
|
return 143;
|
|
659
728
|
}
|
|
660
|
-
log("supervisor.compact", { sid: m.sessionId.slice(0, 8), seat: seat
|
|
729
|
+
log("supervisor.compact", { sid: m.sessionId.slice(0, 8), seat: seat?.id.slice(0, 8), key: compactKey?.id, ok: outcome.ok, reason: outcome.ok ? undefined : outcome.reason });
|
|
661
730
|
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})`);
|
|
662
731
|
compacted = outcome.ok;
|
|
663
732
|
}
|
|
664
733
|
if (m.waitUntil > Date.now()) {
|
|
665
734
|
if (await countdownWait(label, m.waitUntil, { stream, say })) overriddenUntil = m.waitUntil;
|
|
666
735
|
} else say(`\n\x1b[36m↻ tokenmaxxing: moving to ${label} - resuming...\x1b[0m\n`, `tokenmaxxing: moving to ${label} and resuming.`);
|
|
667
|
-
wanted = m.accountId;
|
|
736
|
+
wanted = m.apiKeyId != null ? { accountId: null, apiKeyId: m.apiKeyId } : { accountId: m.accountId, apiKeyId: null };
|
|
668
737
|
refused = m.refused ?? refused;
|
|
669
738
|
saveSessionFlags(m.sessionId, persistable, process.cwd());
|
|
670
|
-
const prompt = resumable ? resumePrompt({ compacted, origin: m.origin }) : null;
|
|
739
|
+
const prompt = resumable ? resumePrompt({ compacted, origin: m.origin, onKey: m.apiKeyId != null }) : null;
|
|
671
740
|
firstLine = relay !== null && prompt !== null ? userLine(prompt) : null;
|
|
672
741
|
pendingTranscript = resumable ? null : transcriptPath(m.sessionId);
|
|
742
|
+
if (resumable) resumed.add(m.sessionId);
|
|
673
743
|
launchArgs = [resumable ? "--resume" : "--session-id", m.sessionId, ...(relay === null && prompt !== null ? [prompt] : []), ...persistable];
|
|
674
744
|
continue;
|
|
675
745
|
}
|
|
676
746
|
clearPresence({ dir: paths.presenceDir, id: sid });
|
|
747
|
+
clearSessionCost(sid);
|
|
677
748
|
await releaseClaim();
|
|
678
749
|
log("supervisor.exit", { sid, respawns, code: proc.exitCode, signal: proc.signalCode, terminated: terminating || undefined });
|
|
679
750
|
return exitStatus(proc);
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { chmodSync, closeSync, existsSync, mkdirSync, readFileSync, rmSync, writeSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { countBy, isEqual, sortBy } from "es-toolkit";
|
|
4
|
+
import { writeFileAtomic } from "./atomic.ts";
|
|
5
|
+
import { withLock } from "./lock.ts";
|
|
6
|
+
import { claudePool, optionalEnv, paths } from "./paths.ts";
|
|
7
|
+
import { livingPresences } from "./presence.ts";
|
|
8
|
+
import type { ApiKeyPool } from "./provider.ts";
|
|
9
|
+
import { readJsonFile } from "./state.ts";
|
|
10
|
+
import { ApiKeysIndexSchema, SessionCostSchema, type ApiKey, type ApiKeysIndex, type SessionCost } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
export const API_KEY_ENV = "TOKENMAXXING_API_KEY_ID";
|
|
13
|
+
export const API_KEY_FD = 3;
|
|
14
|
+
|
|
15
|
+
export function apiKeyEnv(env: Record<string, string | undefined>, key: ApiKey): Record<string, string | undefined> {
|
|
16
|
+
const header = key.workspaceId == null ? null : `anthropic-workspace-id: ${key.workspaceId}`;
|
|
17
|
+
const headers = header == null ? env.ANTHROPIC_CUSTOM_HEADERS : env.ANTHROPIC_CUSTOM_HEADERS ? `${env.ANTHROPIC_CUSTOM_HEADERS}\n${header}` : header;
|
|
18
|
+
return { ...env, CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR: String(API_KEY_FD), ...(headers != null ? { ANTHROPIC_CUSTOM_HEADERS: headers } : {}) };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function readApiKey(key: ApiKey): string {
|
|
22
|
+
return `${readFileSync(apiKeySecretFor(key.id), "utf8").trim()}\n`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function sendApiKey(child: { stdio: readonly (number | null)[] }, secret: string): void {
|
|
26
|
+
const fd = child.stdio[API_KEY_FD];
|
|
27
|
+
if (fd == null) throw new Error(`the child has no descriptor ${API_KEY_FD} to receive the API key`);
|
|
28
|
+
try {
|
|
29
|
+
const written = writeSync(fd, secret);
|
|
30
|
+
if (written !== Buffer.byteLength(secret)) throw new Error(`wrote ${written} of ${Buffer.byteLength(secret)} bytes of the API key to descriptor ${API_KEY_FD}`);
|
|
31
|
+
} finally {
|
|
32
|
+
closeSync(fd);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function apiKeySecretFor(id: string): string {
|
|
37
|
+
return join(paths.apiKeysDir, id);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function loadApiKeys(): ApiKeysIndex {
|
|
41
|
+
if (!existsSync(claudePool.apiKeysJson)) return { version: 1, keys: [], baselines: {} };
|
|
42
|
+
return readJsonFile(claudePool.apiKeysJson, ApiKeysIndexSchema);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function saveApiKeys(idx: ApiKeysIndex): void {
|
|
46
|
+
writeFileAtomic(claudePool.apiKeysJson, JSON.stringify(ApiKeysIndexSchema.parse(idx), null, 2) + "\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function writeApiKeySecret(id: string, secret: string): void {
|
|
50
|
+
mkdirSync(paths.apiKeysDir, { recursive: true, mode: 0o700 });
|
|
51
|
+
chmodSync(paths.apiKeysDir, 0o700);
|
|
52
|
+
writeFileAtomic(apiKeySecretFor(id), secret);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function removeApiKeySecret(id: string): void {
|
|
56
|
+
rmSync(apiKeySecretFor(id), { force: true });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function creditLeft(k: ApiKey): number | null {
|
|
60
|
+
return k.creditUsd == null ? null : k.creditUsd - k.spentUsd;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function keyUsable(k: ApiKey): boolean {
|
|
64
|
+
const left = creditLeft(k);
|
|
65
|
+
return k.refusedAt == null && (left == null || left > 0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function keySessions(): Map<string, number> {
|
|
69
|
+
const ids = livingPresences(paths.presenceDir).flatMap((p) => (p.apiKeyId == null ? [] : [p.apiKeyId]));
|
|
70
|
+
return new Map(Object.entries(countBy(ids, (id) => id)));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function pickApiKey(keys: ApiKey[], sessions: Map<string, number>, exclude: string[]): ApiKey | null {
|
|
74
|
+
const candidates = keys.filter((k) => keyUsable(k) && !exclude.includes(k.id));
|
|
75
|
+
return sortBy(candidates, [(k) => (creditLeft(k) == null ? 1 : 0), (k) => -(creditLeft(k) ?? 0), (k) => sessions.get(k.id) ?? 0, (k) => k.addedAt])[0] ?? null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function availableKeys(): ApiKey[] {
|
|
79
|
+
return loadApiKeys().keys.filter((k) => existsSync(apiKeySecretFor(k.id)));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function usableApiKey(id: string): ApiKey | null {
|
|
83
|
+
const key = availableKeys().find((k) => k.id === id);
|
|
84
|
+
return key != null && keyUsable(key) ? key : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function launchApiKey(): ApiKey | null {
|
|
88
|
+
return pickApiKey(availableKeys(), keySessions(), []);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function liveApiKeyId(env: Record<string, string | undefined> = process.env): string | null {
|
|
92
|
+
return optionalEnv(API_KEY_ENV, env) ?? null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const claudeApiKeys: ApiKeyPool = {
|
|
96
|
+
liveId: () => liveApiKeyId(),
|
|
97
|
+
holds: (id) => usableApiKey(id) != null,
|
|
98
|
+
pick: (exclude) => pickApiKey(availableKeys(), keySessions(), exclude),
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export async function refuseApiKey(id: string, now: number): Promise<boolean> {
|
|
102
|
+
return withLock(claudePool.lockFile, () => {
|
|
103
|
+
const idx = loadApiKeys();
|
|
104
|
+
const key = idx.keys.find((k) => k.id === id);
|
|
105
|
+
if (!key) return false;
|
|
106
|
+
key.refusedAt = now;
|
|
107
|
+
saveApiKeys(idx);
|
|
108
|
+
return true;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const costFileFor = (sid: string): string => join(paths.costDir, `${sid}.json`);
|
|
113
|
+
|
|
114
|
+
export function writeSessionCost(sid: string, cost: SessionCost): void {
|
|
115
|
+
const file = costFileFor(sid);
|
|
116
|
+
if (existsSync(file) && isEqual(readJsonFile(file, SessionCostSchema), cost)) return;
|
|
117
|
+
writeFileAtomic(file, JSON.stringify(cost));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function clearSessionCost(sid: string): void {
|
|
121
|
+
rmSync(costFileFor(sid), { force: true });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const BASELINE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
125
|
+
|
|
126
|
+
export async function foldSessionCost(sid: string, keyId: string | null, resumed: Set<string>): Promise<void> {
|
|
127
|
+
const file = costFileFor(sid);
|
|
128
|
+
if (!existsSync(file) || !existsSync(claudePool.apiKeysJson)) return;
|
|
129
|
+
const seen = readJsonFile(file, SessionCostSchema);
|
|
130
|
+
await withLock(claudePool.lockFile, () => {
|
|
131
|
+
const idx = loadApiKeys();
|
|
132
|
+
const prev = idx.baselines[seen.sessionId];
|
|
133
|
+
if (prev?.usd === seen.usd) return;
|
|
134
|
+
const delta = prev != null ? Math.max(0, seen.usd - prev.usd) : resumed.has(seen.sessionId) ? 0 : seen.usd;
|
|
135
|
+
const key = keyId == null ? undefined : idx.keys.find((k) => k.id === keyId);
|
|
136
|
+
if (key) key.spentUsd += delta;
|
|
137
|
+
const now = Date.now();
|
|
138
|
+
idx.baselines = Object.fromEntries(Object.entries(idx.baselines).filter(([, b]) => now - b.at <= BASELINE_RETENTION_MS));
|
|
139
|
+
idx.baselines[seen.sessionId] = { usd: seen.usd, at: now };
|
|
140
|
+
saveApiKeys(idx);
|
|
141
|
+
});
|
|
142
|
+
}
|
package/src/lib/claude.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { countBy } from "es-toolkit";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
+
import { claudeApiKeys, launchApiKey } from "./apikeys.ts";
|
|
5
6
|
import { readItem, writeItem, deleteItem, isolatedTarget, readStore, storeTarget, claudeAiOauthOnly } from "./credstore.ts";
|
|
6
7
|
import { CLAUDE_BIN, resolveRealBin, resolveVerifiedClaude } from "./claudebin.ts";
|
|
7
8
|
import { activationHint, CHECK_JOB, ensurePathInRc, HUB_JOB, installSupervisor, managedShellRcSkipLines, shellRcPath } from "./install.ts";
|
|
@@ -17,7 +18,7 @@ import { claimSample, foldTee, landRead, readyToSample, runSample, sampleAccount
|
|
|
17
18
|
import { clearUsageSnapshot, liveWaitClaims, loadAccounts, loadConfig, loadUsageSnapshot, pinBinOverride, readJsonFile, saveAccounts, type Harvest } from "./state.ts";
|
|
18
19
|
import { saveTermios, restoreTermios } from "./tty.ts";
|
|
19
20
|
import { claimResetGrant, fetchUsageDirect, gatedFamilies, mergeWindows, modelFromFlag, scrubCredEnv, windowsOf } from "./usage.ts";
|
|
20
|
-
import { CredentialBlobSchema, JsonTextSchema, OAuthAccountSchema, type Account, type Config, type ModelInfo } from "./types.ts";
|
|
21
|
+
import { CredentialBlobSchema, JsonTextSchema, OAuthAccountSchema, type Account, type ApiKey, type Config, type ModelInfo } from "./types.ts";
|
|
21
22
|
import { c } from "../cli/render.ts";
|
|
22
23
|
|
|
23
24
|
function liveId(): string | null {
|
|
@@ -25,7 +26,7 @@ function liveId(): string | null {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
function presence(living = livingPresences(paths.presenceDir)): Map<string, number> {
|
|
28
|
-
const seats = new Map(living.map((s) => [s.id, s.accountId]));
|
|
29
|
+
const seats = new Map(living.filter((s) => s.apiKeyId == null).map((s) => [s.id, s.accountId]));
|
|
29
30
|
for (const claim of liveWaitClaims(Date.now())) seats.set(claim.sessionId, claim.accountId);
|
|
30
31
|
return new Map(Object.entries(countBy([...seats.values()], (id) => id)));
|
|
31
32
|
}
|
|
@@ -138,7 +139,7 @@ async function storeUsable(a: Account): Promise<boolean> {
|
|
|
138
139
|
}
|
|
139
140
|
}
|
|
140
141
|
|
|
141
|
-
|
|
142
|
+
function seatContext(now: number, model: ModelInfo | null, eligible: (a: Account) => boolean): { candidates: Account[]; ctx: PickCtx; fallback: () => Account | null } {
|
|
142
143
|
const cfg = loadConfig();
|
|
143
144
|
const bars = thresholdBars(cfg);
|
|
144
145
|
const idx = loadAccounts(claudePool);
|
|
@@ -148,12 +149,28 @@ export function pickSeat(now: number, model: ModelInfo | null, eligible: (a: Acc
|
|
|
148
149
|
const seats = presence();
|
|
149
150
|
const ctx: PickCtx = { now, thresholds: bars, currentId: null, families: gatedFamilies(model, cfg.policy.switchModels), seats };
|
|
150
151
|
const candidates = idx.accounts.filter(eligible);
|
|
151
|
-
|
|
152
|
-
pickBest(candidates, ctx) ??
|
|
152
|
+
const fallback = () =>
|
|
153
153
|
pickWaitTarget(candidates, ctx, seats, MAX_WAITERS_PER_ACCOUNT, now + cfg.policy.maxWaitMs, now + 2 * cfg.policy.maxWaitMs)?.account ??
|
|
154
154
|
pickEarliestReset(candidates, ctx)?.account ??
|
|
155
|
-
null
|
|
156
|
-
|
|
155
|
+
null;
|
|
156
|
+
return { candidates, ctx, fallback };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function pickSeat(now: number, model: ModelInfo | null, eligible: (a: Account) => boolean = () => true): Account | null {
|
|
160
|
+
const { candidates, ctx, fallback } = seatContext(now, model, eligible);
|
|
161
|
+
return pickBest(candidates, ctx) ?? fallback();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export type LaunchTarget = { account: Account; apiKey: null } | { account: null; apiKey: ApiKey };
|
|
165
|
+
|
|
166
|
+
export function pickLaunch(now: number, model: ModelInfo | null): LaunchTarget | null {
|
|
167
|
+
const { candidates, ctx, fallback } = seatContext(now, model, () => true);
|
|
168
|
+
const best = pickBest(candidates, ctx);
|
|
169
|
+
if (best) return { account: best, apiKey: null };
|
|
170
|
+
const key = launchApiKey();
|
|
171
|
+
if (key) return { account: null, apiKey: key };
|
|
172
|
+
const account = fallback();
|
|
173
|
+
return account ? { account, apiKey: null } : null;
|
|
157
174
|
}
|
|
158
175
|
|
|
159
176
|
export async function borrowClaudeSeat(pid: number): Promise<SeatBorrow> {
|
|
@@ -335,6 +352,7 @@ export const claude: Provider = {
|
|
|
335
352
|
seats: "shared",
|
|
336
353
|
waitsWhenDepleted: true,
|
|
337
354
|
statusOnly: false,
|
|
355
|
+
apiKeys: claudeApiKeys,
|
|
338
356
|
liveId,
|
|
339
357
|
presence,
|
|
340
358
|
gatedFamilies: (cfg) => {
|
package/src/lib/compact.ts
CHANGED
|
@@ -13,13 +13,12 @@ const PIPE_GRACE_MS = 2_000;
|
|
|
13
13
|
|
|
14
14
|
const CompactBoundarySchema = z.looseObject({ type: z.literal("system"), subtype: z.literal("compact_boundary") });
|
|
15
15
|
|
|
16
|
-
export async function compactClaudeSession(input: { real: string; sid: string; transcript: string; env: Record<string, string | undefined>; onSpawn: (child: Subprocess) => void }): Promise<CompactOutcome> {
|
|
16
|
+
export async function compactClaudeSession(input: { real: string; sid: string; transcript: string; env: Record<string, string | undefined>; keyPipe: boolean; onSpawn: (child: Subprocess) => void }): Promise<CompactOutcome> {
|
|
17
17
|
const offset = statSync(input.transcript).size;
|
|
18
|
+
const extra: "pipe"[] = input.keyPipe ? ["pipe"] : [];
|
|
18
19
|
const p = Bun.spawn([input.real, "-p", "--resume", input.sid, "/compact"], {
|
|
19
20
|
env: input.env,
|
|
20
|
-
|
|
21
|
-
stdout: "pipe",
|
|
22
|
-
stderr: "pipe",
|
|
21
|
+
stdio: ["ignore", "pipe", "pipe", ...extra],
|
|
23
22
|
timeout: CLAUDE_COMPACT_KILL_MS,
|
|
24
23
|
killSignal: "SIGKILL",
|
|
25
24
|
});
|
package/src/lib/decide.ts
CHANGED
|
@@ -6,9 +6,9 @@ import { MAX_WAITERS_PER_ACCOUNT, isExhausted, landWindows, limitWindows, liveUs
|
|
|
6
6
|
import { familyTokens } from "./usage.ts";
|
|
7
7
|
import { errorMessage, log } from "./log.ts";
|
|
8
8
|
import type { Observation, Provider, ResetClaim } from "./provider.ts";
|
|
9
|
-
import type { Account, Bars, Config, EnforcedLimit } from "./types.ts";
|
|
9
|
+
import type { Account, ApiKey, Bars, Config, EnforcedLimit } from "./types.ts";
|
|
10
10
|
|
|
11
|
-
export type SwapDecision = { swapped: boolean; account: Account | null; reason: string; waitUntil?: number };
|
|
11
|
+
export type SwapDecision = { swapped: boolean; account: Account | null; apiKey?: ApiKey; reason: string; waitUntil?: number };
|
|
12
12
|
|
|
13
13
|
export type EvalOpts = {
|
|
14
14
|
seatId?: string | null;
|
|
@@ -16,8 +16,18 @@ export type EvalOpts = {
|
|
|
16
16
|
waiterId?: string;
|
|
17
17
|
exclude?: string[];
|
|
18
18
|
refused?: string[];
|
|
19
|
+
apiKeys?: boolean;
|
|
20
|
+
apiKeyId?: string;
|
|
19
21
|
};
|
|
20
22
|
|
|
23
|
+
export type MoveTarget = { accountId: string; apiKeyId?: string; waitUntil: number | undefined };
|
|
24
|
+
|
|
25
|
+
export function moveTarget(d: SwapDecision): MoveTarget | null {
|
|
26
|
+
if (d.apiKey) return { accountId: "", apiKeyId: d.apiKey.id, waitUntil: undefined };
|
|
27
|
+
if (d.account && (d.swapped || d.waitUntil !== undefined)) return { accountId: d.account.id, waitUntil: d.waitUntil };
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
const MOVE_CLAIM_MS = 2 * CLAUDE_COMPACT_KILL_MS;
|
|
22
32
|
|
|
23
33
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
@@ -106,6 +116,8 @@ export async function useBankedResets(p: Provider, cfg: Config, now: number): Pr
|
|
|
106
116
|
|
|
107
117
|
export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRespawn = false, enforced: EnforcedLimit | null = null, opts: EvalOpts = {}): Promise<SwapDecision> {
|
|
108
118
|
const activeId = opts.seatId === undefined ? p.liveId() : opts.seatId;
|
|
119
|
+
const keys = opts.apiKeys === true ? p.apiKeys : undefined;
|
|
120
|
+
const keyId = activeId == null && keys ? (opts.apiKeyId ?? keys.liveId()) : null;
|
|
109
121
|
const waiterId = canRespawn ? opts.waiterId : undefined;
|
|
110
122
|
const cfg = loadConfig();
|
|
111
123
|
const bars = thresholdBars(cfg);
|
|
@@ -116,7 +128,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
|
|
|
116
128
|
const observed = stored0 ? await p.observeLive(stored0, cfg, now, { probe: enforced == null && !walled0, perModel: gated == null || gated.length > 0 }) : null;
|
|
117
129
|
const stored = loadAccounts(p.pool).accounts.find((a) => a.id === activeId);
|
|
118
130
|
|
|
119
|
-
if (!enforced && !isOver(stored, observed, { now, thresholds: bars, currentId: activeId, families: gated, seats: null })) {
|
|
131
|
+
if (!enforced && keyId == null && !isOver(stored, observed, { now, thresholds: bars, currentId: activeId, families: gated, seats: null })) {
|
|
120
132
|
if (waiterId != null && liveWaitClaims(now).some((c) => c.sessionId === waiterId)) {
|
|
121
133
|
await withLock(p.pool.lockFile, () => {
|
|
122
134
|
releaseWaitClaim(waiterId);
|
|
@@ -168,7 +180,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
|
|
|
168
180
|
const seats = p.seats === "shared" ? present : null;
|
|
169
181
|
|
|
170
182
|
const seatExhausted = active != null && isExhausted(active, { now, thresholds: bars, currentId: id2, families: switchFamilies, seats });
|
|
171
|
-
if (!enforced2 && !isOver(active, obs2, { now, thresholds: bars, currentId: id2, families, seats }) && !(enforced && seatExhausted)) {
|
|
183
|
+
if (keyId == null && !enforced2 && !isOver(active, obs2, { now, thresholds: bars, currentId: id2, families, seats }) && !(enforced && seatExhausted)) {
|
|
172
184
|
return { swapped: false, account: null, reason: "raced-already-swapped" };
|
|
173
185
|
}
|
|
174
186
|
if (p.seats === "shared" && !canRespawn) {
|
|
@@ -189,9 +201,9 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
|
|
|
189
201
|
const seat = seatOf(cur);
|
|
190
202
|
const ctx: PickCtx = { now, thresholds: bars, currentId: seat?.id ?? null, families: switchFamilies, seats };
|
|
191
203
|
const credited = !enforced2 && seat != null && !seat.needsReauth && !isExhausted(seat, ctx) && onCredits(seat, ctx);
|
|
192
|
-
const best = pickBest(usable(cur.accounts).filter((a) => !(credited && opts.refused?.includes(a.id))), ctx);
|
|
204
|
+
const best = pickBest(usable(cur.accounts).filter((a) => !((credited || keyId != null) && opts.refused?.includes(a.id))), ctx);
|
|
193
205
|
if (credited && (best == null || onCredits(best, ctx))) return { swapped: false, account: null, reason: "no-plan-headroom" };
|
|
194
|
-
if (!best) break;
|
|
206
|
+
if (!best || (keyId != null && onCredits(best, ctx))) break;
|
|
195
207
|
try {
|
|
196
208
|
await p.swap(best);
|
|
197
209
|
} catch (e) {
|
|
@@ -203,6 +215,15 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
|
|
|
203
215
|
return { swapped: true, account: best, reason: "swapped" };
|
|
204
216
|
}
|
|
205
217
|
|
|
218
|
+
if (keys) {
|
|
219
|
+
if (keyId != null && keys.holds(keyId)) return { swapped: false, account: null, reason: "api-key-holds" };
|
|
220
|
+
const key = keys.pick(keyId == null ? [] : [keyId]);
|
|
221
|
+
if (key) {
|
|
222
|
+
log("decide.api_key", { key: key.id, from: keyId ?? id2?.slice(0, 8) });
|
|
223
|
+
return { swapped: true, account: null, apiKey: key, reason: "api-key" };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
206
227
|
if (!p.waitsWhenDepleted) {
|
|
207
228
|
log("decide.depleted", { waitUntil: 0 });
|
|
208
229
|
return { swapped: false, account: null, reason: "all-depleted" };
|
package/src/lib/paths.ts
CHANGED
|
@@ -28,6 +28,8 @@ export const paths = {
|
|
|
28
28
|
onboardDir: join(TM_HOME, "onboard"),
|
|
29
29
|
storesDir: join(TM_HOME, "stores"),
|
|
30
30
|
hubKeyFile: join(TM_HOME, "hub-key"),
|
|
31
|
+
apiKeysDir: join(TM_HOME, "api-keys"),
|
|
32
|
+
costDir: join(TM_HOME, "cost"),
|
|
31
33
|
|
|
32
34
|
claudeSettings: env(
|
|
33
35
|
"TOKENMAXXING_CLAUDE_SETTINGS",
|
|
@@ -43,6 +45,7 @@ export const claudePool = {
|
|
|
43
45
|
accountsJson: join(TM_HOME, "accounts.json"),
|
|
44
46
|
lockFile: join(TM_HOME, "lock"),
|
|
45
47
|
waitQueueJson: join(TM_HOME, "wait-queue.json"),
|
|
48
|
+
apiKeysJson: join(TM_HOME, "api-keys.json"),
|
|
46
49
|
} as const;
|
|
47
50
|
|
|
48
51
|
export const codexPool = {
|
package/src/lib/pi.ts
CHANGED
|
@@ -98,6 +98,7 @@ export async function piLogin(pool: PiPool): Promise<PiLogin | null> {
|
|
|
98
98
|
function piMover(base: Provider, pool: PiPool): Provider {
|
|
99
99
|
return {
|
|
100
100
|
...base,
|
|
101
|
+
apiKeys: undefined,
|
|
101
102
|
swap: async (target) => {
|
|
102
103
|
if (!piStoreUsable(pool, target.id)) {
|
|
103
104
|
throw new StoreUnusableError(`${target.label} has no pi login - run \`tokenmaxxing auth --pi${base.flag} ${target.label}\``);
|
package/src/lib/presence.ts
CHANGED
|
@@ -13,12 +13,13 @@ const PresenceSchema = z.object({
|
|
|
13
13
|
accountId: z.string(),
|
|
14
14
|
pid: z.number(),
|
|
15
15
|
startedAt: z.string(),
|
|
16
|
+
apiKeyId: z.string().optional(),
|
|
16
17
|
});
|
|
17
18
|
|
|
18
|
-
export function writePresence(input: { dir: string; id: string; accountId: string; pid: number }): void {
|
|
19
|
+
export function writePresence(input: { dir: string; id: string; accountId: string; pid: number; apiKeyId?: string }): void {
|
|
19
20
|
const startedAt = pidStartTimes([input.pid]).get(input.pid);
|
|
20
21
|
if (startedAt == null) throw new Error(`could not read pid ${input.pid}'s start time (ps lstart) - refusing to write an unverifiable presence file`);
|
|
21
|
-
writeFileAtomic(join(input.dir, input.id), JSON.stringify(PresenceSchema.parse({ accountId: input.accountId, pid: input.pid, startedAt })));
|
|
22
|
+
writeFileAtomic(join(input.dir, input.id), JSON.stringify(PresenceSchema.parse({ accountId: input.accountId, pid: input.pid, startedAt, ...(input.apiKeyId != null ? { apiKeyId: input.apiKeyId } : {}) })));
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export function presencePid(input: { dir: string; id: string }): number | null {
|
|
@@ -34,7 +35,7 @@ export function clearPresence(input: { dir: string; id: string }): void {
|
|
|
34
35
|
rmSync(join(input.dir, input.id), { force: true });
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
export type LivingPresence = { id: string; accountId: string };
|
|
38
|
+
export type LivingPresence = { id: string; accountId: string; apiKeyId?: string };
|
|
38
39
|
|
|
39
40
|
export function livingPresences(dir: string): LivingPresence[] {
|
|
40
41
|
if (!existsSync(dir)) return [];
|
|
@@ -65,11 +66,11 @@ export function livingPresences(dir: string): LivingPresence[] {
|
|
|
65
66
|
rmSync(file, { force: true });
|
|
66
67
|
continue;
|
|
67
68
|
}
|
|
68
|
-
living.push({ id: name, accountId: record.accountId });
|
|
69
|
+
living.push({ id: name, accountId: record.accountId, ...(record.apiKeyId != null ? { apiKeyId: record.apiKeyId } : {}) });
|
|
69
70
|
}
|
|
70
71
|
return living;
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
export function seatCounts(dir: string): Map<string, number> {
|
|
74
|
-
return new Map(Object.entries(countBy(livingPresences(dir), (p) => p.accountId)));
|
|
75
|
+
return new Map(Object.entries(countBy(livingPresences(dir).filter((p) => p.apiKeyId == null), (p) => p.accountId)));
|
|
75
76
|
}
|
package/src/lib/provider.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { PoolPaths } from "./paths.ts";
|
|
2
2
|
import type { Harvest } from "./state.ts";
|
|
3
|
-
import type { Account, Config, Window } from "./types.ts";
|
|
3
|
+
import type { Account, ApiKey, Config, Window } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export type ApiKeyPool = {
|
|
6
|
+
liveId(): string | null;
|
|
7
|
+
holds(id: string): boolean;
|
|
8
|
+
pick(exclude: string[]): ApiKey | null;
|
|
9
|
+
};
|
|
4
10
|
|
|
5
11
|
export type Observation = { windows: Window[]; at: number };
|
|
6
12
|
|
|
@@ -26,6 +32,7 @@ export type Provider = {
|
|
|
26
32
|
seats: "shared" | "live";
|
|
27
33
|
waitsWhenDepleted: boolean;
|
|
28
34
|
statusOnly: boolean;
|
|
35
|
+
apiKeys?: ApiKeyPool;
|
|
29
36
|
liveId(): string | null;
|
|
30
37
|
presence(): Map<string, number>;
|
|
31
38
|
gatedFamilies(cfg: Config): string[] | null;
|
package/src/lib/sessions.ts
CHANGED
|
@@ -60,7 +60,7 @@ function listDir(dir: string, root: string): Dirent[] {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
function tmpSweepDirs(root: string): string[] {
|
|
63
|
-
const dirs = [paths.home, paths.usageDir, paths.presenceDir, paths.respawnDir, sessionsDir(), paths.binDir, codexPaths.presenceDir, codexPaths.respawnDir, codexPaths.onboardDir];
|
|
63
|
+
const dirs = [paths.home, paths.usageDir, paths.presenceDir, paths.respawnDir, sessionsDir(), paths.binDir, paths.apiKeysDir, paths.costDir, codexPaths.presenceDir, codexPaths.respawnDir, codexPaths.onboardDir];
|
|
64
64
|
for (const parent of STORE_PARENTS) {
|
|
65
65
|
for (const child of listDir(parent, root)) {
|
|
66
66
|
if (child.isDirectory()) dirs.push(join(parent, child.name));
|
|
@@ -113,6 +113,10 @@ export function supervisedSession(env: Record<string, string | undefined> = proc
|
|
|
113
113
|
return { sid, launchedAt: LaunchedAtSchema.parse(env.TOKENMAXXING_LAUNCHED_AT) ?? null, live: liveSessionId(sid) };
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
export function keyMovesSupported(env: Record<string, string | undefined> = process.env): boolean {
|
|
117
|
+
return env.TOKENMAXXING_API_KEY_ID !== undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
116
120
|
export function refusedAccounts(env: Record<string, string | undefined> = process.env): string[] {
|
|
117
121
|
return RefusedSchema.parse(env.TOKENMAXXING_REFUSED);
|
|
118
122
|
}
|
|
@@ -129,9 +133,10 @@ export function adoptLiveSession(session: SupervisedSession, stdinSid: string |
|
|
|
129
133
|
return { ...session, live: stdinSid };
|
|
130
134
|
}
|
|
131
135
|
|
|
132
|
-
export function writeRespawnMarker(input: { session: SupervisedSession; accountId: string; waitUntil: number; compact: boolean; origin: z.infer<typeof RespawnMarkerSchema>["origin"]; refused?: string[] }): void {
|
|
136
|
+
export function writeRespawnMarker(input: { session: SupervisedSession; accountId: string; apiKeyId?: string; waitUntil: number; compact: boolean; origin: z.infer<typeof RespawnMarkerSchema>["origin"]; refused?: string[] }): void {
|
|
133
137
|
const payload: z.infer<typeof RespawnMarkerSchema> = {
|
|
134
138
|
accountId: input.accountId,
|
|
139
|
+
...(input.apiKeyId != null ? { apiKeyId: input.apiKeyId } : {}),
|
|
135
140
|
ts: Date.now(),
|
|
136
141
|
waitUntil: input.waitUntil,
|
|
137
142
|
sessionId: input.session.live,
|
package/src/lib/supervise.ts
CHANGED
|
@@ -82,10 +82,10 @@ export async function runPassthrough(input: { real: string; argv: string[]; env:
|
|
|
82
82
|
return exitStatus(p);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
export async function recordPresenceOrStop(input: { child: ChildHandle; dir: string; id: string; accountId: string; event: string; message: string; savedTermios: string | null }): Promise<void> {
|
|
85
|
+
export async function recordPresenceOrStop(input: { child: ChildHandle; dir: string; id: string; accountId: string; apiKeyId?: string; event: string; message: string; savedTermios: string | null }): Promise<void> {
|
|
86
86
|
for (let attempt = 0; ; attempt++) {
|
|
87
87
|
try {
|
|
88
|
-
writePresence({ dir: input.dir, id: input.id, accountId: input.accountId, pid: input.child.pid });
|
|
88
|
+
writePresence({ dir: input.dir, id: input.id, accountId: input.accountId, pid: input.child.pid, ...(input.apiKeyId != null ? { apiKeyId: input.apiKeyId } : {}) });
|
|
89
89
|
return;
|
|
90
90
|
} catch (e) {
|
|
91
91
|
if (input.child.exitCode !== null || input.child.signalCode !== null) return;
|
package/src/lib/types.ts
CHANGED
|
@@ -169,8 +169,38 @@ export const RespawnMarkerSchema = z.object({
|
|
|
169
169
|
origin: z.enum(["stop", "sessionstart", "stopfailure", "seatwatch"]),
|
|
170
170
|
launchedAt: z.number().optional(),
|
|
171
171
|
refused: z.array(z.string()).optional(),
|
|
172
|
+
apiKeyId: z.string().optional(),
|
|
172
173
|
});
|
|
173
174
|
|
|
175
|
+
export const ApiKeySchema = z.object({
|
|
176
|
+
id: z.string(),
|
|
177
|
+
label: z.string(),
|
|
178
|
+
workspaceId: z.string().optional(),
|
|
179
|
+
creditUsd: z.number().optional(),
|
|
180
|
+
spentUsd: z.number(),
|
|
181
|
+
creditSetAt: z.number(),
|
|
182
|
+
refusedAt: z.number().optional(),
|
|
183
|
+
addedAt: z.number(),
|
|
184
|
+
});
|
|
185
|
+
export type ApiKey = z.infer<typeof ApiKeySchema>;
|
|
186
|
+
|
|
187
|
+
export const ApiKeysIndexSchema = z.object({
|
|
188
|
+
version: z.literal(1),
|
|
189
|
+
keys: z.array(ApiKeySchema).default([]),
|
|
190
|
+
baselines: z.record(z.string(), z.object({ usd: z.number(), at: z.number() })).default({}),
|
|
191
|
+
});
|
|
192
|
+
export type ApiKeysIndex = z.infer<typeof ApiKeysIndexSchema>;
|
|
193
|
+
|
|
194
|
+
export const SessionCostSchema = z.object({ sessionId: z.string(), usd: z.number() });
|
|
195
|
+
export type SessionCost = z.infer<typeof SessionCostSchema>;
|
|
196
|
+
|
|
197
|
+
export const CostLineSchema = z
|
|
198
|
+
.looseObject({ session_id: z.string(), total_cost_usd: z.number().optional(), cost: z.looseObject({ total_cost_usd: z.number() }).nullable().optional() })
|
|
199
|
+
.transform((line): SessionCost | null => {
|
|
200
|
+
const usd = line.total_cost_usd ?? line.cost?.total_cost_usd;
|
|
201
|
+
return usd == null ? null : { sessionId: line.session_id, usd };
|
|
202
|
+
});
|
|
203
|
+
|
|
174
204
|
export const EpochSecondsSchema = z.number().transform((seconds) => seconds * 1000);
|
|
175
205
|
|
|
176
206
|
export const InstantSchema = z.iso.datetime({ offset: true }).transform((iso) => Date.parse(iso));
|
package/src/lib/usage.ts
CHANGED
|
@@ -105,7 +105,7 @@ export function transcriptRowText(row: TranscriptRow): string {
|
|
|
105
105
|
return blocks.data.filter((b) => b.type === "text").map((b) => b.text ?? "").join("\n").trim();
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
export const ENFORCED_ERRORS = ["rate_limit", "oauth_org_not_allowed"];
|
|
108
|
+
export const ENFORCED_ERRORS = ["rate_limit", "oauth_org_not_allowed", "billing_error"];
|
|
109
109
|
|
|
110
110
|
export function findEnforcedRow(input: { rows: TranscriptRow[]; error: string; lastAssistantMessage: string | undefined }): TranscriptRow | null {
|
|
111
111
|
const { rows, error, lastAssistantMessage } = input;
|
package/src/main.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { cmdStatus } from "./cli/status.ts";
|
|
|
20
20
|
import { cmdDoctor } from "./cli/doctor.ts";
|
|
21
21
|
import { cmdRm } from "./cli/rm.ts";
|
|
22
22
|
import { cmdRename } from "./cli/rename.ts";
|
|
23
|
+
import { cmdKey } from "./cli/key.ts";
|
|
23
24
|
import { cmdCheck } from "./cli/check.ts";
|
|
24
25
|
import { cmdConfig } from "./cli/config.ts";
|
|
25
26
|
import { cmdSeat } from "./cli/seat.ts";
|
|
@@ -38,7 +39,7 @@ const PI_FLAG = "--pi";
|
|
|
38
39
|
const JSON_COMMANDS = new Set(["status", "config", "check"]);
|
|
39
40
|
const CODEX_COMMANDS = new Set(["init", "add", "auth", "rm", "rename", "seat"]);
|
|
40
41
|
const STATUS_ONLY_COMMANDS = new Set(["init", "add", "auth", "rm", "rename"]);
|
|
41
|
-
const MAX_OPERANDS = new Map([["init", 0], ["add", 0], ["rm", 1], ["rename", 2]]);
|
|
42
|
+
const MAX_OPERANDS = new Map([["init", 0], ["add", 0], ["rm", 1], ["rename", 2], ["key", 4]]);
|
|
42
43
|
|
|
43
44
|
function printHelp(): void {
|
|
44
45
|
console.log(`${c.bold("tokenmaxxing")} - automatic Claude Code account switching
|
|
@@ -58,6 +59,9 @@ function printHelp(): void {
|
|
|
58
59
|
${c.cyan("tokenmaxxing doctor")} verify the install is intact
|
|
59
60
|
${c.cyan("tokenmaxxing rename")} [--codex | --grok] <sel> <label>
|
|
60
61
|
${c.cyan("tokenmaxxing rm")} [--codex | --grok] <sel>
|
|
62
|
+
${c.cyan("tokenmaxxing key add")} <label> [credit-usd] [workspace-id] store an Anthropic API key read from stdin; Claude sessions run on it only while every pooled Claude account is at its limit (workspace-id: for a key that spans several workspaces)
|
|
63
|
+
${c.cyan("tokenmaxxing key credit")} <label> <usd> set the key's balance as the Console shows it and restart its spend count
|
|
64
|
+
${c.cyan("tokenmaxxing key rm")} <label> delete the key and its stored secret (refused while a session runs on it)
|
|
61
65
|
${c.cyan("tokenmaxxing seat")} <pid> lend one pooled Claude account to an unattended consumer until <pid> exits: prints the store directory to set as CLAUDE_SECURESTORAGE_CONFIG_DIR (the directory itself, as its owner, never a copy of its credential); host sessions and other borrowers share the account; exit 1 = none usable
|
|
62
66
|
${c.cyan("tokenmaxxing seat --codex")} <pid> borrow one pooled codex account for an unattended consumer (plugin, script): prints the CODEX_HOME to set, lent to <pid> until it exits; codex sessions and other borrowers share the account, and a refresh race between them can sign it out until auth --codex runs again; exit 1 = none usable, fall back to the ambient login
|
|
63
67
|
${c.cyan("tokenmaxxing serve")} serve the CLIProxyAPI-compatible usage API on http://localhost:<hub.port> (default 8317) so a dashboard such as T3 Code's "Add a CLIProxyAPI hub" shows every pooled Claude and Codex account's quota; the management key is the contents of hub-key in the state directory
|
|
@@ -173,6 +177,7 @@ async function main(): Promise<number> {
|
|
|
173
177
|
case "doctor": return cmdDoctor();
|
|
174
178
|
case "rm": return cmdRm(provider, args[1]);
|
|
175
179
|
case "rename": return cmdRename(provider, args.slice(1));
|
|
180
|
+
case "key": return cmdKey(args.slice(1));
|
|
176
181
|
case "seat": return cmdSeat(provider === codex ? "codex" : "claude", args[1], args.slice(2));
|
|
177
182
|
case "serve": return cmdServe(args.slice(1));
|
|
178
183
|
case "uninstall": {
|
|
@@ -198,7 +203,7 @@ async function main(): Promise<number> {
|
|
|
198
203
|
if (out.timer === "still-loaded") console.log(c.yellow(`⚠ the check job may still be loaded - run: ${deactivationHint(CHECK_JOB)}`));
|
|
199
204
|
if (out.hub === "still-loaded") console.log(c.yellow(`⚠ the usage hub job may still be loaded - run: ${deactivationHint(HUB_JOB)}`));
|
|
200
205
|
if (!out.pathLineRemoved) console.log(c.dim("(no tokenmaxxing PATH line found in the shell rc)"));
|
|
201
|
-
console.log(`kept: accounts.json, config.json, and every account credential store (claude: stores/ and its keychain items on macOS; codex: codex-stores/; pi: pi-stores/; grok: grok-stores/) - remove accounts with \`xx rm\` to delete their credentials`);
|
|
206
|
+
console.log(`kept: accounts.json, config.json, and every account credential store (claude: stores/ and its keychain items on macOS; codex: codex-stores/; pi: pi-stores/; grok: grok-stores/), plus api-keys.json and the API keys in api-keys/ - remove accounts with \`xx rm\` and keys with \`xx key rm\` to delete their credentials`);
|
|
202
207
|
return 0;
|
|
203
208
|
}
|
|
204
209
|
case "help":
|