opencode-auth-load-balancer 0.2.1

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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +289 -0
  3. package/dist/accounts.d.ts +20 -0
  4. package/dist/cli/status.d.ts +2 -0
  5. package/dist/fetch.d.ts +34 -0
  6. package/dist/index.d.ts +10 -0
  7. package/dist/index.js +14829 -0
  8. package/dist/notify.d.ts +47 -0
  9. package/dist/pool/lock.d.ts +39 -0
  10. package/dist/pool/paths.d.ts +16 -0
  11. package/dist/pool/store.d.ts +51 -0
  12. package/dist/prime.d.ts +32 -0
  13. package/dist/providers/anthropic/adapter.d.ts +8 -0
  14. package/dist/providers/anthropic/cch.d.ts +20 -0
  15. package/dist/providers/anthropic/constants.d.ts +64 -0
  16. package/dist/providers/anthropic/fallback.d.ts +52 -0
  17. package/dist/providers/anthropic/oauth.d.ts +8 -0
  18. package/dist/providers/anthropic/transform.d.ts +23 -0
  19. package/dist/providers/anthropic/usage.d.ts +14 -0
  20. package/dist/providers/headers.d.ts +7 -0
  21. package/dist/providers/http-timeouts.d.ts +32 -0
  22. package/dist/providers/oauth-callback.d.ts +100 -0
  23. package/dist/providers/openai/adapter.d.ts +9 -0
  24. package/dist/providers/openai/constants.d.ts +18 -0
  25. package/dist/providers/openai/jwt.d.ts +5 -0
  26. package/dist/providers/openai/oauth.d.ts +12 -0
  27. package/dist/providers/openai/transform.d.ts +18 -0
  28. package/dist/providers/openai/usage.d.ts +16 -0
  29. package/dist/providers/pkce.d.ts +10 -0
  30. package/dist/providers/types.d.ts +122 -0
  31. package/dist/providers/usage-headers.d.ts +55 -0
  32. package/dist/providers/usage-http.d.ts +8 -0
  33. package/dist/refresh.d.ts +13 -0
  34. package/dist/scheduler/config.d.ts +46 -0
  35. package/dist/scheduler/score-core.d.ts +167 -0
  36. package/dist/scheduler/select.d.ts +41 -0
  37. package/dist/session.d.ts +15 -0
  38. package/dist/status.d.ts +55 -0
  39. package/dist/types.d.ts +101 -0
  40. package/dist/usage-merge.d.ts +23 -0
  41. package/dist/usage-refresh.d.ts +30 -0
  42. package/dist/util.d.ts +118 -0
  43. package/package.json +75 -0
  44. package/tui/auth-load-balancer-scoring.ts +344 -0
  45. package/tui/auth-load-balancer-tui.logic.ts +362 -0
  46. package/tui/auth-load-balancer-tui.ts +39 -0
  47. package/tui/auth-load-balancer-tui.view.tsx +327 -0
