tokenmaxxing 1.58.0 → 1.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +1 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/cli/status.ts +8 -3
- package/src/entries/codexstophook.ts +1 -1
- package/src/entries/supervisor.ts +20 -10
- package/src/lib/claude.ts +9 -9
- package/src/lib/decide.ts +6 -5
- package/src/lib/provider.ts +1 -1
- package/src/lib/usage.ts +8 -0
package/DESIGN.md
CHANGED
|
@@ -90,7 +90,7 @@ The [switching policy](docs/content/docs/switching.mdx) is one rule: under both
|
|
|
90
90
|
|
|
91
91
|
The [profile](docs/content/docs/switching-profile.mdx) records observed cache rewrites across swaps and does not infer subscription quota savings.
|
|
92
92
|
|
|
93
|
-
**Model-aware trigger.** Claude subscriptions also enforce **per-model weekly caps** - currently only for Sonnet and Fable (there is no Opus-only quota), and Fable's tighter limit binds *before* the aggregate (e.g. 80% week-Fable at only 50% week-all-models). This cap isn't in statusLine stdin, so when the
|
|
93
|
+
**Model-aware trigger.** Claude subscriptions also enforce **per-model weekly caps** - currently only for Sonnet and Fable (there is no Opus-only quota), and Fable's tighter limit binds *before* the aggregate (e.g. 80% week-Fable at only 50% week-all-models). This cap isn't in statusLine stdin, so when the session's model is in `policy.switchModels`, or the seat's tee is older than the TTL, we read it from the `limits[]` rows of the direct no-spend usage read for the seat (free, 0 tokens, one attempt with backoff, with the `/usage` probe under the seat's store refreshing the token when the read needs it) and add `week(<activeModel>) >= threshold` to the trigger. A Fable session moves on the Fable cap; a Sonnet session rides the aggregate. Candidate screening applies the same per-model gate: a burnt Fable cap screens an account out of a move. A session's model is the `--model` value it was launched with: the supervisor reads it from the launch flags, uses it for launch placement and the seat watch, and hands it to the session's hooks in `TOKENMAXXING_MODEL`, which the hooks read ahead of the per-account tee. With the default `policy.switchModels`, a session launched as Opus, Sonnet, or Haiku gates no per-model cap, so it runs on an account whose Fable cap is spent while the session and weekly windows have headroom. A launch without `--model`, or with a value that names no model family (`best`, `opusplan`), is unknown: launch placement and the seat watch gate every configured family for it, and its hooks fall back to the model in the account's tee. A move off a walled seat, or one that follows a subagent's five-hour or weekly refusal, screens candidates on every configured family whatever the launch model.
|
|
94
94
|
|
|
95
95
|
---
|
|
96
96
|
|
package/README.md
CHANGED
|
@@ -84,7 +84,7 @@ claude # use claude as always; each session gets its own accoun
|
|
|
84
84
|
Each session runs on its own seat. One rule: while the seat is under its bars, nothing happens; once it is at or over a bar, the session moves to the usable account that ranks first. The session bar is `thresholds.session` (90) minus `policy.projectionMargin` (3), so **87%** of the 5-hour window by default, and the weekly bar is **98%**. The bars also screen candidates on any of:
|
|
85
85
|
|
|
86
86
|
- **Session** (5-hour) or **week (all models)** - the aggregate windows, fed free/push-based by the statusLine into one tee per account.
|
|
87
|
-
- **Per-model weekly cap** - the most capable model (Fable) has its own tighter weekly limit that binds *before* the aggregate (per-model caps currently exist only for Sonnet and Fable, and Sonnet's is generous). tokenmaxxing reads it from the direct no-spend usage read for the seat (free, 0 tokens, one attempt per interval) whenever the seat's tee is older than `policy.usagePollTtlMs` or the
|
|
87
|
+
- **Per-model weekly cap** - the most capable model (Fable) has its own tighter weekly limit that binds *before* the aggregate (per-model caps currently exist only for Sonnet and Fable, and Sonnet's is generous). tokenmaxxing reads it from the direct no-spend usage read for the seat (free, 0 tokens, one attempt per interval) whenever the seat's tee is older than `policy.usagePollTtlMs` or the session's model is one of `policy.switchModels`, backing off after a failed sample, so a Fable session moves on the Fable cap while a Sonnet session rides the aggregate. A session launched with `--model` on another family (`claude --model opus`) ignores the Fable cap from launch on: it is placed on, and moves to, an account whose Fable cap is spent as long as that account's session and weekly windows have headroom. The exception is a move off an account the server already refused (an enforced-limit wall), which screens every configured family.
|
|
88
88
|
|
|
89
89
|
The bars' headroom is deliberate: it's the budget to reach a clean turn boundary before the account's real limit. The session bar sits lower (90, minus the margin) because a 5-hour reset is cheap to sit out; weekly quota is use-it-or-lose-it, so it drains closer to the limit (98).
|
|
90
90
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.59.0",
|
|
4
4
|
"description": "Automatic Claude Code account switching: pool multiple accounts, start each session on its own account, and resume a session on a fresher account when quota fills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cli/status.ts
CHANGED
|
@@ -88,9 +88,14 @@ async function collect(p: Provider, cfg: Config, now: number, cached: boolean):
|
|
|
88
88
|
if (!cached && idx.accounts.length > 0) {
|
|
89
89
|
await withLock(p.pool.lockFile, async () => {
|
|
90
90
|
idx = loadAccounts(p.pool);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
91
|
+
const notice = process.stderr.isTTY;
|
|
92
|
+
if (notice) process.stderr.write(c.dim(`sampling ${p.name} usage...`));
|
|
93
|
+
try {
|
|
94
|
+
liveId = p.liveId();
|
|
95
|
+
reports = await p.samplePool(idx.accounts, liveId, now);
|
|
96
|
+
} finally {
|
|
97
|
+
if (notice) process.stderr.write("\r\x1b[2K");
|
|
98
|
+
}
|
|
94
99
|
saveAccounts(p.pool, idx);
|
|
95
100
|
});
|
|
96
101
|
}
|
|
@@ -21,7 +21,7 @@ async function compactBeforeMove(input: { sessionId: string | null; now: number
|
|
|
21
21
|
const liveId = codex.liveId();
|
|
22
22
|
const live = liveId == null ? undefined : loadAccounts(codex.pool).accounts.find((a) => a.id === liveId);
|
|
23
23
|
if (!live || live.needsReauth === true || (live.enforcedUntil != null && live.enforcedUntil > now)) return;
|
|
24
|
-
const observed = await codex.observeLive(live, loadConfig(), now, { probe: true });
|
|
24
|
+
const observed = await codex.observeLive(live, loadConfig(), now, { probe: true, perModel: false });
|
|
25
25
|
if (!observed || !isExhausted({ ...live, windows: observed.windows }, codexPickCtx(now, live.id))) return;
|
|
26
26
|
const env: Record<string, string | undefined> = { ...process.env, TOKENMAXXING_PROBE: "1", [WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH) };
|
|
27
27
|
delete env[CODEX_SUPERVISOR_ID_ENV];
|
|
@@ -17,7 +17,8 @@ import { exitStatus, loopGuardTripped, raceMarkerOrExit, recordPresenceOrStop, r
|
|
|
17
17
|
import { saveTermios } from "../lib/tty.ts";
|
|
18
18
|
import { loadSessionFlags, pruneStaleSessions, saveSessionFlags, writeRespawnMarker } from "../lib/sessions.ts";
|
|
19
19
|
import { loadAccounts, loadConfig } from "../lib/state.ts";
|
|
20
|
-
import {
|
|
20
|
+
import { gatedFamilies, modelFromFlag } from "../lib/usage.ts";
|
|
21
|
+
import { RespawnMarkerSchema, type Account, type Config, type ModelInfo } from "../lib/types.ts";
|
|
21
22
|
import { errorMessage, log } from "../lib/log.ts";
|
|
22
23
|
|
|
23
24
|
const NONINTERACTIVE_SUBCMDS = new Set([
|
|
@@ -53,6 +54,7 @@ type Analysis = {
|
|
|
53
54
|
continueLatest: boolean;
|
|
54
55
|
streamInput: boolean;
|
|
55
56
|
streamOutput: boolean;
|
|
57
|
+
model: string | null;
|
|
56
58
|
};
|
|
57
59
|
|
|
58
60
|
export function analyzeArgs(argv: string[]): Analysis {
|
|
@@ -61,6 +63,7 @@ export function analyzeArgs(argv: string[]): Analysis {
|
|
|
61
63
|
let continueLatest = false;
|
|
62
64
|
let streamInput = false;
|
|
63
65
|
let streamOutput = false;
|
|
66
|
+
let model: string | null = null;
|
|
64
67
|
let printMode = false;
|
|
65
68
|
let invalidSessionArg = false;
|
|
66
69
|
let pickerResume = false;
|
|
@@ -98,6 +101,8 @@ export function analyzeArgs(argv: string[]): Analysis {
|
|
|
98
101
|
else if (a.startsWith("--input-format=")) streamInput = a.slice("--input-format=".length) === "stream-json";
|
|
99
102
|
else if (a === "--output-format") streamOutput = argv[++i] === "stream-json";
|
|
100
103
|
else if (a.startsWith("--output-format=")) streamOutput = a.slice("--output-format=".length) === "stream-json";
|
|
104
|
+
else if (a === "--model") model = argv[++i] ?? null;
|
|
105
|
+
else if (a.startsWith("--model=")) model = a.slice("--model=".length);
|
|
101
106
|
else if (VALUE_TAKING_ROOT_FLAGS.has(a)) i++;
|
|
102
107
|
else if (VARIADIC_ROOT_FLAGS.has(a)) {
|
|
103
108
|
while (i + 1 < argv.length && !argv[i + 1]!.startsWith("-")) i++;
|
|
@@ -113,7 +118,7 @@ export function analyzeArgs(argv: string[]): Analysis {
|
|
|
113
118
|
const isSubcmd = firstPositional !== null && NONINTERACTIVE_SUBCMDS.has(firstPositional);
|
|
114
119
|
const forkResume = forkSession && (resumeId !== null || continueLatest);
|
|
115
120
|
const manage = !printMode && !isSubcmd && !invalidSessionArg && !pickerResume && !forkResume && !process.env.TOKENMAXXING_PROBE;
|
|
116
|
-
return { manage, sessionId, resumeId, continueLatest, streamInput, streamOutput };
|
|
121
|
+
return { manage, sessionId, resumeId, continueLatest, streamInput, streamOutput, model };
|
|
117
122
|
}
|
|
118
123
|
|
|
119
124
|
export function stripSessionFlags(argv: string[]): string[] {
|
|
@@ -201,22 +206,23 @@ function consumableMarker(marker: string, gate: MarkerGate): z.infer<typeof Resp
|
|
|
201
206
|
const SEAT_POLL_MS = 10_000;
|
|
202
207
|
const SEAT_RETRY_MS = 60_000;
|
|
203
208
|
|
|
204
|
-
function seatBlockedUntil(seatId: string, now: number): number | null {
|
|
209
|
+
function seatBlockedUntil(seatId: string, now: number, families: string[], cfg: Config): number | null {
|
|
205
210
|
const account = loadAccounts(claudePool).accounts.find((a) => a.id === seatId);
|
|
206
211
|
if (!account) return null;
|
|
207
|
-
const cfg = loadConfig();
|
|
208
212
|
const observed = teeObservation(account);
|
|
209
213
|
const current = observed ? { ...account, windows: observed.windows } : account;
|
|
210
|
-
const until = usableAt(current, { now, thresholds: thresholdBars(cfg), currentId: seatId, families
|
|
214
|
+
const until = usableAt(current, { now, thresholds: thresholdBars(cfg), currentId: seatId, families, seats: null });
|
|
211
215
|
return until > now ? until : null;
|
|
212
216
|
}
|
|
213
217
|
|
|
214
|
-
async function moveExhaustedSeat(seat: Account, sid: string, gate: MarkerGate): Promise<boolean> {
|
|
218
|
+
async function moveExhaustedSeat(seat: Account, sid: string, gate: MarkerGate, model: ModelInfo | null): Promise<boolean> {
|
|
215
219
|
try {
|
|
216
220
|
const now = Date.now();
|
|
217
|
-
const
|
|
221
|
+
const cfg = loadConfig();
|
|
222
|
+
const families = gatedFamilies(model, cfg.policy.switchModels);
|
|
223
|
+
const until = seatBlockedUntil(seat.id, now, families, cfg);
|
|
218
224
|
if (until == null || until <= gate.overriddenUntil) return false;
|
|
219
|
-
const decision = await evaluateAndMaybeSwap(claude, now, true, null, seat.id);
|
|
225
|
+
const decision = await evaluateAndMaybeSwap(claude, now, true, null, seat.id, families);
|
|
220
226
|
log("supervisor.seat_exhausted", { seat: seat.id.slice(0, 8), until, reason: decision.reason, account: decision.account?.id.slice(0, 8), waitUntil: decision.waitUntil });
|
|
221
227
|
if (decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
|
|
222
228
|
writeRespawnMarker({ session: { sid, launchedAt: gate.launchedAt }, sessionId: sid, accountId: decision.account.id, waitUntil: decision.waitUntil ?? now, compact: true });
|
|
@@ -342,6 +348,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
342
348
|
const passthroughEnv: Record<string, string | undefined> = { ...childEnv };
|
|
343
349
|
delete passthroughEnv.TOKENMAXXING_SUPERVISED;
|
|
344
350
|
delete passthroughEnv.TOKENMAXXING_SESSION_ID;
|
|
351
|
+
delete passthroughEnv.TOKENMAXXING_MODEL;
|
|
345
352
|
return runPassthrough({ real, argv, env: passthroughEnv, onSpawn: (p) => { child = p; } });
|
|
346
353
|
}
|
|
347
354
|
|
|
@@ -380,6 +387,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
380
387
|
const effective = analyzeArgs(base);
|
|
381
388
|
const relay = effective.streamInput ? new StdinRelay() : null;
|
|
382
389
|
const stream = effective.streamOutput;
|
|
390
|
+
const model = modelFromFlag(effective.model);
|
|
383
391
|
let noticeSid = sid;
|
|
384
392
|
const say: Say = (terminal, text) => {
|
|
385
393
|
if (stream) process.stdout.write(systemLine(noticeSid, text));
|
|
@@ -394,7 +402,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
394
402
|
|
|
395
403
|
const gate: MarkerGate = { launchedAt: Date.now(), overriddenUntil };
|
|
396
404
|
const { child: proc, seat } = await withLock(claudePool.lockFile, async () => {
|
|
397
|
-
const picked = (wanted == null ? null : (loadAccounts(claudePool).accounts.find((a) => a.id === wanted) ?? null)) ?? pickSeat(gate.launchedAt);
|
|
405
|
+
const picked = (wanted == null ? null : (loadAccounts(claudePool).accounts.find((a) => a.id === wanted) ?? null)) ?? pickSeat(gate.launchedAt, model);
|
|
398
406
|
log("supervisor.launch", { sid, respawns, seat: picked?.id.slice(0, 8) ?? null, args: launchArgs.join(" "), injected: firstLine !== null });
|
|
399
407
|
const spawned = Bun.spawn([real, ...launchArgs], {
|
|
400
408
|
stdin: relay === null ? "inherit" : "pipe",
|
|
@@ -405,6 +413,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
405
413
|
TOKENMAXXING_SUPERVISED: "1",
|
|
406
414
|
TOKENMAXXING_SESSION_ID: sid,
|
|
407
415
|
TOKENMAXXING_LAUNCHED_AT: String(gate.launchedAt),
|
|
416
|
+
TOKENMAXXING_MODEL: effective.model ?? "",
|
|
408
417
|
...(picked ? { CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(picked.id) } : {}),
|
|
409
418
|
},
|
|
410
419
|
});
|
|
@@ -431,7 +440,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
431
440
|
tick: async () => {
|
|
432
441
|
if (existsSync(marker) && consumableMarker(marker, gate) != null) return true;
|
|
433
442
|
if (seat && !terminating && Date.now() >= seatCheckAt) {
|
|
434
|
-
const decided = await moveExhaustedSeat(seat, sid, gate);
|
|
443
|
+
const decided = await moveExhaustedSeat(seat, sid, gate, model);
|
|
435
444
|
seatCheckAt = Date.now() + (decided ? SEAT_RETRY_MS : SEAT_POLL_MS);
|
|
436
445
|
}
|
|
437
446
|
return false;
|
|
@@ -459,6 +468,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
459
468
|
delete compactEnv.TOKENMAXXING_SUPERVISED;
|
|
460
469
|
delete compactEnv.TOKENMAXXING_SESSION_ID;
|
|
461
470
|
delete compactEnv.TOKENMAXXING_LAUNCHED_AT;
|
|
471
|
+
delete compactEnv.TOKENMAXXING_MODEL;
|
|
462
472
|
const outcome = await compactClaudeSession({ real, sid: m.sessionId, env: compactEnv });
|
|
463
473
|
log("supervisor.compact", { sid: m.sessionId.slice(0, 8), seat: seat.id.slice(0, 8), ok: outcome.ok, reason: outcome.ok ? undefined : outcome.reason });
|
|
464
474
|
if (!outcome.ok) say(`\x1b[33m compaction did not land (${outcome.reason}) - resuming with the full context\x1b[0m\n`, `tokenmaxxing: compaction did not land; resuming with the full context. (${outcome.reason})`);
|
package/src/lib/claude.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { ensurePathInRc, installSupervisor, managedShellRcSkipLines, shellRcPath
|
|
|
8
8
|
import { withLock } from "./lock.ts";
|
|
9
9
|
import { errorMessage, log } from "./log.ts";
|
|
10
10
|
import { claudeTierLabel, describeIdentity, fetchTokenIdentity, isDeadCredential, InvalidGrantError } from "./oauth.ts";
|
|
11
|
-
import { claudePool, paths, sampleDirFor, seatFromEnv, storeDirFor } from "./paths.ts";
|
|
11
|
+
import { claudePool, env, paths, sampleDirFor, seatFromEnv, storeDirFor } from "./paths.ts";
|
|
12
12
|
import { pickBest, pickEarliestReset, thresholdBars, type PickCtx } from "./picker.ts";
|
|
13
13
|
import { seatCounts } from "./presence.ts";
|
|
14
14
|
import type { Observation, Provider, SampleReport } from "./provider.ts";
|
|
@@ -16,8 +16,8 @@ import { foldTee, sampleAccountUsage, teeObservation } from "./sample.ts";
|
|
|
16
16
|
import { clearUsageSnapshot, loadAccounts, loadConfig, loadUsageSnapshot, pinBinOverride, saveAccounts, type Harvest } from "./state.ts";
|
|
17
17
|
import { loadSetupTokens, saveSetupTokens } from "./setuptokens.ts";
|
|
18
18
|
import { saveTermios, restoreTermios } from "./tty.ts";
|
|
19
|
-
import { fetchUsageDirect, gatedFamilies, mergeWindows, scrubCredentialEnv, windowsOf } from "./usage.ts";
|
|
20
|
-
import { CredentialBlobSchema, JsonTextSchema, OAuthAccountSchema, type Account, type Config } from "./types.ts";
|
|
19
|
+
import { fetchUsageDirect, gatedFamilies, mergeWindows, modelFromFlag, scrubCredentialEnv, windowsOf } from "./usage.ts";
|
|
20
|
+
import { CredentialBlobSchema, JsonTextSchema, OAuthAccountSchema, type Account, type Config, type ModelInfo } from "./types.ts";
|
|
21
21
|
import { c } from "../cli/render.ts";
|
|
22
22
|
|
|
23
23
|
class StoreUnusableError extends Error {
|
|
@@ -37,14 +37,13 @@ function presence(): Map<string, number> {
|
|
|
37
37
|
|
|
38
38
|
const PROBE_BACKOFF_CAP_MS = 30 * 60 * 1000;
|
|
39
39
|
|
|
40
|
-
async function observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean }): Promise<Observation | null> {
|
|
40
|
+
async function observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean; perModel: boolean }): Promise<Observation | null> {
|
|
41
41
|
const ttl = cfg.policy.usagePollTtlMs;
|
|
42
42
|
const interval = Math.min(ttl * 2 ** (account.probeFails ?? 0), PROBE_BACKOFF_CAP_MS);
|
|
43
43
|
const probeAttempted = account.lastProbeAt != null && now - account.lastProbeAt <= interval;
|
|
44
44
|
const snap = loadUsageSnapshot(account.id);
|
|
45
45
|
const fresh = snap != null && now - snap.at <= ttl;
|
|
46
|
-
|
|
47
|
-
if (opts.probe && !probeAttempted && (!fresh || needsPerModel)) {
|
|
46
|
+
if (opts.probe && !probeAttempted && (!fresh || opts.perModel)) {
|
|
48
47
|
const startedAt = Date.now();
|
|
49
48
|
const outcome = await sampleAccountUsage(account);
|
|
50
49
|
await withLock(claudePool.lockFile, () => {
|
|
@@ -125,13 +124,13 @@ async function storeUsable(a: Account): Promise<boolean> {
|
|
|
125
124
|
}
|
|
126
125
|
}
|
|
127
126
|
|
|
128
|
-
export function pickSeat(now: number): Account | null {
|
|
127
|
+
export function pickSeat(now: number, model: ModelInfo | null): Account | null {
|
|
129
128
|
const cfg = loadConfig();
|
|
130
129
|
const idx = loadAccounts(claudePool);
|
|
131
130
|
let dirty = false;
|
|
132
131
|
for (const a of idx.accounts) dirty = foldTee(a) || dirty;
|
|
133
132
|
if (dirty) saveAccounts(claudePool, idx);
|
|
134
|
-
const ctx: PickCtx = { now, thresholds: thresholdBars(cfg), currentId: null, families: cfg.policy.switchModels, seats: presence() };
|
|
133
|
+
const ctx: PickCtx = { now, thresholds: thresholdBars(cfg), currentId: null, families: gatedFamilies(model, cfg.policy.switchModels), seats: presence() };
|
|
135
134
|
return pickBest(idx.accounts, ctx) ?? pickEarliestReset(idx.accounts, ctx)?.account ?? null;
|
|
136
135
|
}
|
|
137
136
|
|
|
@@ -286,7 +285,8 @@ export const claude: Provider = {
|
|
|
286
285
|
presence,
|
|
287
286
|
gatedFamilies: (cfg) => {
|
|
288
287
|
const seat = liveId();
|
|
289
|
-
|
|
288
|
+
const model = modelFromFlag(env("TOKENMAXXING_MODEL", "")) ?? (seat == null ? null : (loadUsageSnapshot(seat)?.state.model ?? null));
|
|
289
|
+
return gatedFamilies(model, cfg.policy.switchModels);
|
|
290
290
|
},
|
|
291
291
|
observeLive,
|
|
292
292
|
samplePool,
|
package/src/lib/decide.ts
CHANGED
|
@@ -36,16 +36,17 @@ function enforcedWall(limit: EnforcedLimit, account: Account, now: number): numb
|
|
|
36
36
|
return limit.resetsAt ?? cachedReset ?? now + (limit.kind === "session" ? FIVE_HOURS_MS : WEEK_MS);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRespawn = false, enforced: EnforcedLimit | null = null, seatId: string | null = p.liveId()): Promise<SwapDecision> {
|
|
39
|
+
export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRespawn = false, enforced: EnforcedLimit | null = null, seatId: string | null = p.liveId(), sessionFamilies?: string[]): Promise<SwapDecision> {
|
|
40
40
|
const activeId = seatId;
|
|
41
41
|
const cfg = loadConfig();
|
|
42
42
|
const bars = thresholdBars(cfg);
|
|
43
43
|
const stored0 = loadAccounts(p.pool).accounts.find((a) => a.id === activeId);
|
|
44
44
|
const walled0 = stored0?.enforcedUntil != null && stored0.enforcedUntil > now;
|
|
45
|
-
const
|
|
45
|
+
const gated = sessionFamilies ?? p.gatedFamilies(cfg);
|
|
46
|
+
const observed = stored0 ? await p.observeLive(stored0, cfg, now, { probe: enforced == null && !walled0, perModel: gated == null || gated.length > 0 }) : null;
|
|
46
47
|
const stored = loadAccounts(p.pool).accounts.find((a) => a.id === activeId);
|
|
47
48
|
|
|
48
|
-
if (!enforced && !isOver(stored, observed, { now, thresholds: bars, currentId: activeId, families:
|
|
49
|
+
if (!enforced && !isOver(stored, observed, { now, thresholds: bars, currentId: activeId, families: gated, seats: null })) {
|
|
49
50
|
return { swapped: false, account: null, reason: "under-threshold-or-stale" };
|
|
50
51
|
}
|
|
51
52
|
|
|
@@ -70,7 +71,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
|
|
|
70
71
|
|
|
71
72
|
let obs2: Observation | null = null;
|
|
72
73
|
for (const a of idx.accounts) {
|
|
73
|
-
const obs = await p.observeLive(a, cfg, now, { probe: false });
|
|
74
|
+
const obs = await p.observeLive(a, cfg, now, { probe: false, perModel: false });
|
|
74
75
|
if (a === active) obs2 = obs;
|
|
75
76
|
if (obs && (a.lastUsageAt == null || obs.at > a.lastUsageAt)) {
|
|
76
77
|
a.windows = p.mergeWindows(obs.windows, a.windows);
|
|
@@ -79,7 +80,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
|
|
|
79
80
|
}
|
|
80
81
|
saveAccounts(p.pool, idx);
|
|
81
82
|
|
|
82
|
-
const families = p.gatedFamilies(cfg);
|
|
83
|
+
const families = sessionFamilies ?? p.gatedFamilies(cfg);
|
|
83
84
|
const walled = active?.enforcedUntil != null && active.enforcedUntil > now;
|
|
84
85
|
const blindScreen = (enforced != null && (prior || enforced.blind)) || (walled && !enforced2);
|
|
85
86
|
const screened = blindScreen && families != null ? cfg.policy.switchModels : families;
|
package/src/lib/provider.ts
CHANGED
|
@@ -18,7 +18,7 @@ export type Provider = {
|
|
|
18
18
|
liveId(): string | null;
|
|
19
19
|
presence(): Map<string, number>;
|
|
20
20
|
gatedFamilies(cfg: Config): string[] | null;
|
|
21
|
-
observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean }): Promise<Observation | null>;
|
|
21
|
+
observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean; perModel: boolean }): Promise<Observation | null>;
|
|
22
22
|
samplePool(accounts: Account[], liveId: string | null, now: number): Promise<Map<string, SampleReport>>;
|
|
23
23
|
mergeWindows(next: Window[], prev: Window[]): Window[];
|
|
24
24
|
swap(target: Account): Promise<void>;
|
package/src/lib/usage.ts
CHANGED
|
@@ -44,6 +44,14 @@ export function gatedFamilies(model: ModelInfo | null, families: string[]): stri
|
|
|
44
44
|
return family ? [family] : [];
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
const MODEL_FAMILIES = ["sonnet", "opus", "haiku", "fable"];
|
|
48
|
+
|
|
49
|
+
export function modelFromFlag(value: string | null): ModelInfo | null {
|
|
50
|
+
const name = value?.split("[")[0]?.trim() ?? "";
|
|
51
|
+
const tokens = familyTokens(name);
|
|
52
|
+
return MODEL_FAMILIES.some((f) => tokens.includes(f)) ? { id: name, display: name } : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
47
55
|
const TranscriptBlockSchema = z.looseObject({ type: z.string().optional(), text: z.string().optional() });
|
|
48
56
|
export const TranscriptRowSchema = z.looseObject({
|
|
49
57
|
type: z.string().optional(),
|