tokenmaxxing 1.130.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/DESIGN.md +3 -3
- package/README.md +7 -7
- package/package.json +1 -1
- package/src/cli/auth.ts +1 -1
- package/src/cli/key.ts +147 -0
- package/src/cli/rename.ts +1 -1
- package/src/cli/seat.ts +0 -6
- package/src/cli/status.ts +50 -11
- 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 -19
- package/src/lib/pi.ts +1 -0
- package/src/lib/presence.ts +6 -5
- package/src/lib/provider.ts +9 -2
- package/src/lib/sessions.ts +9 -4
- package/src/lib/state.ts +1 -2
- package/src/lib/statusonly.ts +1 -1
- package/src/lib/supervise.ts +2 -2
- package/src/lib/types.ts +30 -1
- package/src/lib/usage.ts +1 -1
- package/src/main.ts +25 -22
- package/src/lib/opencodego.ts +0 -99
|
@@ -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" };
|