tokenmaxxing 1.87.0 → 1.89.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +2 -2
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/entries/pisupervisor.ts +7 -1
- package/src/entries/stopfailurehook.ts +5 -4
- package/src/entries/supervisor.ts +28 -10
- package/src/lib/claude.ts +6 -3
- package/src/lib/decide.ts +8 -3
- package/src/lib/sessions.ts +7 -1
- package/src/lib/types.ts +2 -1
- package/src/lib/usage.ts +12 -14
package/DESIGN.md
CHANGED
|
@@ -36,8 +36,8 @@ It is a process manager only - spawn with inherited stdio (stdin through a relay
|
|
|
36
36
|
- `codex-stores/<uuid8>/` - one Codex credential store per pooled account: its `auth.json`, with everything else symlinked to the shared `~/.codex` so sessions, history, and config stay shared and `codex resume` works across stores.
|
|
37
37
|
- `usage/<uuid8>.json` - the statusline tee per account, written by the sessions running on it.
|
|
38
38
|
- `live/<session-id>` - PID-validated presence per supervised session, naming its account; placement counts them.
|
|
39
|
-
- `respawn/<session-id>` - per-session respawn markers (the hook→supervisor signal): the target account, the session to resume, a `waitUntil`, a `compact` flag, the `origin` (the hook or the seat watch that wrote it),
|
|
40
|
-
- `wait-queue.json` - the
|
|
39
|
+
- `respawn/<session-id>` - per-session respawn markers (the hook→supervisor signal): the target account, the session to resume, a `waitUntil`, a `compact` flag, the `origin` (the hook or the seat watch that wrote it), the launch time the marker was written for, and after a usage-credits refusal the accounts that have refused the session.
|
|
40
|
+
- `wait-queue.json` - the claims of supervised sessions that are moving or waiting (`{sessionId, accountId, at, waitUntil}`), naming the account each relaunches on: placement counts a claimed session there, and at most four sessions wait on one account; a session that arrives when every account already has four waiters takes the soonest reset anyway.
|
|
41
41
|
- `bin/claude` and `bin/codex` - the supervisors, beside the `tokenmaxxing` and `xx` entry points; `bin/pi` after `init --pi`.
|
|
42
42
|
- `pi-stores/claude/<uuid8>/` and `pi-stores/codex/<uuid8>/` - one pi store per pooled account that has a pi login: the account's own pi grant in `auth.json`, everything else symlinked to the shared `~/.pi/agent` ([pi support](docs/content/docs/pi.mdx)).
|
|
43
43
|
- `sessions/<sid>.json`, `onboard/`, `hub-key`, `update.json`, `spawnrate.json`, `tokenmaxxing.log`, the codex `codex-onboard/`, `codex-respawn/`, and `codex-live/`, and the `grok-*` and `opencode-go-*` state of the status-only pools: the full table is in [State directory layout](docs/content/docs/state-directory.mdx).
|
package/README.md
CHANGED
|
@@ -84,7 +84,7 @@ claude # use claude as always; each session gets its own accoun
|
|
|
84
84
|
Each session runs on its own seat. One rule: while the seat is under its bars, nothing happens; once it is at or over a bar, the session moves to the usable account that ranks first. The session bar is `thresholds.session` (90) minus `policy.projectionMargin` (3), so **87%** of the 5-hour window by default, and the weekly bar is **98%**. The bars also screen candidates on any of:
|
|
85
85
|
|
|
86
86
|
- **Session** (5-hour) or **week (all models)** - the aggregate windows, fed free/push-based by the statusLine into one tee per account.
|
|
87
|
-
- **Per-model weekly cap** - the most capable model (Fable) has its own tighter weekly limit that binds *before* the aggregate (per-model caps currently exist only for Sonnet and Fable, and Sonnet's is generous). tokenmaxxing reads it from the direct no-spend usage read for the seat (free, 0 tokens, one attempt per interval) whenever the seat's tee is older than `policy.usagePollTtlMs` or the session's model is one of `policy.switchModels`, backing off after a failed sample, so a Fable session moves on the Fable cap while a Sonnet session rides the aggregate. A session launched with `--model` on another family (`claude --model opus`) ignores the Fable cap from launch on: it is placed on, and moves to, an account whose Fable cap is spent as long as that account's session and weekly windows have headroom. The exception is a move off an account the server already refused (an enforced-limit wall), which screens every configured family.
|
|
87
|
+
- **Per-model weekly cap** - the most capable model (Fable) has its own tighter weekly limit that binds *before* the aggregate (per-model caps currently exist only for Sonnet and Fable, and Sonnet's is generous). tokenmaxxing reads it from the direct no-spend usage read for the seat (free, 0 tokens, one attempt per interval) whenever the seat's tee is older than `policy.usagePollTtlMs` or the session's model is one of `policy.switchModels`, backing off after a failed sample, so a Fable session moves on the Fable cap while a Sonnet session rides the aggregate. A session launched with `--model` on another family (`claude --model opus`) ignores the Fable cap from launch on: it is placed on, and moves to, an account whose Fable cap is spent as long as that account's session and weekly windows have headroom. The exception is a move off an account the server already refused (an enforced-limit wall), which screens every configured family. A refusal for usage credits, which the usage read cannot predict, walls nothing: the refused session moves with the Fable cap screened, and a later credits move never returns it to an account that already refused it.
|
|
88
88
|
|
|
89
89
|
The bars' headroom is deliberate: it's the budget to reach a clean turn boundary before the account's real limit. The session bar sits lower (90, minus the margin) because a 5-hour reset is cheap to sit out; weekly quota is use-it-or-lose-it, so it drains closer to the limit (98).
|
|
90
90
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.89.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",
|
|
@@ -386,6 +386,13 @@ export async function runPiSupervisor(argv: string[]): Promise<number> {
|
|
|
386
386
|
savedTermios,
|
|
387
387
|
});
|
|
388
388
|
}
|
|
389
|
+
if (pool === "claude") {
|
|
390
|
+
try {
|
|
391
|
+
releaseWaitClaim(id);
|
|
392
|
+
} catch (e) {
|
|
393
|
+
log("pisupervisor.claim_release_failed", { err: errorMessage(e) });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
389
396
|
return { proc: spawned, seat: picked };
|
|
390
397
|
});
|
|
391
398
|
if (launched == null) {
|
|
@@ -422,7 +429,6 @@ export async function runPiSupervisor(argv: string[]): Promise<number> {
|
|
|
422
429
|
if (waitUntil > Date.now()) {
|
|
423
430
|
if (await countdownWait(target.label, waitUntil, { stream: false, say })) overriddenUntil = waitUntil;
|
|
424
431
|
} else say(`\n\x1b[36m↻ tokenmaxxing: moving pi to ${target.label} - resuming...\x1b[0m\n`);
|
|
425
|
-
await releaseClaim();
|
|
426
432
|
wanted = target.id;
|
|
427
433
|
launchArgs = sessionExists(dirs, sid) ? ["--session-id", sid, ...args.flags, "--", RESUME_PROMPT] : firstArgs;
|
|
428
434
|
continue;
|
|
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { claude } from "../lib/claude.ts";
|
|
3
3
|
import { evaluateAndMaybeSwap } from "../lib/decide.ts";
|
|
4
4
|
import { readStdin } from "../lib/proc.ts";
|
|
5
|
-
import { adoptLiveSession, supervisedSession, writeRespawnMarker } from "../lib/sessions.ts";
|
|
5
|
+
import { adoptLiveSession, refusedAccounts, supervisedSession, writeRespawnMarker } from "../lib/sessions.ts";
|
|
6
6
|
import { loadConfig } from "../lib/state.ts";
|
|
7
7
|
import { classifyEnforcedLimit, findEnforcedRow, parseErrorBody, readTranscriptTail, type TranscriptRow } from "../lib/usage.ts";
|
|
8
8
|
import { paths } from "../lib/paths.ts";
|
|
@@ -57,7 +57,7 @@ export async function runStopFailureHook(): Promise<number> {
|
|
|
57
57
|
|
|
58
58
|
let enforced: EnforcedLimit | null = null;
|
|
59
59
|
if (limit && account) {
|
|
60
|
-
enforced = { account, kind: limit.kind, family: limit.kind === "model" ? limit.family : null, resetsAt: limit.resetsAt, blind: !mainLoop && limit.kind !== "model" };
|
|
60
|
+
enforced = { account, kind: limit.kind, family: limit.kind === "model" || limit.kind === "credits" ? limit.family : null, resetsAt: limit.resetsAt, blind: !mainLoop && limit.kind !== "model" };
|
|
61
61
|
log("stopfailure.enforced", { kind: limit.kind, family: enforced.family ?? undefined, resetsAt: limit.resetsAt, subagent: !mainLoop });
|
|
62
62
|
} else {
|
|
63
63
|
log("stopfailure.unclassified", {
|
|
@@ -80,9 +80,10 @@ export async function runStopFailureHook(): Promise<number> {
|
|
|
80
80
|
);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
const
|
|
83
|
+
const refused = enforced?.kind === "credits" ? [...new Set([...refusedAccounts(), enforced.account])] : undefined;
|
|
84
|
+
const decision = await evaluateAndMaybeSwap(claude, now, canRespawn, enforced, { waiterId: canRespawn ? session?.sid : undefined, exclude: refused });
|
|
84
85
|
if (session && canRespawn && decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
|
|
85
|
-
writeRespawnMarker({ session, accountId: decision.account.id, waitUntil: decision.waitUntil ?? now, compact: false, origin: "stopfailure" });
|
|
86
|
+
writeRespawnMarker({ session, accountId: decision.account.id, waitUntil: decision.waitUntil ?? now, compact: false, origin: "stopfailure", refused });
|
|
86
87
|
log("stopfailure.marker", { session: session.sid.slice(0, 8), live: session.live.slice(0, 8), account: decision.account.id.slice(0, 8), waitUntil: decision.waitUntil ?? now });
|
|
87
88
|
} else {
|
|
88
89
|
log("stopfailure.decision", { reason: decision.reason, swapped: decision.swapped, account: decision.account?.id.slice(0, 8), waitUntil: decision.waitUntil });
|
|
@@ -180,7 +180,24 @@ type MarkerGate = {
|
|
|
180
180
|
overriddenUntil: number;
|
|
181
181
|
};
|
|
182
182
|
|
|
183
|
-
function
|
|
183
|
+
function releaseClaimLocked(sid: string): void {
|
|
184
|
+
try {
|
|
185
|
+
releaseWaitClaim(sid);
|
|
186
|
+
} catch (e) {
|
|
187
|
+
log("supervisor.claim_release_failed", { err: errorMessage(e) });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function discardMarker(marker: string, event: string, fields: Record<string, unknown>): Promise<null> {
|
|
192
|
+
await withLock(claudePool.lockFile, () => {
|
|
193
|
+
rmSync(marker, { force: true });
|
|
194
|
+
releaseClaimLocked(basename(marker));
|
|
195
|
+
});
|
|
196
|
+
log(event, fields);
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function consumableMarker(marker: string, gate: MarkerGate): Promise<z.infer<typeof RespawnMarkerSchema> | null> {
|
|
184
201
|
let m: z.infer<typeof RespawnMarkerSchema>;
|
|
185
202
|
try {
|
|
186
203
|
m = readJsonFile(marker, RespawnMarkerSchema);
|
|
@@ -191,14 +208,10 @@ function consumableMarker(marker: string, gate: MarkerGate): z.infer<typeof Resp
|
|
|
191
208
|
);
|
|
192
209
|
}
|
|
193
210
|
if (m.launchedAt !== undefined && m.launchedAt !== gate.launchedAt) {
|
|
194
|
-
|
|
195
|
-
log("supervisor.marker_stale", { markerLaunch: m.launchedAt, childLaunch: gate.launchedAt });
|
|
196
|
-
return null;
|
|
211
|
+
return discardMarker(marker, "supervisor.marker_stale", { markerLaunch: m.launchedAt, childLaunch: gate.launchedAt });
|
|
197
212
|
}
|
|
198
213
|
if (m.waitUntil > Date.now() && m.waitUntil <= gate.overriddenUntil) {
|
|
199
|
-
|
|
200
|
-
log("supervisor.marker_overridden", { waitUntil: m.waitUntil });
|
|
201
|
-
return null;
|
|
214
|
+
return discardMarker(marker, "supervisor.marker_overridden", { waitUntil: m.waitUntil });
|
|
202
215
|
}
|
|
203
216
|
return m;
|
|
204
217
|
}
|
|
@@ -365,6 +378,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
365
378
|
delete passthroughEnv.TOKENMAXXING_SUPERVISED;
|
|
366
379
|
delete passthroughEnv.TOKENMAXXING_SESSION_ID;
|
|
367
380
|
delete passthroughEnv.TOKENMAXXING_MODEL;
|
|
381
|
+
delete passthroughEnv.TOKENMAXXING_REFUSED;
|
|
368
382
|
return runPassthrough({ real, argv, env: passthroughEnv, onSpawn: (p) => { child = p; } });
|
|
369
383
|
}
|
|
370
384
|
|
|
@@ -414,6 +428,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
414
428
|
let respawns = 0;
|
|
415
429
|
let overriddenUntil = 0;
|
|
416
430
|
let wanted: string | null = null;
|
|
431
|
+
let refused: string[] = [];
|
|
417
432
|
try {
|
|
418
433
|
while (true) {
|
|
419
434
|
if (existsSync(marker)) rmSync(marker, { force: true });
|
|
@@ -433,6 +448,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
433
448
|
TOKENMAXXING_SESSION_ID: sid,
|
|
434
449
|
TOKENMAXXING_LAUNCHED_AT: String(gate.launchedAt),
|
|
435
450
|
TOKENMAXXING_MODEL: effective.model ?? "",
|
|
451
|
+
TOKENMAXXING_REFUSED: refused.join(","),
|
|
436
452
|
...(picked ? { CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(picked.id) } : {}),
|
|
437
453
|
},
|
|
438
454
|
});
|
|
@@ -450,6 +466,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
450
466
|
savedTermios,
|
|
451
467
|
});
|
|
452
468
|
}
|
|
469
|
+
releaseClaimLocked(sid);
|
|
453
470
|
return { child: spawned, seat: picked };
|
|
454
471
|
});
|
|
455
472
|
if (launched == null) {
|
|
@@ -462,7 +479,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
462
479
|
await raceMarkerOrExit({
|
|
463
480
|
child: proc,
|
|
464
481
|
tick: async () => {
|
|
465
|
-
if (existsSync(marker) && consumableMarker(marker, gate) != null) return true;
|
|
482
|
+
if (existsSync(marker) && (await consumableMarker(marker, gate)) != null) return true;
|
|
466
483
|
if (seat && !terminating && Date.now() >= seatCheckAt) {
|
|
467
484
|
const decided = await moveExhaustedSeat(seat, sid, gate, model);
|
|
468
485
|
seatCheckAt = Date.now() + (decided ? SEAT_RETRY_MS : SEAT_POLL_MS);
|
|
@@ -478,7 +495,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
478
495
|
},
|
|
479
496
|
});
|
|
480
497
|
|
|
481
|
-
const m = !terminating && existsSync(marker) ? consumableMarker(marker, gate) : null;
|
|
498
|
+
const m = !terminating && existsSync(marker) ? await consumableMarker(marker, gate) : null;
|
|
482
499
|
if (m) {
|
|
483
500
|
rmSync(marker, { force: true });
|
|
484
501
|
respawns++;
|
|
@@ -498,6 +515,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
498
515
|
delete compactEnv.TOKENMAXXING_SESSION_ID;
|
|
499
516
|
delete compactEnv.TOKENMAXXING_LAUNCHED_AT;
|
|
500
517
|
delete compactEnv.TOKENMAXXING_MODEL;
|
|
518
|
+
delete compactEnv.TOKENMAXXING_REFUSED;
|
|
501
519
|
const outcome = await compactClaudeSession({ real, sid: m.sessionId, transcript, env: compactEnv, onSpawn: (p) => { child = p; } });
|
|
502
520
|
child = null;
|
|
503
521
|
if (terminating) {
|
|
@@ -511,8 +529,8 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
511
529
|
if (m.waitUntil > Date.now()) {
|
|
512
530
|
if (await countdownWait(label, m.waitUntil, { stream, say })) overriddenUntil = m.waitUntil;
|
|
513
531
|
} else say(`\n\x1b[36m↻ tokenmaxxing: moving to ${label} - resuming...\x1b[0m\n`, `tokenmaxxing: moving to ${label} and resuming.`);
|
|
514
|
-
await releaseClaim();
|
|
515
532
|
wanted = m.accountId;
|
|
533
|
+
refused = m.refused ?? refused;
|
|
516
534
|
saveSessionFlags(m.sessionId, persistable, process.cwd());
|
|
517
535
|
const prompt = resumable ? resumePrompt({ compacted, origin: m.origin }) : null;
|
|
518
536
|
firstLine = relay !== null && prompt !== null ? userLine(prompt) : null;
|
package/src/lib/claude.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { countBy } from "es-toolkit";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { readItem, writeItem, deleteItem, isolatedTarget, readStore, storeTarget, claudeAiOauthOnly } from "./credstore.ts";
|
|
5
6
|
import { CLAUDE_BIN, resolveRealBin, resolveVerifiedClaude } from "./claudebin.ts";
|
|
@@ -10,10 +11,10 @@ import { claudeTierLabel, describeIdentity, fetchTokenIdentity, isDeadCredential
|
|
|
10
11
|
import { claudePool, env, paths, seatFromEnv, storeDirFor } from "./paths.ts";
|
|
11
12
|
import { landWindows, pickBest, pickEarliestReset, thresholdBars, type PickCtx } from "./picker.ts";
|
|
12
13
|
import { deletePiStore } from "./piauth.ts";
|
|
13
|
-
import {
|
|
14
|
+
import { livingPresences } from "./presence.ts";
|
|
14
15
|
import { StoreUnusableError, type Observation, type Provider, type SampleReport } from "./provider.ts";
|
|
15
16
|
import { foldTee, sampleAccountUsage, teeObservation, usageBlockedUntil } from "./sample.ts";
|
|
16
|
-
import { clearUsageSnapshot, loadAccounts, loadConfig, loadUsageSnapshot, pinBinOverride, readJsonFile, saveAccounts, type Harvest } from "./state.ts";
|
|
17
|
+
import { clearUsageSnapshot, liveWaitClaims, loadAccounts, loadConfig, loadUsageSnapshot, pinBinOverride, readJsonFile, saveAccounts, type Harvest } from "./state.ts";
|
|
17
18
|
import { saveTermios, restoreTermios } from "./tty.ts";
|
|
18
19
|
import { fetchUsageDirect, gatedFamilies, mergeWindows, modelFromFlag, scrubCredentialEnv, windowsOf } from "./usage.ts";
|
|
19
20
|
import { CredentialBlobSchema, JsonTextSchema, OAuthAccountSchema, type Account, type Config, type ModelInfo } from "./types.ts";
|
|
@@ -24,7 +25,9 @@ function liveId(): string | null {
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
function presence(): Map<string, number> {
|
|
27
|
-
|
|
28
|
+
const seats = new Map(livingPresences(paths.presenceDir).map((s) => [s.id, s.accountId]));
|
|
29
|
+
for (const claim of liveWaitClaims(Date.now())) seats.set(claim.sessionId, claim.accountId);
|
|
30
|
+
return new Map(Object.entries(countBy([...seats.values()], (id) => id)));
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
const PROBE_BACKOFF_CAP_MS = 30 * 60 * 1000;
|
package/src/lib/decide.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { countBy } from "es-toolkit";
|
|
2
|
+
import { CLAUDE_COMPACT_KILL_MS } from "./compact.ts";
|
|
2
3
|
import { withLock } from "./lock.ts";
|
|
3
4
|
import { loadAccounts, loadConfig, liveWaitClaims, releaseWaitClaim, replaceWaitClaim, saveAccounts } from "./state.ts";
|
|
4
5
|
import { isExhausted, landWindows, limitWindows, liveUsed, nextWeeklyReset, pickBest, pickWaitTarget, sessionWindow, thresholdBars, weeklyWindow, type PickCtx } from "./picker.ts";
|
|
@@ -13,9 +14,11 @@ export type EvalOpts = {
|
|
|
13
14
|
seatId?: string | null;
|
|
14
15
|
sessionFamilies?: string[];
|
|
15
16
|
waiterId?: string;
|
|
17
|
+
exclude?: string[];
|
|
16
18
|
};
|
|
17
19
|
|
|
18
20
|
const MAX_WAITERS_PER_ACCOUNT = 4;
|
|
21
|
+
const MOVE_CLAIM_MS = 2 * CLAUDE_COMPACT_KILL_MS;
|
|
19
22
|
|
|
20
23
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
21
24
|
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
|
|
@@ -80,7 +83,8 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
|
|
|
80
83
|
const origin = enforced ? idx.accounts.find((a) => a.id === enforced.account) : undefined;
|
|
81
84
|
const prior = origin?.enforcedUntil != null && origin.enforcedUntil > now;
|
|
82
85
|
let stamped = false;
|
|
83
|
-
|
|
86
|
+
const credits = enforced?.kind === "credits";
|
|
87
|
+
if (enforced && origin && !credits) {
|
|
84
88
|
const covered = modelKindCovered(enforced, origin, now, bars.weekly);
|
|
85
89
|
if (!covered || prior) {
|
|
86
90
|
origin.enforcedUntil = Math.max(origin.enforcedUntil ?? 0, enforcedWall(enforced, origin, now));
|
|
@@ -90,7 +94,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
|
|
|
90
94
|
saveAccounts(p.pool, idx);
|
|
91
95
|
log("usage.enforced_limit", { kind: enforced.kind, family: enforced.family ?? undefined, resetsAt: origin.enforcedUntil, blind: prior || enforced.blind, live: origin === active, covered: covered && !prior });
|
|
92
96
|
}
|
|
93
|
-
const enforced2 = stamped && origin === active ? enforced : null;
|
|
97
|
+
const enforced2 = (stamped || credits) && origin === active ? enforced : null;
|
|
94
98
|
|
|
95
99
|
if (id2 != null && !active) {
|
|
96
100
|
return { swapped: false, account: null, reason: "live-credential-not-in-pool" };
|
|
@@ -123,7 +127,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
|
|
|
123
127
|
|
|
124
128
|
const seatOf = (cur: { accounts: Account[] }): Account | null => cur.accounts.find((a) => a.id === id2) ?? null;
|
|
125
129
|
|
|
126
|
-
const rejected = new Set<string>();
|
|
130
|
+
const rejected = new Set<string>(opts.exclude);
|
|
127
131
|
const usable = (accounts: Account[]): Account[] => accounts.filter((a) => !rejected.has(a.id) && (p.seats === "shared" || a.id === id2 || !present.has(a.id)));
|
|
128
132
|
const skipOrThrow = (e: unknown, candidate: Account): void => {
|
|
129
133
|
if (p.classifySwapError(e) === "fatal") throw e;
|
|
@@ -143,6 +147,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
|
|
|
143
147
|
continue;
|
|
144
148
|
}
|
|
145
149
|
log("decide.swap", { account: best.id.slice(0, 8), enforced: enforced2 != null });
|
|
150
|
+
if (waiterId != null && p.seats === "shared") replaceWaitClaim({ sessionId: waiterId, accountId: best.id, at: now, waitUntil: now + MOVE_CLAIM_MS });
|
|
146
151
|
return { swapped: true, account: best, reason: "swapped" };
|
|
147
152
|
}
|
|
148
153
|
|
package/src/lib/sessions.ts
CHANGED
|
@@ -104,6 +104,7 @@ export function pruneStaleSessions(now: number): void {
|
|
|
104
104
|
export type SupervisedSession = { sid: string; launchedAt: number | null; live: string };
|
|
105
105
|
|
|
106
106
|
const LaunchedAtSchema = z.coerce.number().finite().optional().catch(undefined);
|
|
107
|
+
const RefusedSchema = z.string().optional().transform((ids) => (ids ?? "").split(",").filter((id) => id !== ""));
|
|
107
108
|
|
|
108
109
|
export function supervisedSession(env: Record<string, string | undefined> = process.env): SupervisedSession | null {
|
|
109
110
|
if (env.TOKENMAXXING_SUPERVISED !== "1") return null;
|
|
@@ -112,6 +113,10 @@ export function supervisedSession(env: Record<string, string | undefined> = proc
|
|
|
112
113
|
return { sid, launchedAt: LaunchedAtSchema.parse(env.TOKENMAXXING_LAUNCHED_AT) ?? null, live: liveSessionId(sid) };
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
export function refusedAccounts(env: Record<string, string | undefined> = process.env): string[] {
|
|
117
|
+
return RefusedSchema.parse(env.TOKENMAXXING_REFUSED);
|
|
118
|
+
}
|
|
119
|
+
|
|
115
120
|
export function adoptLiveSession(session: SupervisedSession, stdinSid: string | undefined): SupervisedSession | null {
|
|
116
121
|
if (stdinSid == null || stdinSid === session.live) return session;
|
|
117
122
|
const child = presencePid({ dir: paths.presenceDir, id: session.sid });
|
|
@@ -124,7 +129,7 @@ export function adoptLiveSession(session: SupervisedSession, stdinSid: string |
|
|
|
124
129
|
return { ...session, live: stdinSid };
|
|
125
130
|
}
|
|
126
131
|
|
|
127
|
-
export function writeRespawnMarker(input: { session: SupervisedSession; accountId: string; waitUntil: number; compact: boolean; origin: z.infer<typeof RespawnMarkerSchema>["origin"] }): void {
|
|
132
|
+
export function writeRespawnMarker(input: { session: SupervisedSession; accountId: string; waitUntil: number; compact: boolean; origin: z.infer<typeof RespawnMarkerSchema>["origin"]; refused?: string[] }): void {
|
|
128
133
|
const payload: z.infer<typeof RespawnMarkerSchema> = {
|
|
129
134
|
accountId: input.accountId,
|
|
130
135
|
ts: Date.now(),
|
|
@@ -133,6 +138,7 @@ export function writeRespawnMarker(input: { session: SupervisedSession; accountI
|
|
|
133
138
|
compact: input.compact,
|
|
134
139
|
origin: input.origin,
|
|
135
140
|
...(input.session.launchedAt != null ? { launchedAt: input.session.launchedAt } : {}),
|
|
141
|
+
...(input.refused != null ? { refused: input.refused } : {}),
|
|
136
142
|
};
|
|
137
143
|
writeFileAtomic(join(paths.respawnDir, input.session.sid), JSON.stringify(payload));
|
|
138
144
|
}
|
package/src/lib/types.ts
CHANGED
|
@@ -84,7 +84,7 @@ export type AccountsIndex = z.infer<typeof AccountsIndexSchema>;
|
|
|
84
84
|
|
|
85
85
|
export type EnforcedLimit = {
|
|
86
86
|
account: string;
|
|
87
|
-
kind: "session" | "weekly" | "model";
|
|
87
|
+
kind: "session" | "weekly" | "model" | "credits";
|
|
88
88
|
family: string | null;
|
|
89
89
|
resetsAt: number | null;
|
|
90
90
|
blind: boolean;
|
|
@@ -150,6 +150,7 @@ export const RespawnMarkerSchema = z.object({
|
|
|
150
150
|
compact: z.boolean(),
|
|
151
151
|
origin: z.enum(["stop", "sessionstart", "stopfailure", "seatwatch"]),
|
|
152
152
|
launchedAt: z.number().optional(),
|
|
153
|
+
refused: z.array(z.string()).optional(),
|
|
153
154
|
});
|
|
154
155
|
|
|
155
156
|
export const EpochSecondsSchema = z.number().transform((seconds) => seconds * 1000);
|
package/src/lib/usage.ts
CHANGED
|
@@ -56,6 +56,7 @@ export const TranscriptRowSchema = z.looseObject({
|
|
|
56
56
|
timestamp: z.string().optional(),
|
|
57
57
|
isApiErrorMessage: z.boolean().optional(),
|
|
58
58
|
apiErrorIsTransient: z.boolean().optional(),
|
|
59
|
+
apiError: z.string().optional(),
|
|
59
60
|
error: z.string().optional(),
|
|
60
61
|
errorDetails: z.string().optional(),
|
|
61
62
|
quotaLimits: z.looseObject({ rateLimitType: z.string().optional(), resetsAt: EpochSecondsSchema.optional() }).optional(),
|
|
@@ -114,13 +115,14 @@ export function findEnforcedRow(input: { rows: TranscriptRow[]; lastAssistantMes
|
|
|
114
115
|
export type EnforcedClass =
|
|
115
116
|
| { kind: "session"; resetsAt: number | null }
|
|
116
117
|
| { kind: "weekly"; resetsAt: number | null }
|
|
117
|
-
| { kind: "model"; family: string; resetsAt: number | null }
|
|
118
|
+
| { kind: "model"; family: string; resetsAt: number | null }
|
|
119
|
+
| { kind: "credits"; family: string; resetsAt: null };
|
|
118
120
|
|
|
119
121
|
const ErrorBodySchema = z.looseObject({
|
|
120
122
|
error: z.looseObject({ type: z.string().optional(), details: z.looseObject({ error_code: z.string().optional() }).optional() }).optional(),
|
|
121
123
|
});
|
|
122
124
|
|
|
123
|
-
const
|
|
125
|
+
const CREDITS_FAMILY = "fable";
|
|
124
126
|
|
|
125
127
|
export function parseErrorBody(errorDetails: string | undefined): z.infer<typeof ErrorBodySchema> | null {
|
|
126
128
|
if (!errorDetails) return null;
|
|
@@ -131,19 +133,15 @@ export function parseErrorBody(errorDetails: string | undefined): z.infer<typeof
|
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
export function classifyEnforcedLimit(row: TranscriptRow, switchModels: string[]): EnforcedClass | null {
|
|
136
|
+
if (row.apiError === "model_requires_usage_credits") return { kind: "credits", family: CREDITS_FAMILY, resetsAt: null };
|
|
134
137
|
const q = row.quotaLimits;
|
|
135
|
-
if (q)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
if (row.apiErrorIsTransient === true) return null;
|
|
144
|
-
if (parseErrorBody(row.errorDetails)?.error?.type !== "rate_limit_error") return null;
|
|
145
|
-
const family = switchModels.find((f) => CREDITS_GATED_FAMILIES.includes(f));
|
|
146
|
-
return family ? { kind: "model", family, resetsAt: null } : null;
|
|
138
|
+
if (!q) return null;
|
|
139
|
+
const resetsAt = q.resetsAt ?? null;
|
|
140
|
+
const type = q.rateLimitType ?? "";
|
|
141
|
+
if (type === "five_hour") return { kind: "session", resetsAt };
|
|
142
|
+
if (type === "seven_day") return { kind: "weekly", resetsAt };
|
|
143
|
+
const family = switchModels.find((f) => type.includes(f));
|
|
144
|
+
return family ? { kind: "model", family, resetsAt } : null;
|
|
147
145
|
}
|
|
148
146
|
|
|
149
147
|
const FIVE_HOURS_S = 5 * 3600;
|