@@ -0,0 +1,30 @@
1
+ import type { ProviderAdapter } from './providers/types';
2
+ import type { PoolFile } from './types';
3
+ /**
4
+ * Test-only window into the module-private `lastPoll` throttle map. Never
5
+ * imported by production code — only by usage-refresh tests that need to
6
+ * assert on the prune's exact membership (a net-neutral delete+re-add cycle
7
+ * cannot be distinguished by fetchUsage call counts alone, since a freshly
8
+ * added account never collides with a stale id).
9
+ */
10
+ export declare function _lastPollIdsForTests(): Set<string>;
11
+ /**
12
+ * Best-effort: seed/refresh usage for accounts whose WEEKLY snapshot is missing or
13
+ * stale (`capturedAt` is stamped only when a weekly window arrives — see
14
+ * `applyUsagePartial` in fetch.ts), via the provider's dedicated usage endpoint.
15
+ * Throttled per account (at most one poll per account per SEED_TTL_MS). Callers
16
+ * invoke it fire-and-forget, so it adds no latency to the request path; failures
17
+ * are swallowed — response headers remain the primary, always-fresh usage signal
18
+ * and this fixes cold-start blindness AND out-of-band server-side resets (e.g. a
19
+ * promotional weekly-quota reset) that response headers alone don't converge.
20
+ *
21
+ * Returns a promise (for tests / explicit awaiting); request-path callers ignore it.
22
+ *
23
+ * `poolSnapshot` lets the request hot path reuse the pool it JUST read for
24
+ * account selection instead of paying a second serialized file read +
25
+ * JSON.parse per request. The snapshot is only consulted for the staleness
26
+ * gates below; the actual usage write still goes through `mutatePool` (which
27
+ * re-reads under the lock), so a slightly stale snapshot is harmless. Callers
28
+ * without a pool in hand (e.g. the startup seed in index.ts) omit it.
29
+ */
30
+ export declare function refreshUsageInBackground(adapter: ProviderAdapter, now: number, poolSnapshot?: PoolFile): Promise<void>;
package/dist/util.d.ts ADDED
@@ -0,0 +1,118 @@
1
+ /** Shared no-op for swallowing rejected promises / throwaway callbacks. */
2
+ export declare const ignore: () => undefined;
3
+ /** Shared async sleep — used by the pool's lock-retry and atomic-rename loops. */
4
+ export declare function sleep(ms: number): Promise<void>;
5
+ /**
6
+ * Abortable sleep. Resolves after `ms`, or rejects with the signal's abort reason the
7
+ * moment `signal` aborts — so a request waiting out a rate-limit cooldown (see fetch.ts)
8
+ * wakes IMMEDIATELY when opencode cancels the turn instead of blocking the full delay.
9
+ * Falls back to the plain `sleep` when no signal is given. The abort reason is propagated
10
+ * untouched (a DOMException named `AbortError` by default), which fetch.ts recognizes as
11
+ * a client abort and re-throws rather than treating as the account's fault.
12
+ */
13
+ export declare function sleepAbortable(ms: number, signal?: AbortSignal): Promise<void>;
14
+ /**
15
+ * Clamp a number into [0, 1], treating NaN/Infinity as 0. Used by both provider
16
+ * usage parsers to normalize header/endpoint utilization into the contract the
17
+ * scheduler expects (a finite fraction). NOTE: `src/scheduler/score-core.ts` ships
18
+ * its OWN identical clamp01 by design — that file is byte-copied into
19
+ * `tui/auth-load-balancer-scoring.ts` and MUST stay dependency-free (enforced by
20
+ * `tui-scoring-sync.test.ts`), so this helper covers the providers only.
21
+ */
22
+ export declare function clamp01(n: number): number;
23
+ /**
24
+ * Type guard: a real, finite number (rejects NaN/±Infinity AND non-numbers).
25
+ * Shared by both providers' `endpointWindow` malformed-window checks, where
26
+ * the `v is number` narrowing also handles optional fields. NOTE:
27
+ * `tui/auth-load-balancer-tui.logic.ts` ships its OWN `isFiniteNumber` copy
28
+ * by design — the TUI runtime cannot import `src/`.
29
+ */
30
+ export declare function isFiniteNumber(v: unknown): v is number;
31
+ /**
32
+ * True for a plain JSON object (non-null `object`, not an array). Shared
33
+ * trust-boundary predicate for the two consumers that accept arbitrary JSON
34
+ * shapes: the user-editable pool file's `lastSelected` / `sessions`
35
+ * containers and session rows (`pool/store.ts`), and the incoming `system`
36
+ * prompt blocks in the Anthropic request transform
37
+ * (`providers/anthropic/transform.ts`). NOTE:
38
+ * `tui/auth-load-balancer-tui.logic.ts` ships its OWN `isPlainRecordValue`
39
+ * copy by design — the TUI runtime cannot import `src/`.
40
+ */
41
+ export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
42
+ /**
43
+ * Collect the `text` of every content block into one string. A message's
44
+ * `content` arrives as either a plain string (passed through) or an array of
45
+ * blocks (anything else → ''); each block contributes its `text` when it is a
46
+ * string, and non-empty texts are joined with `separator`. Shared by the
47
+ * session-key hasher (`session.ts`, `''` join) and the Codex instructions
48
+ * lifter (`providers/openai/transform.ts`, `'\n'` join) — previously two
49
+ * copy-pasted 15-line walkers. NOTE: `providers/anthropic/cch.ts`'s
50
+ * `messageText` is deliberately different (FIRST text block only) — not this.
51
+ */
52
+ export declare function joinBlockTexts(content: unknown, separator: string): string;
53
+ /**
54
+ * Set `key`→`value` on a bounded `Map`, clearing it first once it is already
55
+ * at `max` — a "clear-on-full" cache eviction with no LRU bookkeeping,
56
+ * appropriate for the three call sites that only need a rough memory cap, not
57
+ * exact recency: `sanitizeCache` in `providers/anthropic/transform.ts`,
58
+ * `cachedHeader` in `providers/anthropic/cch.ts`, and `lastFallbackToasted`
59
+ * in `notify.ts`. The first two were previously the same 2-line
60
+ * `if (map.size >= max) map.clear(); map.set(key, value)` block, each
61
+ * commented as duplicating the other — this is that logic unified. Each call
62
+ * site keeps its own cap constant and key/value semantics; only the eviction
63
+ * mechanics are shared.
64
+ */
65
+ export declare function setBounded<K, V>(map: Map<K, V>, key: K, value: V, max: number): void;
66
+ /**
67
+ * A single-slot memo keyed by a raw value compared with `===`. Returns a
68
+ * closure that recomputes only when the key changes since the last call —
69
+ * the "cache the value derived from a raw env/token string, keyed by that
70
+ * string" pattern previously hand-rolled four separate times across the
71
+ * Anthropic provider module (`mergeBetaHeaders`/`resolveBaseUrl` in
72
+ * `providers/anthropic/transform.ts`, `resolveFamilyOrder`/
73
+ * `resolveFallbackSetting` in `providers/anthropic/fallback.ts`). Each
74
+ * caller keeps any fast-path early return (e.g. "no raw value at all")
75
+ * BEFORE calling into this memo — the memo only covers the "check cache,
76
+ * else compute and reseat" part. NOTE: `providers/openai/jwt.ts`'s
77
+ * `resolveAccountId` needed a MULTI-slot `Map` variant instead of this
78
+ * (a one-slot memo thrashes there because the plugin decodes MULTIPLE
79
+ * OpenAI accounts' access tokens concurrently — see that file's own
80
+ * `decodedAccountIdCache` doc comment). `src/scheduler/score-core.ts`
81
+ * deliberately does NOT use this either — that file is byte-copied into
82
+ * `tui/auth-load-balancer-scoring.ts` and must stay dependency-free.
83
+ */
84
+ export declare function memoOne<K, V>(compute: (key: K) => V): (key: K) => V;
85
+ /**
86
+ * True when `candidateMs` (an absolute epoch-ms reset timestamp) sits further
87
+ * in the future than `MAX_QUOTA_RESET_BOUND_MS` past the real wall clock — used to
88
+ * reject a FINITE-but-absurd reset (e.g. a corrupted proxy emitting
89
+ * `99999999999` seconds ≈ year 5138) that would otherwise silently become the
90
+ * account's `resetAt`, permanently near-zero-ranking it in `weeklyUrgency`
91
+ * (days² in the denominator) and printing a nonsensical "115200d" countdown.
92
+ * Shared by `secondsToMs` (below) and Anthropic's `parseResetAt`
93
+ * (providers/anthropic/usage.ts), which cannot delegate to `secondsToMs`
94
+ * itself (different seconds-vs-ms-vs-ISO-string semantics) but needs the
95
+ * exact same "how far in the future is too far" guard. Reads the REAL wall
96
+ * clock (not an injected `now`) because every caller parses a response/
97
+ * endpoint payload at essentially the moment it arrives — there is no
98
+ * meaningful test-injectable `now` to thread through the provider
99
+ * `parseUsageHeaders`/endpoint-parsing call chain the way `cooldownUntilFrom`
100
+ * (fetch.ts) threads an already-captured `now`. A value in the PAST always
101
+ * passes (returns false) — an elapsed reset is simply "expired", handled
102
+ * elsewhere (`isWindowExpired`), not a broken-clock symptom.
103
+ */
104
+ export declare function isImplausiblyFarFuture(candidateMs: number): boolean;
105
+ /**
106
+ * Convert epoch-seconds → epoch-ms while rejecting non-finite/zero/negative inputs,
107
+ * the `Number.MAX_VALUE`-overflow case (`1e308` is finite but `1e308 * 1000`
108
+ * collapses to +Infinity), AND a finite-but-implausibly-far-future reset (see
109
+ * `isImplausiblyFarFuture`) — any of which would silently commit a corrupt value to
110
+ * `pool.usage.{hourly,weekly}.resetAt`, breaking `isWindowExpired`, `weeklyUrgency`,
111
+ * and `relTime` rendering. Returns 0 to signal "no usable reset". Mirrors
112
+ * `cooldownUntilFrom`'s retry-after guard in `fetch.ts` (cooldown's
113
+ * `until = now + delta` shape differs, so it is NOT folded in here). Used by both
114
+ * provider header parsers and the OpenAI endpoint helper. Anthropic's `parseResetAt`
115
+ * ms-vs-seconds heuristic (`value > 1e12`) intentionally stays separate — different
116
+ * semantics.
117
+ */
118
+ export declare function secondsToMs(resetSec: number): number;
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "opencode-auth-load-balancer",
3
+ "version": "0.2.1",
4
+ "description": "Load-balance opencode requests across multiple Anthropic (Claude) and OpenAI (Codex) OAuth accounts, weighted primarily by weekly usage with drain-before-reset scheduling.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "owjs3901",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/owjs3901/opencode-auth-load-balancer.git"
11
+ },
12
+ "homepage": "https://github.com/owjs3901/opencode-auth-load-balancer#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/owjs3901/opencode-auth-load-balancer/issues"
15
+ },
16
+ "main": "./dist/index.js",
17
+ "module": "./dist/index.js",
18
+ "types": "./dist/index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "default": "./dist/index.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "tui"
28
+ ],
29
+ "scripts": {
30
+ "sync:tui": "bun -e \"await Bun.write('tui/auth-load-balancer-scoring.ts', Bun.file('src/scheduler/score-core.ts'))\"",
31
+ "build": "bun run sync:tui && bun build ./src/index.ts --outdir dist --target node --format esm && tsc -p tsconfig.build.json",
32
+ "dev": "bun build ./src/index.ts --outdir dist --target node --format esm --watch",
33
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.tui.json --noEmit",
34
+ "typecheck:tui": "tsc -p tsconfig.tui.json --noEmit",
35
+ "lint": "oxlint",
36
+ "lint:fix": "oxlint --fix",
37
+ "test": "bun test",
38
+ "test:watch": "bun test --watch",
39
+ "status": "bun run src/cli/status.ts",
40
+ "changepacks": "bunx @changepacks/cli",
41
+ "prepare": "husky"
42
+ },
43
+ "peerDependencies": {
44
+ "@opencode-ai/plugin": ">=1.17.13"
45
+ },
46
+ "devDependencies": {
47
+ "@opencode-ai/plugin": "^1.17.13",
48
+ "@opentui/core": "0.4.2",
49
+ "@opentui/solid": "0.4.2",
50
+ "@types/bun": "^1.3.14",
51
+ "@types/node": "^26.1.0",
52
+ "eslint-plugin-devup": "^2.0.19",
53
+ "husky": "^9.1",
54
+ "oxlint": "1.72",
55
+ "solid-js": "1.9.14",
56
+ "typescript": "^6.0.3"
57
+ },
58
+ "keywords": [
59
+ "opencode",
60
+ "opencode-plugin",
61
+ "anthropic",
62
+ "claude",
63
+ "openai",
64
+ "codex",
65
+ "oauth",
66
+ "load-balancer"
67
+ ],
68
+ "engines": {
69
+ "node": ">=18",
70
+ "bun": ">=1.3"
71
+ },
72
+ "publishConfig": {
73
+ "access": "public"
74
+ }
75
+ }
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Pure account-scoring core — the SINGLE SOURCE OF TRUTH for how the load balancer
3
+ * ranks accounts. Imported by the server scheduler AND the standalone TUI view, which
4
+ * loads a byte-identical copy installed alongside it
5
+ * (`tui/auth-load-balancer-scoring.ts`, kept in sync by a build step + a test). Keeping
6
+ * the formula in one place means the dashboard's ranking can never silently drift from
7
+ * what the scheduler actually picks (which it once did, as duplicated copies).
8
+ *
9
+ * This module MUST stay dependency-free (only `process.env`) so the TUI view can load a
10
+ * single copied file without resolving the rest of `src/`.
11
+ */
12
+
13
+ /** Tunable scoring knobs (the subset of the scheduler config that affects ranking). */
14
+ export interface ScoreConfig {
15
+ /**
16
+ * How much the 5h-window headroom modulates the weekly-urgency score, in [0,1].
17
+ * 0 = ignore the 5h window; 1 = an account with no 5h headroom scores 0. Applied
18
+ * multiplicatively so it de-rates within the weekly ordering but never flips it.
19
+ */
20
+ hourlyInfluence: number
21
+ /** Floor for time-until-reset (ms). Caps how large urgency can grow near a reset. */
22
+ minResetMs: number
23
+ /** Baseline horizon (ms) used when an account's weekly reset time is unknown/stale. */
24
+ weekWindowMs: number
25
+ /**
26
+ * Hard exhaustion: utilization >= this excludes the account entirely while others
27
+ * have headroom, and forces a pinned session off it regardless of switch cost.
28
+ */
29
+ exhaustedAt: number
30
+ /**
31
+ * Soft threshold for the SHORT 5h window: proactively migrate a pinned session once
32
+ * its 5h utilization crosses this — before it hard-exhausts at `exhaustedAt`. Must be
33
+ * < exhaustedAt. (The WEEKLY window migrates at the higher `weeklyDrainTarget`.)
34
+ */
35
+ migrateAt: number
36
+ /**
37
+ * Weekly drain target (default 0.98): scoring treats weekly quota as "fully drained"
38
+ * at this utilization (the account stops being favored past it), and a pinned session
39
+ * proactively migrates once its WEEKLY util crosses it — so perishable weekly quota is
40
+ * drained close to full before the window resets. In (migrateAt, exhaustedAt].
41
+ */
42
+ weeklyDrainTarget: number
43
+ }
44
+
45
+ /** A single rate-limit window slice the scoring reads. */
46
+ export interface ScoreWindow {
47
+ utilization: number
48
+ resetAt: number
49
+ }
50
+
51
+ /**
52
+ * The slice of an account the scoring reads. The server's full `PoolAccount` and the
53
+ * TUI's parsed-pool account both satisfy this (the TUI normalizes its looser JSON into
54
+ * this shape before scoring).
55
+ */
56
+ export interface ScoreAccount {
57
+ usage: {
58
+ hourly: ScoreWindow | null
59
+ weekly: ScoreWindow | null
60
+ }
61
+ cooldownUntil: number
62
+ disabledReason: string | null
63
+ }
64
+
65
+ const DAY_MS = 24 * 60 * 60 * 1000
66
+ /**
67
+ * Weekly windows repeat on FIXED per-account anchors every 7 days (see
68
+ * usage-merge.ts). Exported so `usage-merge.ts` (an ordinary `src/` file with
69
+ * no dependency-free constraint) can import this single source instead of
70
+ * computing the same 604800000ms constant independently — `score-core.ts`
71
+ * itself still imports nothing, so the TUI's byte-identical copy stays
72
+ * dependency-free.
73
+ */
74
+ export const WEEK_MS = 7 * DAY_MS
75
+ const HOUR_MS = 60 * 60 * 1000
76
+ /** The ~5h short rolling window (Anthropic 5h ≈ Codex primary). */
77
+ const HOURLY_WINDOW_MS = 5 * HOUR_MS
78
+ /**
79
+ * ~6h cushion added to time-to-reset so an imminent reset stays urgent without the score
80
+ * exploding toward infinity at the reset instant (and so tiny crumbs near a reset don't
81
+ * dominate). In days.
82
+ */
83
+ const RESET_CUSHION_DAYS = 0.25
84
+
85
+ /** Scoring-config defaults (the scoring subset of the scheduler's DEFAULT_CONFIG). */
86
+ export const SCORE_DEFAULTS: ScoreConfig = {
87
+ hourlyInfluence: 0.5,
88
+ minResetMs: 5 * 60 * 1000, // 5 minutes
89
+ weekWindowMs: WEEK_MS, // 7 days
90
+ exhaustedAt: 0.999,
91
+ migrateAt: 0.95,
92
+ weeklyDrainTarget: 0.98,
93
+ }
94
+
95
+ /**
96
+ * Parse one numeric env override: missing/empty → default, non-finite → default.
97
+ * The SINGLE parsing contract for every numeric `OPENCODE_AUTH_LB_*` knob — shared
98
+ * by `loadScoreConfig` below and the server-only `loadConfig` in
99
+ * `src/scheduler/config.ts`, so the two loaders can never silently diverge.
100
+ */
101
+ export function readEnvNumber(
102
+ env: Record<string, string | undefined>,
103
+ key: string,
104
+ dflt: number,
105
+ ): number {
106
+ const raw = env[key]
107
+ // trim(): a whitespace-only value must fall back too — `Number(' ') === 0`
108
+ // (finite), so it would otherwise silently zero the knob instead of using
109
+ // the default. The bool parser in config.ts trims the same way.
110
+ if (raw === undefined || raw.trim() === '') return dflt
111
+ const n = Number(raw)
112
+ return Number.isFinite(n) ? n : dflt
113
+ }
114
+
115
+ /** Read scoring overrides from env (OPENCODE_AUTH_LB_*), falling back to SCORE_DEFAULTS. */
116
+ export function loadScoreConfig(
117
+ env: Record<string, string | undefined> = process.env,
118
+ ): ScoreConfig {
119
+ const num = (key: string, dflt: number): number =>
120
+ readEnvNumber(env, key, dflt)
121
+ return {
122
+ hourlyInfluence: num(
123
+ 'OPENCODE_AUTH_LB_HOURLY_INFLUENCE',
124
+ SCORE_DEFAULTS.hourlyInfluence,
125
+ ),
126
+ minResetMs: num('OPENCODE_AUTH_LB_MIN_RESET_MS', SCORE_DEFAULTS.minResetMs),
127
+ weekWindowMs: num(
128
+ 'OPENCODE_AUTH_LB_WEEK_WINDOW_MS',
129
+ SCORE_DEFAULTS.weekWindowMs,
130
+ ),
131
+ exhaustedAt: num(
132
+ 'OPENCODE_AUTH_LB_EXHAUSTED_AT',
133
+ SCORE_DEFAULTS.exhaustedAt,
134
+ ),
135
+ migrateAt: num('OPENCODE_AUTH_LB_MIGRATE_AT', SCORE_DEFAULTS.migrateAt),
136
+ weeklyDrainTarget: num(
137
+ 'OPENCODE_AUTH_LB_WEEKLY_DRAIN_TARGET',
138
+ SCORE_DEFAULTS.weeklyDrainTarget,
139
+ ),
140
+ }
141
+ }
142
+
143
+ function clamp01(n: number): number {
144
+ if (!Number.isFinite(n)) return 0
145
+ return Math.min(1, Math.max(0, n))
146
+ }
147
+
148
+ /**
149
+ * A window is EXPIRED once `now` has passed its `resetAt`: the rate-limit window it
150
+ * described has rolled over, so its stored utilization is STALE and no longer applies (a
151
+ * 5h window that read 100% an hour ago has full headroom again, even before we re-poll).
152
+ * `resetAt === 0` means the reset time is UNKNOWN — NOT expired — so an account with no
153
+ * reset metadata keeps its reported utilization until real numbers arrive (see
154
+ * `weeklyUrgency` / `scoreAccount` for how an unknown reset is then ranked).
155
+ */
156
+ function isWindowExpired(window: ScoreWindow, now: number): boolean {
157
+ return window.resetAt > 0 && window.resetAt <= now
158
+ }
159
+
160
+ /**
161
+ * Effective utilization of a window RIGHT NOW (0..1). A missing window — or one that has
162
+ * already reset (see `isWindowExpired`) — contributes 0, so a stale 100% left over from
163
+ * an elapsed window never makes an account look busy or exhausted (which would exclude it
164
+ * from selection, so it would never be picked, never re-polled, and stay "full" forever).
165
+ */
166
+ export function utilOf(window: ScoreWindow | null, now: number): number {
167
+ if (!window || isWindowExpired(window, now)) return 0
168
+ return clamp01(window.utilization)
169
+ }
170
+
171
+ /**
172
+ * Effective utilization for DISPLAY: `null` when there is no window (dashboards render
173
+ * "-"), `0` once the window has reset (the stale stored value is discarded → "0%"),
174
+ * otherwise the stored utilization. Mirrors `utilOf`'s expiry rule but keeps the
175
+ * unknown-vs-zero distinction the dashboards need ("-" for no data, "0%" for a freshly
176
+ * reset window with full headroom).
177
+ */
178
+ export function displayUtil(
179
+ window: ScoreWindow | null,
180
+ now: number,
181
+ ): number | null {
182
+ if (!window) return null
183
+ return isWindowExpired(window, now) ? 0 : clamp01(window.utilization)
184
+ }
185
+
186
+ /** The busiest window's utilization right now (whichever of 5h / weekly is higher). */
187
+ export function maxUtil(account: ScoreAccount, now: number): number {
188
+ return Math.max(
189
+ utilOf(account.usage.hourly, now),
190
+ utilOf(account.usage.weekly, now),
191
+ )
192
+ }
193
+
194
+ /** An account is exhausted when ANY still-live window is at/over the exhaustion threshold. */
195
+ export function isExhausted(
196
+ account: ScoreAccount,
197
+ cfg: ScoreConfig,
198
+ now: number,
199
+ ): boolean {
200
+ return maxUtil(account, now) >= cfg.exhaustedAt
201
+ }
202
+
203
+ /** Available = not disabled, not in cooldown, and not exhausted. */
204
+ export function isAvailable(
205
+ account: ScoreAccount,
206
+ cfg: ScoreConfig,
207
+ now: number,
208
+ ): boolean {
209
+ if (account.disabledReason) return false
210
+ if (account.cooldownUntil > now) return false
211
+ if (isExhausted(account, cfg, now)) return false
212
+ return true
213
+ }
214
+
215
+ /**
216
+ * Weekly "urgency" — how perishable an account's remaining weekly quota is. It favors
217
+ * draining the SOONEST-resetting account first (earliest-deadline-first), because unused
218
+ * weekly quota is LOST at the reset:
219
+ *
220
+ * drainable = max(0, weeklyDrainTarget - weeklyUtil) // quota left to safely burn
221
+ * urgency = drainable / (daysToReset + cushion)^2
222
+ *
223
+ * The SQUARED time term is the key: it approximates the cost of DELAYING — as a reset
224
+ * nears, the drain rate needed to avoid waste worsens nonlinearly, so a near-reset
225
+ * account outranks one that merely has more quota left but resets far away (which is what
226
+ * `remaining / time` failed to do). `drainable` caps the chase at `weeklyDrainTarget`
227
+ * (stop favoring an account near the top of its window) and the cushion prevents a blow-up
228
+ * at the reset instant.
229
+ *
230
+ * Imperfect reset metadata splits in three:
231
+ * - Window PRESENT but `resetAt === 0` (utilization known, anchor unknown — the
232
+ * post-quota-reset shape): weekly resets are FIXED per-account anchors, so
233
+ * "unknown" does NOT imply "a full window away" — it may be hours away, wasting a
234
+ * refillable budget every idle hour. Assume the reset is IMMINENT (`minResetMs`)
235
+ * so the account is ranked FIRST and serves one request ASAP; that response's
236
+ * headers reveal the real anchor and the score self-corrects immediately.
237
+ * (`drainable` still gates this: an unknown-anchor account with no headroom
238
+ * scores 0, so probing never routes to an exhausted account.)
239
+ * - Window PRESENT with an ELAPSED anchor (`resetAt` in the past — e.g. an idle
240
+ * account behind a failing usage endpoint): because the anchor is FIXED and
241
+ * repeats every 7 days, the TRUE next reset is `WEEK_MS - (elapsed % WEEK_MS)`
242
+ * away — roll it forward (same model as `rollWeeklyAnchorForward` in
243
+ * usage-merge.ts, inlined here to keep score-core dependency-free). An anchor
244
+ * that lapsed 6 days ago really resets in ~1 day and must rank accordingly,
245
+ * not as "a full window away".
246
+ * - Window MISSING entirely (never polled — utilization unknown too): keep the
247
+ * conservative full-window baseline. The free usage-endpoint poll resolves this
248
+ * case within seconds without routing any traffic to it.
249
+ */
250
+ export function weeklyUrgency(
251
+ account: ScoreAccount,
252
+ cfg: ScoreConfig,
253
+ now: number,
254
+ ): number {
255
+ // Hoist `account.usage.weekly` to a local so the body skips one chained
256
+ // property indirection per access on the per-candidate hot path (reached
257
+ // transitively via `scoreAccount` for every account in the pool — on every
258
+ // request through `selectAccount` and on every dashboard render through
259
+ // `status.ts`'s `buildStatus` — and directly from `selectForSession`'s
260
+ // drain branch).
261
+ const weekly = account.usage.weekly
262
+ const drainable = Math.max(0, cfg.weeklyDrainTarget - utilOf(weekly, now))
263
+ // At/over the weekly drain target `drainable === 0`, so `urgency = 0 / (days*days) = 0`
264
+ // regardless of the divisor — skip the resetAt/ms/days/divide chain (exact identity).
265
+ if (drainable === 0) return 0
266
+ const resetAt = weekly?.resetAt ?? 0
267
+ // See the jsdoc: a PRESENT window with an unknown anchor assumes an IMMINENT
268
+ // reset (probe-first — one request reveals the fixed anchor); an ELAPSED
269
+ // anchor rolls forward to its next 7-day occurrence; only a MISSING window
270
+ // keeps the full-window baseline.
271
+ const ms =
272
+ resetAt > now
273
+ ? resetAt - now
274
+ : weekly
275
+ ? resetAt === 0
276
+ ? cfg.minResetMs
277
+ : WEEK_MS - ((now - resetAt) % WEEK_MS)
278
+ : cfg.weekWindowMs
279
+ const days = Math.max(ms, cfg.minResetMs) / DAY_MS + RESET_CUSHION_DAYS
280
+ return drainable / (days * days)
281
+ }
282
+
283
+ /**
284
+ * Score one candidate account (higher is better):
285
+ * score = weeklyUrgency * (1 - hourlyInfluence * hourlyPressure)
286
+ *
287
+ * The 5h window is only a SAFETY de-rate (avoid a short-window hard limit), NOT a quota
288
+ * objective — and it is RESET-AWARE: `hourlyPressure = hourlyUtil * (msToReset / 5h)`, so
289
+ * an account whose 5h window is busy but resets in minutes is barely penalized (its
290
+ * short-term pressure is about to clear), letting the perishable weekly urgency drive the
291
+ * choice. An unknown 5h reset is treated as a full window (full penalty).
292
+ */
293
+ export function scoreAccount(
294
+ account: ScoreAccount,
295
+ cfg: ScoreConfig,
296
+ now: number,
297
+ ): number {
298
+ const urgency = weeklyUrgency(account, cfg, now)
299
+ // With zero weekly urgency the score is 0 no matter the 5h pressure:
300
+ // `hourlyInfluence` is finite (readEnvNumber rejects non-finite) and
301
+ // `pressure` is a product of clamp01 values, so the multiplier is always
302
+ // finite and `0 * finite === 0` — skip the whole hourly chain (exact identity).
303
+ if (urgency === 0) return 0
304
+ // Hoist `account.usage.hourly` to a local — same rationale as `weekly`
305
+ // in `weeklyUrgency` above (per-candidate, per-request hot path). Checked
306
+ // for null BEFORE `utilOf` (rather than after, via `hourly?.resetAt ?? 0`):
307
+ // `utilOf` returns 0 for a null window anyway, so this narrows `hourly` to
308
+ // non-null for the `resetAt` read below instead of leaving a provably-dead
309
+ // `?.`/`?? 0` — unlike `weeklyUrgency`'s sibling `weekly?.resetAt ?? 0`,
310
+ // where `weekly` CAN legitimately be null with a non-zero `drainable`.
311
+ const hourly = account.usage.hourly
312
+ if (!hourly) return urgency
313
+ const hourlyUtil = utilOf(hourly, now)
314
+ // With no 5h pressure `pressure === 0`, so `score = urgency * (1 - influence*0) = urgency`
315
+ // — skip the resetAt/msToReset/resetFactor/multiply chain (exact identity).
316
+ if (hourlyUtil === 0) return urgency
317
+ const resetAt = hourly.resetAt
318
+ const msToReset = resetAt > now ? resetAt - now : HOURLY_WINDOW_MS
319
+ const resetFactor = clamp01(msToReset / HOURLY_WINDOW_MS)
320
+ const pressure = hourlyUtil * resetFactor
321
+ return urgency * (1 - cfg.hourlyInfluence * pressure)
322
+ }
323
+
324
+ /**
325
+ * Sort comparator shared by `src/status.ts` and the TUI sidebar ranking pipeline, so the
326
+ * dashboard's *ordering* rule can never silently drift from what the server displays —
327
+ * the same single-source guarantee `scoreAccount` provides for the *formula*. Both
328
+ * pipelines map each account into `{ available, score, weeklyUtil }` (the per-pipeline
329
+ * `weeklyUtil` field is computed locally — `src/status.ts` uses the raw stored value to
330
+ * keep tie-breaking deterministic for cooling-down accounts, the TUI uses `utilOf` to
331
+ * discard a window that already reset) and pass that shape here:
332
+ *
333
+ * - available accounts come first (they're the only ones the scheduler will pick);
334
+ * - among available accounts, higher `score` wins (matches `selectAccount`'s pick);
335
+ * - among unavailable accounts, lower `weeklyUtil` wins — the "least bad cooling-down
336
+ * account" surfaces first so a forced fall-through stays predictable.
337
+ */
338
+ export function compareRanked<
339
+ T extends { available: boolean; score: number; weeklyUtil: number },
340
+ >(x: T, y: T): number {
341
+ if (x.available !== y.available) return x.available ? -1 : 1
342
+ if (x.available) return y.score - x.score
343
+ return x.weeklyUtil - y.weeklyUtil
344
+ }