tokenmaxxing 1.24.0 → 1.25.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 +10 -16
- package/README.md +8 -10
- package/package.json +1 -1
- package/src/cli/codexswitch.ts +2 -2
- package/src/cli/config.ts +0 -4
- package/src/cli/status.ts +5 -5
- package/src/cli/switch.ts +3 -9
- package/src/entries/codexstophook.ts +2 -2
- package/src/lib/codexdecide.ts +5 -30
- package/src/lib/codexpick.ts +0 -6
- package/src/lib/decide.ts +9 -70
- package/src/lib/picker.ts +2 -46
- package/src/lib/state.ts +3 -15
- package/src/lib/swap.ts +0 -32
- package/src/lib/types.ts +3 -22
package/DESIGN.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# tokenmaxxing - design
|
|
2
2
|
|
|
3
|
-
Automatic Claude Code account switching. You run `claude` exactly as always; when the active account crosses its swap threshold (
|
|
3
|
+
Automatic Claude Code account switching. You run `claude` exactly as always; when the active account crosses its swap threshold (**90%** of the 5h session window or **98%** of a weekly window), tokenmaxxing swaps the credential to a fresh account at a safe turn boundary and **your running session adopts it in place - no restart**. Works across many concurrent sessions at once; a fully depleted pool pauses with a countdown and auto-resumes at the soonest reset when it lands within `policy.maxWaitMs` (default 1h; further out, the session stays put rather than parking for hours).
|
|
4
4
|
|
|
5
5
|
> Scope: **Claude Code first (macOS + Linux, the latter since 2026-07-09).** Codex support landed in 0.13.0 (2026-07-16) with its own parallel state, decision engine, and supervisor; its verified internals live in AGENTS.md's Codex sections.
|
|
6
6
|
>
|
|
@@ -10,9 +10,9 @@ Automatic Claude Code account switching. You run `claude` exactly as always; whe
|
|
|
10
10
|
|
|
11
11
|
## 1. Why there is a thin supervisor (and why that's the whole trick)
|
|
12
12
|
|
|
13
|
-
A **running** `claude` DOES adopt an externally swapped credential (verified live 2026-07-10, correcting this document's original claim): an ensure-fresh poll re-reads the credential store around every request, so a swap lands within ~30s on macOS (raw keychain cache) and on the next request on Linux. A plain swap therefore needs no process management at all (since 0.15.0, 2026-07-16; earlier versions respawned on every swap): the Stop hook swaps the credential and the session keeps running. What adoption cannot give you is the depleted case: when every account is
|
|
13
|
+
A **running** `claude` DOES adopt an externally swapped credential (verified live 2026-07-10, correcting this document's original claim): an ensure-fresh poll re-reads the credential store around every request, so a swap lands within ~30s on macOS (raw keychain cache) and on the next request on Linux. A plain swap therefore needs no process management at all (since 0.15.0, 2026-07-16; earlier versions respawned on every swap): the Stop hook swaps the credential and the session keeps running. What adoption cannot give you is the depleted case: when every account is over its bar the session must be PAUSED until something resets, and a live `claude` cannot pause itself.
|
|
14
14
|
|
|
15
|
-
So the supervisor's job is narrow: on a depleted pool it **replaces the process at a salvageable moment** - after a turn completes, the conversation is fully written to the transcript JSONL and `claude` is idle at the prompt, so killing it there loses nothing - shows an interruptible countdown to the soonest reset, and relaunches `claude --resume <session-id>` when it passes. **Thresholds still sit below 100%: the headroom is the budget to reach a clean turn boundary (plus up to one turn of adoption lag on macOS) before the account actually hits
|
|
15
|
+
So the supervisor's job is narrow: on a depleted pool it **replaces the process at a salvageable moment** - after a turn completes, the conversation is fully written to the transcript JSONL and `claude` is idle at the prompt, so killing it there loses nothing - shows an interruptible countdown to the soonest reset, and relaunches `claude --resume <session-id>` when it passes. **Thresholds still sit below 100%: the headroom is the budget to reach a clean turn boundary (plus up to one turn of adoption lag on macOS) before the account actually hits its real limit.** The session window swaps at 90 (a 5h reset is cheap to sit out), while the weekly windows drain to 98 (weekly allowance is use-it-or-lose-it).
|
|
16
16
|
|
|
17
17
|
A hook can't do the pause-and-relaunch - when `claude` exits, the shell owns the terminal. So tokenmaxxing installs a **supervisor** (aliased to `claude`) that owns the process lifecycle:
|
|
18
18
|
|
|
@@ -49,7 +49,7 @@ The switching path runs no long-lived daemon. The statusline pushes usage. Hooks
|
|
|
49
49
|
The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limits.{five_hour,seven_day}.{used_percentage,resets_at}`, after every turn, 300ms debounce, zero cost). tokenmaxxing's statusLine tees that to `usage.json` (write-on-change, O(ms)) and renders its own native line (it replaced the earlier pass-through delegation on 2026-07-11: install takes the statusLine slot outright, so a pre-existing custom statusline command is overwritten). Cold-start fallback if `usage.json` is absent: `TOKENMAXXING_PROBE=1 claude -p '/usage'`, with `[ -n "$TOKENMAXXING_PROBE" ] && exit 0` as the hook's first line to stop the nested process recursing (hooks fire in `-p` too). The probe scrubs every ambient credential override claude reads before the keychain (`CLAUDE_CODE_OAUTH_TOKEN`, `CLAUDE_SECURESTORAGE_CONFIG_DIR`, etc.) so it can only meter the credential in the keychain item, and retries the transient empty-footer case (claude prints local stats with no percentages when its own usage fetch throttles).
|
|
50
50
|
|
|
51
51
|
### 3.2 Detect + swap + signal (Stop hook, per turn)
|
|
52
|
-
1. Read `usage.json` and exit
|
|
52
|
+
1. Read `usage.json` and exit while the live account is under both bars, as defined in the [switching policy](docs/content/docs/switching.mdx).
|
|
53
53
|
2. Take the pool lock, re-check state, select a candidate under the [switching policy](docs/content/docs/switching.mdx), and attempt bounded verification of stale usage before swapping the credential.
|
|
54
54
|
3. Done - the running session adopts the new credential on its own within a request or two. Only when the pool is depleted (the decision returned a `waitUntil`: pre-parked on the soonest-recovering account, or staying on the current one when it recovers first) does the hook write `respawn/<session_id>` (atomic temp+rename).
|
|
55
55
|
|
|
@@ -79,24 +79,18 @@ Each terminal ran the supervisor, so each has its own child `claude` and its own
|
|
|
79
79
|
---
|
|
80
80
|
|
|
81
81
|
## 5. Rotation policy
|
|
82
|
-
The [switching policy](docs/content/docs/switching.mdx)
|
|
82
|
+
The [switching policy](docs/content/docs/switching.mdx) is one rule: under both bars, hold; at or over a bar, move to the usable account with the highest pace pressure; nothing usable, wait for the soonest reset.
|
|
83
83
|
|
|
84
|
-
- Automatic
|
|
85
|
-
-
|
|
86
|
-
- Manual switching retains pure pace-pressure ranking.
|
|
87
|
-
- Successful candidate probes persist their results and restart selection, preserving candidates that fail a screening bar but still clear the wall.
|
|
84
|
+
- Automatic and manual switching rank by pace pressure alone; organization membership is not an input.
|
|
85
|
+
- Successful candidate probes persist their results and restart selection.
|
|
88
86
|
- Verification attempts at most two candidates with a shared 12-second deadline per candidate for identity reads and CLI execution.
|
|
89
87
|
- Expiring access tokens are not refreshed for verification, and the isolated CLI receives no refresh grant.
|
|
90
88
|
- Failed or budget-limited verification uses the existing cached data, so stale-target handoffs remain possible.
|
|
89
|
+
- The depleted pause happens at the bar, not at 100; Codex has no pause and rides its account until the server refuses it, because a running sibling cannot adopt another account without restarting.
|
|
91
90
|
|
|
92
|
-
The [profile](docs/content/docs/switching-profile.mdx)
|
|
91
|
+
The [profile](docs/content/docs/switching-profile.mdx) records observed cache rewrites across swaps and does not infer subscription quota savings.
|
|
93
92
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
- Layer 2 is Claude-only because its running sessions can adopt a credential change.
|
|
97
|
-
- Codex keeps its current account until the wall because a running sibling cannot adopt another account without restarting.
|
|
98
|
-
|
|
99
|
-
**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 active model is in `policy.switchModels` we read it from `claude -p '/usage'` (free, 0 tokens, TTL-cached) and add `week(<activeModel>) >= threshold` to the trigger. A Fable session switches on the Fable cap; a Sonnet session rides the aggregate. Both layers apply the per-model gate: a burnt Fable cap screens an account out of a Layer 1 switch, and a Fable cap at the wall screens it out of the Layer 2 squeeze too.
|
|
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 active model is in `policy.switchModels` we read it from `claude -p '/usage'` (free, 0 tokens, TTL-cached) and add `week(<activeModel>) >= threshold` to the trigger. A Fable session switches 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 switch.
|
|
100
94
|
|
|
101
95
|
---
|
|
102
96
|
|
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ claude # use claude as always
|
|
|
66
66
|
| `tokenmaxxing add` | register an additional account (isolated login, harvested into the pool) |
|
|
67
67
|
| `tokenmaxxing add --codex` | register an additional codex account (isolated login) |
|
|
68
68
|
| `tokenmaxxing auth [sel \| --all]` | reauthenticate a pooled account in place: bare lists the pool (emails shown) and asks which; a selector targets one account and tells you the email to sign in with; `--all` walks every needs-reauth account one by one |
|
|
69
|
-
| `tokenmaxxing switch [sel]` | switch the claude pool: bare picks the best account
|
|
69
|
+
| `tokenmaxxing switch [sel]` | switch the claude pool: bare picks the best account (no-op when the current one wins), a selector targets one |
|
|
70
70
|
| `tokenmaxxing switch --codex [sel]` | switch the codex pool (takes effect on the next codex start) |
|
|
71
71
|
| `tokenmaxxing ls` | list pooled accounts |
|
|
72
72
|
| `tokenmaxxing status` | accounts with 5h / weekly usage bars, active + exhausted-until-reset |
|
|
@@ -80,19 +80,18 @@ claude # use claude as always
|
|
|
80
80
|
|
|
81
81
|
## How switching decides
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
One rule: while the active account is under its bars, nothing happens; once it is at or over a bar, the decision moves to the usable account **furthest behind its own weekly pace**. The session bar is **90%** of the 5-hour window and the weekly bar is **98%**. The bars also screen candidates on any of:
|
|
84
84
|
|
|
85
85
|
- **Session** (5-hour) or **week (all models)** - the aggregate windows, fed free/push-based by the statusLine.
|
|
86
86
|
- **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 `claude -p '/usage'` (free, 0 tokens, TTL-cached) whenever the active model is one of `policy.switchModels`, so a Fable session switches on the Fable cap while a Sonnet session rides the aggregate.
|
|
87
87
|
|
|
88
|
-
The bars' headroom is deliberate: it's the budget to reach a clean turn boundary (plus up to one turn of adoption lag on macOS) before the
|
|
88
|
+
The bars' headroom is deliberate: it's the budget to reach a clean turn boundary (plus up to one turn of adoption lag on macOS) before the account's real limit. The session bar sits lower (90) 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).
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
Selection ranks usable candidates by remaining weekly percentage divided by time to reset, highest first; organization membership is not an input, and manual `tokenmaxxing switch` uses the same ranking.
|
|
91
91
|
|
|
92
|
-
- Lateral moves stay within the current organization, while a crossed bar or enforced limit may trigger a cross-organization handoff.
|
|
93
92
|
- Candidate verification makes at most two bounded `/usage` attempts per evaluation and re-ranks successful samples before switching.
|
|
94
93
|
- Failed verification leaves cached figures in force, so it cannot guarantee a fresh target.
|
|
95
|
-
-
|
|
94
|
+
- When no account is usable, the session pauses until the soonest reset if that lands within `policy.maxWaitMs`, and otherwise stays put.
|
|
96
95
|
|
|
97
96
|
See [How switching decides](docs/content/docs/switching.mdx) for the policy and the [cache-cost profile](docs/content/docs/switching-profile.mdx) for measurements and their limits.
|
|
98
97
|
|
|
@@ -102,10 +101,9 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
|
|
|
102
101
|
|
|
103
102
|
```json
|
|
104
103
|
{
|
|
105
|
-
"thresholds": { "session":
|
|
104
|
+
"thresholds": { "session": 90, "weekly": 98 },
|
|
106
105
|
"policy": {
|
|
107
106
|
"projectionMargin": 0,
|
|
108
|
-
"greedySessionFloor": 80,
|
|
109
107
|
"switchModels": ["fable"],
|
|
110
108
|
"usagePollTtlMs": 90000,
|
|
111
109
|
"checkIntervalMs": 60000
|
|
@@ -113,7 +111,7 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
|
|
|
113
111
|
}
|
|
114
112
|
```
|
|
115
113
|
|
|
116
|
-
`thresholds.session`
|
|
114
|
+
`thresholds.session` and `thresholds.weekly` are the two bars, one number each (an array `thresholds.session`, the former ladder, fails config loading with a message naming the field); `projectionMargin` is a fixed safety margin subtracted from each threshold bar (effective bar = threshold - margin), so a large turn is less likely to blow past a bar between checks; `switchModels` names the models whose per-model cap triggers a switch; `usagePollTtlMs` is how long a `/usage` per-model poll stays fresh; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick.
|
|
117
115
|
|
|
118
116
|
State lives entirely in `~/.config/tokenmaxxing/`. Per-account credentials follow the platform's Claude Code store: the login keychain on macOS (`tokenmaxxing-cred-<uuid8>` items, never plaintext on disk), 0600 files under `~/.config/tokenmaxxing/creds/` on Linux (the same plaintext model claude itself uses for `~/.claude/.credentials.json`).
|
|
119
117
|
|
|
@@ -127,7 +125,7 @@ tokenmaxxing add --codex # log in another account, isolated - your primary lo
|
|
|
127
125
|
codex # use codex as always
|
|
128
126
|
```
|
|
129
127
|
|
|
130
|
-
Codex mechanics differ from Claude Code in one hard way: a running codex process refuses a credential swapped to a different account, so **a restart is the switch**. The installed Stop hook runs the same
|
|
128
|
+
Codex mechanics differ from Claude Code in one hard way: a running codex process refuses a credential swapped to a different account, so **a restart is the switch**. The installed Stop hook runs the same pace-pressure decision at each turn boundary (usage read free from codex's own rate-limit endpoint: percentages plus absolute reset times, weekly aggregate and per-model caps alike); when it swaps, the supervisor relaunches `codex resume <session-id>` on the fresh account with the transcript intact. `tokenmaxxing switch --codex [sel]` does it manually, `status`/`watch`/`ls` show both pools.
|
|
131
129
|
|
|
132
130
|
Two codex-specific facts worth knowing: codex does not run hooks it has not been told to trust, so after `init --codex` you must open codex once and trust the tokenmaxxing Stop hook via `/hooks` (auto-switching is inert until then); and codex has no cross-process lock on `auth.json`, so tokenmaxxing serializes all of its own credential writes behind its own lock and swaps only at idle turn boundaries.
|
|
133
131
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.25.0",
|
|
4
4
|
"description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cli/codexswitch.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { performCodexSwap } from "../lib/codexswap.ts";
|
|
|
7
7
|
import { CodexInvalidGrantError } from "../lib/codexoauth.ts";
|
|
8
8
|
import { liveCodexAccountId } from "../lib/codexsample.ts";
|
|
9
9
|
import { presentCodexAccountIds, targetableCodexAccounts } from "../lib/codexpresence.ts";
|
|
10
|
-
import {
|
|
10
|
+
import { thresholdBars } from "../lib/picker.ts";
|
|
11
11
|
import { c, emitError, emitJson } from "./render.ts";
|
|
12
12
|
|
|
13
13
|
export async function cmdCodexSwitch(sel?: string, json = false): Promise<number> {
|
|
@@ -23,7 +23,7 @@ export async function cmdCodexSwitch(sel?: string, json = false): Promise<number
|
|
|
23
23
|
};
|
|
24
24
|
const deadGrantMessage = (label: string) => `${label}'s refresh token is dead - re-add it with \`tokenmaxxing add --codex\``;
|
|
25
25
|
const cfg = loadConfig();
|
|
26
|
-
const bars =
|
|
26
|
+
const bars = thresholdBars(cfg);
|
|
27
27
|
const now = Date.now();
|
|
28
28
|
|
|
29
29
|
return withLock(codexPaths.lockFile, async () => {
|
package/src/cli/config.ts
CHANGED
|
@@ -10,13 +10,9 @@ import { c, emitError, emitJson } from "./render.ts";
|
|
|
10
10
|
export const KNOWN_KEYS = [
|
|
11
11
|
"thresholds.session",
|
|
12
12
|
"thresholds.weekly",
|
|
13
|
-
"hardThresholds.session",
|
|
14
|
-
"hardThresholds.weekly",
|
|
15
13
|
"claudeBin",
|
|
16
14
|
"codexBin",
|
|
17
15
|
"policy.projectionMargin",
|
|
18
|
-
"policy.greedySessionFloor",
|
|
19
|
-
"policy.greedySwapMargin",
|
|
20
16
|
"policy.switchModels",
|
|
21
17
|
"policy.usagePollTtlMs",
|
|
22
18
|
"policy.maxWaitMs",
|
package/src/cli/status.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
|
5
5
|
import { ensureLiveTokenFresh, probeActiveUsage, probeParkedUsage, type SampleOutcome } from "../lib/sample.ts";
|
|
6
6
|
import { withLock } from "../lib/lock.ts";
|
|
7
7
|
import { codexPaths, paths } from "../lib/paths.ts";
|
|
8
|
-
import { earliestReset,
|
|
8
|
+
import { earliestReset, isExhausted, nextWeeklyReset, thresholdBars } from "../lib/picker.ts";
|
|
9
9
|
import { loadCodexAccounts, saveCodexAccounts } from "../lib/codexstate.ts";
|
|
10
10
|
import { liveCodexAccountId, sampleCodexAccount, type CodexSampleOutcome } from "../lib/codexsample.ts";
|
|
11
11
|
import { isCodexExhausted } from "../lib/codexpick.ts";
|
|
@@ -62,7 +62,7 @@ type CodexStatusAccount = z.infer<typeof CodexStatusAccountSchema>;
|
|
|
62
62
|
const StatusReportSchema = z.object({
|
|
63
63
|
now: z.number(),
|
|
64
64
|
claude: z.object({
|
|
65
|
-
thresholds:
|
|
65
|
+
thresholds: ThresholdsSchema,
|
|
66
66
|
bars: ThresholdsSchema,
|
|
67
67
|
projectionMargin: z.number(),
|
|
68
68
|
accounts: z.array(ClaudeStatusAccountSchema),
|
|
@@ -171,7 +171,7 @@ async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: num
|
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
const families = gatedFamilies(loadUsage()?.model ?? null, cfg.policy.switchModels);
|
|
174
|
-
const bars =
|
|
174
|
+
const bars = thresholdBars(cfg);
|
|
175
175
|
const liveAccount = readOAuthAccount()?.accountUuid ?? null;
|
|
176
176
|
const ordered = sortBy(idx.accounts, [(a) => (a.needsReauth ? 1 : 0), (a) => earliestReset(a, now)]);
|
|
177
177
|
const accounts = ordered.map((a): ClaudeStatusAccount => {
|
|
@@ -207,7 +207,7 @@ async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: num
|
|
|
207
207
|
|
|
208
208
|
async function collectCodex(input: { cfg: Config; now: number }): Promise<StatusReport["codex"]> {
|
|
209
209
|
const { cfg, now } = input;
|
|
210
|
-
const bars =
|
|
210
|
+
const bars = thresholdBars(cfg);
|
|
211
211
|
let index = loadCodexAccounts();
|
|
212
212
|
if (index.accounts.length === 0) return { bars, accounts: [] };
|
|
213
213
|
|
|
@@ -410,7 +410,7 @@ function renderClaude(input: { claude: StatusReport["claude"]; codexPooled: bool
|
|
|
410
410
|
return;
|
|
411
411
|
}
|
|
412
412
|
|
|
413
|
-
console.log(c.dim(`thresholds 5h ${claude.thresholds.session
|
|
413
|
+
console.log(c.dim(`thresholds 5h ${claude.thresholds.session}% weekly ${claude.thresholds.weekly}% (${count({ n: claude.accounts.length, noun: "claude account" })})`));
|
|
414
414
|
console.log();
|
|
415
415
|
renderGrid(claude.accounts.map((a) => claudeCard(a, now, staleAfterMs)));
|
|
416
416
|
}
|
package/src/cli/switch.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { paths } from "../lib/paths.ts";
|
|
|
3
3
|
import { loadAccounts, loadConfig, loadUsage } from "../lib/state.ts";
|
|
4
4
|
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
5
5
|
import { isSkippableSwapError, performSwap } from "../lib/swap.ts";
|
|
6
|
-
import { currentWins,
|
|
6
|
+
import { currentWins, pickBest, pickEarliestReset, thresholdBars, weeklyExpiry, type PickCtx } from "../lib/picker.ts";
|
|
7
7
|
import { InvalidGrantError } from "../lib/oauth.ts";
|
|
8
8
|
import { gatedFamilies } from "../lib/usage.ts";
|
|
9
9
|
import { findAccount } from "./rename.ts";
|
|
@@ -62,12 +62,7 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
const switchFamilies = gatedFamilies(loadUsage()?.model ?? null, cfg.policy.switchModels);
|
|
65
|
-
const
|
|
66
|
-
now,
|
|
67
|
-
thresholds: effectiveBars(cfg, { accounts, now, switchFamilies }),
|
|
68
|
-
currentAccountUuid: null,
|
|
69
|
-
switchFamilies,
|
|
70
|
-
});
|
|
65
|
+
const everyone: PickCtx = { now, thresholds: thresholdBars(cfg), currentAccountUuid: null, switchFamilies };
|
|
71
66
|
const rejected = new Set<string>();
|
|
72
67
|
while (true) {
|
|
73
68
|
const cur = loadAccounts();
|
|
@@ -76,7 +71,6 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
|
|
|
76
71
|
(claimed != null ? cur.accounts.find((a) => a.accountUuid === claimed) : null) ??
|
|
77
72
|
cur.accounts.find((a) => a.accountUuid === cur.activeAccountUuid) ??
|
|
78
73
|
null;
|
|
79
|
-
const everyone = everyoneIn(cur.accounts);
|
|
80
74
|
if (active != null && currentWins(active, pool, everyone)) {
|
|
81
75
|
if (drifted) return swapTo(active, "drift-reconciled");
|
|
82
76
|
const expiry = weeklyExpiry(active, now);
|
|
@@ -114,7 +108,7 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
|
|
|
114
108
|
while (true) {
|
|
115
109
|
const fresh = loadAccounts();
|
|
116
110
|
const pool = fresh.accounts.filter((a) => !rejected.has(a.accountUuid));
|
|
117
|
-
const earliest = pickEarliestReset(pool,
|
|
111
|
+
const earliest = pickEarliestReset(pool, everyone);
|
|
118
112
|
const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
|
|
119
113
|
if (!earliest) {
|
|
120
114
|
if (reauth.length > 0) {
|
|
@@ -10,7 +10,7 @@ import { livingCodexPresences } from "../lib/codexpresence.ts";
|
|
|
10
10
|
import { liveCodexAccountId } from "../lib/codexsample.ts";
|
|
11
11
|
import { loadCodexAccounts } from "../lib/codexstate.ts";
|
|
12
12
|
import { loadConfig } from "../lib/state.ts";
|
|
13
|
-
import {
|
|
13
|
+
import { thresholdBars } from "../lib/picker.ts";
|
|
14
14
|
import { CODEX_SUPERVISOR_ID_ENV } from "./codexsupervisor.ts";
|
|
15
15
|
import { CodexReconcileMarkerSchema, CodexRespawnMarkerSchema, CodexStopStdinSchema, type CodexAccount } from "../lib/types.ts";
|
|
16
16
|
import { log } from "../lib/log.ts";
|
|
@@ -51,7 +51,7 @@ function promoteReconcileLocked(input: { supervisorId: string; sessionId: string
|
|
|
51
51
|
return false;
|
|
52
52
|
}
|
|
53
53
|
const now = Date.now();
|
|
54
|
-
const bars =
|
|
54
|
+
const bars = thresholdBars(loadConfig());
|
|
55
55
|
const index = loadCodexAccounts();
|
|
56
56
|
const unusable = (account: CodexAccount): boolean =>
|
|
57
57
|
account.needsReauth === true || isCodexExhausted({ account, thresholds: bars, now });
|
package/src/lib/codexdecide.ts
CHANGED
|
@@ -6,14 +6,14 @@ import { writeFileAtomic } from "./atomic.ts";
|
|
|
6
6
|
import { codexPaths } from "./paths.ts";
|
|
7
7
|
import { loadConfig } from "./state.ts";
|
|
8
8
|
import { loadCodexAccounts, loadCodexLastSwapAt, saveCodexAccounts } from "./codexstate.ts";
|
|
9
|
-
import {
|
|
9
|
+
import { isCodexExhausted, pickBestCodex } from "./codexpick.ts";
|
|
10
10
|
import { performCodexSwap } from "./codexswap.ts";
|
|
11
11
|
import { CodexInvalidGrantError, refreshCodexAuth } from "./codexoauth.ts";
|
|
12
12
|
import { fetchCodexUsage } from "./codexusage.ts";
|
|
13
13
|
import { codexIdentityOf, isCodexAccessExpiring, readLiveCodexAuth, writeLiveCodexAuth, writeParkedCodexAuth } from "./codexauth.ts";
|
|
14
14
|
import { liveCodexAccountId } from "./codexsample.ts";
|
|
15
15
|
import { livingCodexPresences, presentCodexAccountIds, targetableCodexAccounts } from "./codexpresence.ts";
|
|
16
|
-
import {
|
|
16
|
+
import { thresholdBars } from "./picker.ts";
|
|
17
17
|
import { log } from "./log.ts";
|
|
18
18
|
import { CodexAccountSchema, CodexReconcileMarkerSchema, type CodexAccount } from "./types.ts";
|
|
19
19
|
|
|
@@ -100,7 +100,7 @@ function postSwapResweep(input: { liveAccountId: string; bars: { session: number
|
|
|
100
100
|
export async function evaluateAndMaybeSwapCodex(input: { now?: number }): Promise<CodexSwapDecision> {
|
|
101
101
|
const now = input.now ?? Date.now();
|
|
102
102
|
const cfg = loadConfig();
|
|
103
|
-
const bars =
|
|
103
|
+
const bars = thresholdBars(cfg);
|
|
104
104
|
|
|
105
105
|
return withLock(codexPaths.lockFile, async () => {
|
|
106
106
|
let index = loadCodexAccounts();
|
|
@@ -131,34 +131,9 @@ export async function evaluateAndMaybeSwapCodex(input: { now?: number }): Promis
|
|
|
131
131
|
const active = index.accounts.find((account) => account.accountId === activeId) ?? null;
|
|
132
132
|
if (!active) return { swapped: false, account: null, reason: "no-active-account" };
|
|
133
133
|
|
|
134
|
-
const engaged =
|
|
135
|
-
active.needsReauth === true ||
|
|
136
|
-
isCodexEngaged({ account: active, floor: cfg.policy.greedySessionFloor, now }) ||
|
|
137
|
-
isCodexExhausted({ account: active, thresholds: bars, now });
|
|
134
|
+
const engaged = active.needsReauth === true || isCodexExhausted({ account: active, thresholds: bars, now });
|
|
138
135
|
if (!engaged) return { swapped: false, account: null, reason: "under-threshold-or-stale" };
|
|
139
136
|
|
|
140
|
-
if (!isCodexExhausted({ account: active, thresholds: bars, now })) {
|
|
141
|
-
while (true) {
|
|
142
|
-
const current = loadCodexAccounts();
|
|
143
|
-
const candidates = targetableCodexAccounts({ accounts: current.accounts, activeAccountId: activeId });
|
|
144
|
-
const cur = candidates.find((account) => account.accountId === activeId) ?? null;
|
|
145
|
-
if (codexCurrentWins({ active: cur, accounts: candidates, thresholds: bars, now })) {
|
|
146
|
-
return { swapped: false, account: null, reason: "current-best" };
|
|
147
|
-
}
|
|
148
|
-
const best = pickBestCodex({ accounts: candidates, thresholds: bars, now, currentAccountId: activeId });
|
|
149
|
-
if (!best) return { swapped: false, account: null, reason: "no-usable-target" };
|
|
150
|
-
try {
|
|
151
|
-
await performCodexSwap({ target: best });
|
|
152
|
-
} catch (e) {
|
|
153
|
-
if (e instanceof CodexInvalidGrantError) continue;
|
|
154
|
-
throw e;
|
|
155
|
-
}
|
|
156
|
-
log("codexdecide.greedy_swap", { account: best.accountId.slice(0, 8) });
|
|
157
|
-
postSwapResweep({ liveAccountId: best.accountId, bars, now });
|
|
158
|
-
return { swapped: true, account: best, reason: "swapped" };
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
137
|
const tried = new Set<string>();
|
|
163
138
|
while (true) {
|
|
164
139
|
const current = loadCodexAccounts();
|
|
@@ -174,7 +149,7 @@ export async function evaluateAndMaybeSwapCodex(input: { now?: number }): Promis
|
|
|
174
149
|
if (e instanceof CodexInvalidGrantError) continue;
|
|
175
150
|
throw e;
|
|
176
151
|
}
|
|
177
|
-
log("codexdecide.
|
|
152
|
+
log("codexdecide.swap", { account: best.accountId.slice(0, 8) });
|
|
178
153
|
postSwapResweep({ liveAccountId: best.accountId, bars, now });
|
|
179
154
|
return { swapped: true, account: best, reason: "swapped" };
|
|
180
155
|
}
|
package/src/lib/codexpick.ts
CHANGED
|
@@ -78,9 +78,3 @@ export function codexCurrentWins(input: {
|
|
|
78
78
|
if (best == null || best.accountId === active.accountId) return true;
|
|
79
79
|
return codexPacePressure({ account: best, now }) <= codexPacePressure({ account: active, now }) * CODEX_SWAP_IMPROVEMENT;
|
|
80
80
|
}
|
|
81
|
-
|
|
82
|
-
export function isCodexEngaged(input: { account: CodexAccount; floor: number; now: number }): boolean {
|
|
83
|
-
const { account, floor, now } = input;
|
|
84
|
-
const sampledAt = account.lastUsageAt ?? null;
|
|
85
|
-
return allWindows(account).some((window) => liveUsed({ window, now, sampledAt }) >= floor);
|
|
86
|
-
}
|
package/src/lib/decide.ts
CHANGED
|
@@ -4,9 +4,9 @@ import { withLock } from "./lock.ts";
|
|
|
4
4
|
import { paths } from "./paths.ts";
|
|
5
5
|
import { POST_SWAP_COOLDOWN_MS, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadUsageSnapshot, loadModelUsage, saveAccounts, saveDepletedWait, saveModelUsage, writeUsage } from "./state.ts";
|
|
6
6
|
import { readOAuthAccount } from "./claudejson.ts";
|
|
7
|
-
import {
|
|
7
|
+
import { isSkippableSwapError, performSwap, type VerifyVerdict } from "./swap.ts";
|
|
8
8
|
import { probeParkedUsage } from "./sample.ts";
|
|
9
|
-
import {
|
|
9
|
+
import { isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, thresholdBars, usableAt, type PickCtx } from "./picker.ts";
|
|
10
10
|
import { familyTokens, gatedFamilies, probeUsage, type EnforcedClass } from "./usage.ts";
|
|
11
11
|
import { log } from "./log.ts";
|
|
12
12
|
import { AccountSchema, ModelUsageStateSchema, UsageStateSchema, type Account, type Config, type EnforcedLimit, type ModelUsageState, type Thresholds, type UsageState, type UsageWindow } from "./types.ts";
|
|
@@ -38,15 +38,6 @@ function capForFamily(mu: ModelUsageState, family: string, now: number): UsageWi
|
|
|
38
38
|
return maxBy(rows, (w) => liveUsed({ window: w, windowMs: WEEK_MS, sampledAt: mu.sampledAt ?? mu.ts, now }));
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
function overlayLive(accounts: Account[], u: UsageState | null, mu: ModelUsageState | null, account: string | null): Account[] {
|
|
42
|
-
return accounts.map((a) => {
|
|
43
|
-
if (account == null || a.accountUuid !== account) return a;
|
|
44
|
-
const live = u && u.account === account ? { lastUsage: { fiveHour: u.fiveHour, sevenDay: u.sevenDay }, lastUsageAt: u.ts } : {};
|
|
45
|
-
const perModel = mu && mu.account === account && Object.keys(mu.perModel).length > 0 ? { lastPerModel: mu.perModel, lastPerModelAt: mu.sampledAt ?? mu.ts } : {};
|
|
46
|
-
return { ...a, ...live, ...perModel };
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
|
|
50
41
|
function isOver(u: UsageState | null, mu: ModelUsageState | null, account: string | null, bars: Thresholds, cfg: Config, now: number): boolean {
|
|
51
42
|
if (!u || !account || u.account !== account) return false;
|
|
52
43
|
if (
|
|
@@ -66,11 +57,6 @@ function needsPerModel(u: UsageState | null, cfg: Config): boolean {
|
|
|
66
57
|
return u != null && gatedFamilies(u.model, cfg.policy.switchModels).length > 0;
|
|
67
58
|
}
|
|
68
59
|
|
|
69
|
-
function isEngaged(u: UsageState | null, mu: ModelUsageState | null, account: string | null, bars: Thresholds, cfg: Config, now: number): boolean {
|
|
70
|
-
if (!u || !account || u.account !== account) return false;
|
|
71
|
-
return liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }) >= cfg.policy.greedySessionFloor || isOver(u, mu, account, bars, cfg, now);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
60
|
const SnapshotsSchema = z.object({
|
|
75
61
|
u: UsageStateSchema.nullable(),
|
|
76
62
|
mu: ModelUsageStateSchema.nullable(),
|
|
@@ -138,17 +124,13 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
138
124
|
}
|
|
139
125
|
|
|
140
126
|
const cfg = loadConfig();
|
|
127
|
+
const bars = thresholdBars(cfg);
|
|
141
128
|
|
|
142
129
|
const { u: teeUsage, mu, uAt } = await loadFreshSnapshots(cfg, activeAccount, now);
|
|
143
130
|
const pool = loadAccounts();
|
|
144
131
|
const usage = freshest(teeUsage, uAt, pool.accounts.find((a) => a.accountUuid === activeAccount));
|
|
145
|
-
const bars0 = effectiveBars(cfg, {
|
|
146
|
-
accounts: overlayLive(pool.accounts, usage, mu, activeAccount),
|
|
147
|
-
now,
|
|
148
|
-
switchFamilies: gatedFamilies(usage?.model ?? null, cfg.policy.switchModels),
|
|
149
|
-
});
|
|
150
132
|
|
|
151
|
-
if (!enforced0 && !
|
|
133
|
+
if (!enforced0 && !isOver(usage, mu, activeAccount, bars, cfg, now)) {
|
|
152
134
|
const measured = usage != null && activeAccount != null && usage.account === activeAccount;
|
|
153
135
|
if (!measured) {
|
|
154
136
|
const replay = depletedReplay(now);
|
|
@@ -187,10 +169,8 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
187
169
|
|
|
188
170
|
const gated = gatedFamilies(u2?.model ?? null, cfg.policy.switchModels);
|
|
189
171
|
const switchFamilies = enforced2?.family && !gated.includes(enforced2.family) ? [...gated, enforced2.family] : gated;
|
|
190
|
-
const barsOf = (accounts: Account[]): Thresholds => effectiveBars(cfg, { accounts, now, switchFamilies });
|
|
191
|
-
const bars = barsOf(idx.accounts);
|
|
192
172
|
|
|
193
|
-
if (!enforced2 && !
|
|
173
|
+
if (!enforced2 && !isOver(u2, mu2, account2, bars, cfg, now)) {
|
|
194
174
|
return depletedReplay(now) ?? { swapped: false, account: null, reason: "raced-already-swapped" };
|
|
195
175
|
}
|
|
196
176
|
|
|
@@ -246,40 +226,11 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
246
226
|
return "rerank";
|
|
247
227
|
};
|
|
248
228
|
|
|
249
|
-
const greedy = async (holdMargin: number): Promise<SwapDecision> => {
|
|
250
|
-
while (true) {
|
|
251
|
-
const cur = loadAccounts();
|
|
252
|
-
const active = seatOf(cur);
|
|
253
|
-
const ctxAll = { now, thresholds: barsOf(cur.accounts), currentAccountUuid: null, currentOrganizationUuid: active?.organizationUuid ?? null, orgAffinityFloor: cfg.policy.greedySessionFloor, switchFamilies, holdMargin };
|
|
254
|
-
const seatOrg = active != null && !active.needsReauth && !isExhausted(active, ctxAll) ? active.organizationUuid : null;
|
|
255
|
-
const pool = usable(cur.accounts).filter((a) => seatOrg == null || a.organizationUuid === seatOrg);
|
|
256
|
-
if (currentWins(active, pool, ctxAll)) {
|
|
257
|
-
return { swapped: false, account: null, reason: "current-best" };
|
|
258
|
-
}
|
|
259
|
-
const ctx = { ...ctxAll, currentAccountUuid: active?.accountUuid ?? null };
|
|
260
|
-
const best = pickBest(pool, ctx);
|
|
261
|
-
if (!best) return { swapped: false, account: null, reason: "no-usable-target" };
|
|
262
|
-
if ((await verified(best, ctx)) !== "go") continue;
|
|
263
|
-
try {
|
|
264
|
-
await performSwap(best);
|
|
265
|
-
} catch (e) {
|
|
266
|
-
skipOrThrow(e, best);
|
|
267
|
-
continue;
|
|
268
|
-
}
|
|
269
|
-
log("decide.greedy_swap", { account: best.accountUuid.slice(0, 8) });
|
|
270
|
-
return { swapped: true, account: best, reason: "swapped" };
|
|
271
|
-
}
|
|
272
|
-
};
|
|
273
|
-
if (!enforced2 && !isOver(u2, mu2, account2, bars, cfg, now)) return greedy(cfg.policy.greedySwapMargin);
|
|
274
|
-
|
|
275
229
|
while (true) {
|
|
276
230
|
const cur = loadAccounts();
|
|
277
231
|
const seat = seatOf(cur);
|
|
278
|
-
const
|
|
279
|
-
const
|
|
280
|
-
if (!enforced2 && seat && !seat.needsReauth && !isExhausted(seat, { now, thresholds, currentAccountUuid: null, switchFamilies })) return greedy(0);
|
|
281
|
-
const ctx = { now, thresholds, currentAccountUuid: seat?.accountUuid ?? null, currentOrganizationUuid: seat?.organizationUuid ?? null, orgAffinityFloor: cfg.policy.greedySessionFloor, switchFamilies };
|
|
282
|
-
const best = pickBest(pool, ctx);
|
|
232
|
+
const ctx = { now, thresholds: bars, currentAccountUuid: seat?.accountUuid ?? null, switchFamilies };
|
|
233
|
+
const best = pickBest(usable(cur.accounts), ctx);
|
|
283
234
|
if (!best) break;
|
|
284
235
|
if ((await verified(best, ctx)) !== "go") continue;
|
|
285
236
|
try {
|
|
@@ -288,26 +239,14 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
288
239
|
skipOrThrow(e, best);
|
|
289
240
|
continue;
|
|
290
241
|
}
|
|
291
|
-
log("decide.
|
|
242
|
+
log("decide.swap", { account: best.accountUuid.slice(0, 8), enforced: enforced2 != null });
|
|
292
243
|
return { swapped: true, account: best, reason: "swapped" };
|
|
293
244
|
}
|
|
294
245
|
|
|
295
|
-
const hardCtx = { now, thresholds: hardBars(cfg), currentAccountUuid: null, switchFamilies };
|
|
296
|
-
const seat = seatOf(loadAccounts());
|
|
297
|
-
if (!enforced2 && seat && !seat.needsReauth && !isExhausted(seat, hardCtx)) {
|
|
298
|
-
log("decide.last_drop_hold", { account: seat.accountUuid.slice(0, 8) });
|
|
299
|
-
return { swapped: false, account: null, reason: "last-drop-hold" };
|
|
300
|
-
}
|
|
301
|
-
const squeezed = await chooseAndSwap({ ...hardCtx, currentAccountUuid: seat?.accountUuid ?? null, currentOrganizationUuid: seat?.organizationUuid ?? null, orgAffinityFloor: cfg.policy.greedySessionFloor }, rejected, verified);
|
|
302
|
-
if (squeezed) {
|
|
303
|
-
log("decide.last_drop_swap", { account: squeezed.accountUuid.slice(0, 8) });
|
|
304
|
-
return { swapped: true, account: squeezed, reason: "last-drop-swap" };
|
|
305
|
-
}
|
|
306
|
-
|
|
307
246
|
while (true) {
|
|
308
247
|
const fresh = loadAccounts();
|
|
309
248
|
const current = seatOf(fresh);
|
|
310
|
-
const ctx = { now, thresholds:
|
|
249
|
+
const ctx = { now, thresholds: bars, currentAccountUuid: current?.accountUuid ?? null, switchFamilies };
|
|
311
250
|
const enforcedUntil = enforced2 && current && current.accountUuid === enforced2.account ? (enforced2.resetsAt ?? now + enforced2.windowMs) : 0;
|
|
312
251
|
const currentAt = current ? Math.max(usableAt(current, ctx), enforcedUntil) : Number.POSITIVE_INFINITY;
|
|
313
252
|
const other = pickEarliestReset(usable(fresh.accounts), ctx);
|
package/src/lib/picker.ts
CHANGED
|
@@ -3,41 +3,18 @@ import { z } from "zod";
|
|
|
3
3
|
import { familyTokens } from "./usage.ts";
|
|
4
4
|
import { AccountSchema, ThresholdsSchema, type Account, type Config, type Thresholds, type UsageWindow } from "./types.ts";
|
|
5
5
|
|
|
6
|
-
export function
|
|
7
|
-
return cfg.thresholds.session.map((rung) => rung - cfg.policy.projectionMargin);
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function terminalBars(cfg: Config): Thresholds {
|
|
6
|
+
export function thresholdBars(cfg: Config): Thresholds {
|
|
11
7
|
return {
|
|
12
|
-
session:
|
|
8
|
+
session: cfg.thresholds.session - cfg.policy.projectionMargin,
|
|
13
9
|
weekly: cfg.thresholds.weekly - cfg.policy.projectionMargin,
|
|
14
10
|
};
|
|
15
11
|
}
|
|
16
12
|
|
|
17
|
-
export function effectiveBars(cfg: Config, pool: { accounts: Account[]; now: number; switchFamilies: string[] }): Thresholds {
|
|
18
|
-
const top = terminalBars(cfg);
|
|
19
|
-
const holdsAt = (session: number) =>
|
|
20
|
-
pool.accounts.some(
|
|
21
|
-
(a) => !a.needsReauth && !isExhausted(a, { now: pool.now, thresholds: { session, weekly: top.weekly }, currentAccountUuid: null, switchFamilies: pool.switchFamilies }),
|
|
22
|
-
);
|
|
23
|
-
return { session: sessionLadder(cfg).find(holdsAt) ?? top.session, weekly: top.weekly };
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function hardBars(cfg: Config): Thresholds {
|
|
27
|
-
return {
|
|
28
|
-
session: cfg.hardThresholds.session - cfg.policy.projectionMargin,
|
|
29
|
-
weekly: cfg.hardThresholds.weekly - cfg.policy.projectionMargin,
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
|
|
33
13
|
const PickCtxSchema = z.object({
|
|
34
14
|
now: z.number(),
|
|
35
15
|
thresholds: ThresholdsSchema,
|
|
36
16
|
currentAccountUuid: z.string().nullable(),
|
|
37
|
-
currentOrganizationUuid: z.string().nullable().optional(),
|
|
38
|
-
orgAffinityFloor: z.number().optional(),
|
|
39
17
|
switchFamilies: z.array(z.string()),
|
|
40
|
-
holdMargin: z.number().min(0).optional(),
|
|
41
18
|
});
|
|
42
19
|
export type PickCtx = z.infer<typeof PickCtxSchema>;
|
|
43
20
|
|
|
@@ -96,25 +73,7 @@ export function pacePressure(a: Account, now: number): number {
|
|
|
96
73
|
return Math.max(0, 100 - used) / Math.max(1, reset - now);
|
|
97
74
|
}
|
|
98
75
|
|
|
99
|
-
function sessionUsed(a: Account, now: number): number {
|
|
100
|
-
const w = a.lastUsage?.fiveHour;
|
|
101
|
-
if (w == null) return 101;
|
|
102
|
-
if (w.resetsAt != null) return w.resetsAt <= now ? 0 : w.usedPercentage;
|
|
103
|
-
if (a.lastUsageAt != null && now >= a.lastUsageAt + FIVE_HOURS_MS) return 0;
|
|
104
|
-
return w.usedPercentage;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function orgTier(a: Account, ctx: PickCtx): number {
|
|
108
|
-
if (ctx.currentOrganizationUuid == null || a.organizationUuid !== ctx.currentOrganizationUuid) return 1;
|
|
109
|
-
if (ctx.orgAffinityFloor != null && sessionUsed(a, ctx.now) >= ctx.orgAffinityFloor) return 1;
|
|
110
|
-
if (!a.lastUsage || ctx.switchFamilies.some((family) => gatedPerModelWindows(a, [family]).length === 0)) return 1;
|
|
111
|
-
const headroom = Math.max(1, ctx.thresholds.session - (ctx.orgAffinityFloor ?? ctx.thresholds.session));
|
|
112
|
-
if (isExhausted(a, { ...ctx, thresholds: { session: ctx.thresholds.session - headroom, weekly: ctx.thresholds.weekly - headroom } })) return 1;
|
|
113
|
-
return 0;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
76
|
const swapPreference = (ctx: PickCtx) => [
|
|
117
|
-
(a: Account) => orgTier(a, ctx),
|
|
118
77
|
(a: Account) => -pacePressure(a, ctx.now),
|
|
119
78
|
(a: Account) => weeklyExpiry(a, ctx.now),
|
|
120
79
|
(a: Account) => a.lastUsage?.sevenDay.usedPercentage ?? 101,
|
|
@@ -131,9 +90,6 @@ export function currentWins(active: Account | null, accounts: Account[], ctx: Pi
|
|
|
131
90
|
if (!active || active.needsReauth || isExhausted(active, ctx)) return false;
|
|
132
91
|
const best = pickBest(accounts, { ...ctx, currentAccountUuid: null });
|
|
133
92
|
if (best == null || best.accountUuid === active.accountUuid) return true;
|
|
134
|
-
const margin = ctx.holdMargin ?? 0;
|
|
135
|
-
const bestPace = pacePressure(best, ctx.now);
|
|
136
|
-
if (margin > 0 && bestPace > 0 && bestPace <= pacePressure(active, ctx.now) * (1 + margin)) return true;
|
|
137
93
|
return swapPreference(ctx).every((k) => k(active) === k(best));
|
|
138
94
|
}
|
|
139
95
|
|
package/src/lib/state.ts
CHANGED
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
ConfigSchema,
|
|
9
9
|
LastSwapSchema,
|
|
10
10
|
ModelUsageStateSchema,
|
|
11
|
-
SessionLadderSchema,
|
|
12
11
|
UsageStateSchema,
|
|
13
12
|
type AccountsIndex,
|
|
14
13
|
type Config,
|
|
@@ -17,14 +16,11 @@ import {
|
|
|
17
16
|
} from "./types.ts";
|
|
18
17
|
|
|
19
18
|
const DEFAULT_CONFIG: Config = {
|
|
20
|
-
thresholds: { session:
|
|
21
|
-
hardThresholds: { session: 100, weekly: 100 },
|
|
19
|
+
thresholds: { session: 90, weekly: 98 },
|
|
22
20
|
claudeBin: "",
|
|
23
21
|
codexBin: "",
|
|
24
22
|
policy: {
|
|
25
23
|
projectionMargin: 0,
|
|
26
|
-
greedySessionFloor: 80,
|
|
27
|
-
greedySwapMargin: 0.15,
|
|
28
24
|
switchModels: ["fable"],
|
|
29
25
|
usagePollTtlMs: 90_000,
|
|
30
26
|
maxWaitMs: 3_600_000,
|
|
@@ -36,15 +32,12 @@ const PercentSchema = z.number().min(0).max(100);
|
|
|
36
32
|
|
|
37
33
|
export const ConfigFileSchema = z
|
|
38
34
|
.object({
|
|
39
|
-
thresholds: z.object({ session:
|
|
40
|
-
hardThresholds: z.object({ session: PercentSchema, weekly: PercentSchema }).partial(),
|
|
35
|
+
thresholds: z.object({ session: PercentSchema, weekly: PercentSchema }).partial(),
|
|
41
36
|
claudeBin: z.string(),
|
|
42
37
|
codexBin: z.string(),
|
|
43
38
|
policy: z
|
|
44
39
|
.object({
|
|
45
40
|
projectionMargin: PercentSchema,
|
|
46
|
-
greedySessionFloor: PercentSchema,
|
|
47
|
-
greedySwapMargin: z.number().min(0).max(1),
|
|
48
41
|
switchModels: z.array(z.string()),
|
|
49
42
|
usagePollTtlMs: z.number().int().positive(),
|
|
50
43
|
maxWaitMs: z.number().int().positive(),
|
|
@@ -64,18 +57,13 @@ export function mergeConfigFile(p: z.infer<typeof ConfigFileSchema>): MergeOutco
|
|
|
64
57
|
const cfg: Config = {
|
|
65
58
|
...DEFAULT_CONFIG,
|
|
66
59
|
thresholds: { ...DEFAULT_CONFIG.thresholds },
|
|
67
|
-
hardThresholds: { ...DEFAULT_CONFIG.hardThresholds },
|
|
68
60
|
policy: { ...DEFAULT_CONFIG.policy },
|
|
69
61
|
};
|
|
70
62
|
cfg.thresholds.session = p.thresholds?.session ?? cfg.thresholds.session;
|
|
71
63
|
cfg.thresholds.weekly = p.thresholds?.weekly ?? cfg.thresholds.weekly;
|
|
72
|
-
cfg.hardThresholds.session = p.hardThresholds?.session ?? cfg.hardThresholds.session;
|
|
73
|
-
cfg.hardThresholds.weekly = p.hardThresholds?.weekly ?? cfg.hardThresholds.weekly;
|
|
74
64
|
cfg.claudeBin = p.claudeBin ?? cfg.claudeBin;
|
|
75
65
|
cfg.codexBin = p.codexBin ?? cfg.codexBin;
|
|
76
66
|
cfg.policy.projectionMargin = p.policy?.projectionMargin ?? cfg.policy.projectionMargin;
|
|
77
|
-
cfg.policy.greedySessionFloor = p.policy?.greedySessionFloor ?? cfg.policy.greedySessionFloor;
|
|
78
|
-
cfg.policy.greedySwapMargin = p.policy?.greedySwapMargin ?? cfg.policy.greedySwapMargin;
|
|
79
67
|
cfg.policy.usagePollTtlMs = p.policy?.usagePollTtlMs ?? cfg.policy.usagePollTtlMs;
|
|
80
68
|
cfg.policy.maxWaitMs = p.policy?.maxWaitMs ?? cfg.policy.maxWaitMs;
|
|
81
69
|
cfg.policy.checkIntervalMs = p.policy?.checkIntervalMs ?? cfg.policy.checkIntervalMs;
|
|
@@ -104,7 +92,7 @@ export function loadConfig(): Config {
|
|
|
104
92
|
}
|
|
105
93
|
const parsed = ConfigFileSchema.safeParse(raw);
|
|
106
94
|
if (!parsed.success) {
|
|
107
|
-
const fields = parsed.error.issues.map((issue) => issue.path.join(".")).join(", ");
|
|
95
|
+
const fields = parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(", ");
|
|
108
96
|
throw new Error(`${paths.configJson} has wrong-typed values (${fields}) - fix or remove them`);
|
|
109
97
|
}
|
|
110
98
|
fileData = parsed.data;
|
package/src/lib/swap.ts
CHANGED
|
@@ -5,7 +5,6 @@ import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenI
|
|
|
5
5
|
import { swapOAuthAccount } from "./claudejson.ts";
|
|
6
6
|
import { withClaudeRefreshLock } from "./claudelock.ts";
|
|
7
7
|
import { log } from "./log.ts";
|
|
8
|
-
import { pickBest, type PickCtx } from "./picker.ts";
|
|
9
8
|
import { CredentialBlobSchema, type Account, type OAuthCreds, type TokenIdentity } from "./types.ts";
|
|
10
9
|
|
|
11
10
|
function parseBlob(raw: string) {
|
|
@@ -198,34 +197,3 @@ export async function keepRotatedPair(input: {
|
|
|
198
197
|
export function isSkippableSwapError(e: unknown): boolean {
|
|
199
198
|
return e instanceof InvalidGrantError || e instanceof RefreshRejectedError || e instanceof IdentityUnavailableError;
|
|
200
199
|
}
|
|
201
|
-
|
|
202
|
-
export async function chooseAndSwap(
|
|
203
|
-
ctx: PickCtx,
|
|
204
|
-
exclude: ReadonlySet<string> = new Set(),
|
|
205
|
-
verify?: (candidate: Account, ctx: PickCtx) => Promise<VerifyVerdict>,
|
|
206
|
-
): Promise<Account | null> {
|
|
207
|
-
const tried = new Set<string>(exclude);
|
|
208
|
-
while (true) {
|
|
209
|
-
const idx = loadAccounts();
|
|
210
|
-
const candidates = idx.accounts.filter((a) => !tried.has(a.accountUuid));
|
|
211
|
-
const best = pickBest(candidates, ctx);
|
|
212
|
-
if (!best) return null;
|
|
213
|
-
if (verify) {
|
|
214
|
-
const verdict = await verify(best, ctx);
|
|
215
|
-
if (verdict === "rerank") continue;
|
|
216
|
-
if (verdict === "skip") {
|
|
217
|
-
tried.add(best.accountUuid);
|
|
218
|
-
continue;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
tried.add(best.accountUuid);
|
|
222
|
-
try {
|
|
223
|
-
await performSwap(best);
|
|
224
|
-
return best;
|
|
225
|
-
} catch (e) {
|
|
226
|
-
if (isSkippableSwapError(e)) continue;
|
|
227
|
-
throw e;
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
package/src/lib/types.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { isEqual, uniq } from "es-toolkit";
|
|
2
1
|
import { z } from "zod";
|
|
3
2
|
|
|
4
3
|
const OAuthCredsSchema = z.looseObject({
|
|
@@ -99,39 +98,21 @@ export const ThresholdsSchema = z.object({
|
|
|
99
98
|
});
|
|
100
99
|
export type Thresholds = z.infer<typeof ThresholdsSchema>;
|
|
101
100
|
|
|
102
|
-
export const SessionLadderSchema = z
|
|
103
|
-
.array(z.number().min(0).max(100))
|
|
104
|
-
.min(1)
|
|
105
|
-
.refine((rungs) => isEqual(rungs, uniq(rungs).toSorted((a, b) => a - b)), {
|
|
106
|
-
message: "thresholds.session rungs must be strictly ascending",
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
export const ScreeningThresholdsSchema = z.object({
|
|
110
|
-
session: SessionLadderSchema,
|
|
111
|
-
weekly: z.number().min(0).max(100),
|
|
112
|
-
});
|
|
113
|
-
|
|
114
101
|
export const ConfigSchema = z
|
|
115
102
|
.object({
|
|
116
|
-
thresholds:
|
|
117
|
-
hardThresholds: ThresholdsSchema,
|
|
103
|
+
thresholds: ThresholdsSchema,
|
|
118
104
|
claudeBin: z.string(),
|
|
119
105
|
codexBin: z.string(),
|
|
120
106
|
policy: z.object({
|
|
121
107
|
projectionMargin: z.number().min(0).max(100),
|
|
122
|
-
greedySessionFloor: z.number().min(0).max(100),
|
|
123
|
-
greedySwapMargin: z.number().min(0).max(1),
|
|
124
108
|
switchModels: z.array(z.string()),
|
|
125
109
|
usagePollTtlMs: z.number().int().positive(),
|
|
126
110
|
maxWaitMs: z.number().int().positive(),
|
|
127
111
|
checkIntervalMs: z.number().int().min(10_000),
|
|
128
112
|
}),
|
|
129
113
|
})
|
|
130
|
-
.refine((cfg) => cfg.policy.projectionMargin < Math.min(
|
|
131
|
-
message: "policy.projectionMargin must be strictly below
|
|
132
|
-
})
|
|
133
|
-
.refine((cfg) => cfg.hardThresholds.session >= Math.max(...cfg.thresholds.session) && cfg.hardThresholds.weekly >= cfg.thresholds.weekly, {
|
|
134
|
-
message: "hardThresholds (the Layer 2 wall) must be at or above thresholds (the Layer 1 screening bars, the top session rung) for both windows",
|
|
114
|
+
.refine((cfg) => cfg.policy.projectionMargin < Math.min(cfg.thresholds.session, cfg.thresholds.weekly), {
|
|
115
|
+
message: "policy.projectionMargin must be strictly below both thresholds (the bars would hit zero and every account would read as exhausted)",
|
|
135
116
|
});
|
|
136
117
|
export type Config = z.infer<typeof ConfigSchema>;
|
|
137
118
|
|