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.
- package/LICENSE +21 -0
- package/README.md +289 -0
- package/dist/accounts.d.ts +20 -0
- package/dist/cli/status.d.ts +2 -0
- package/dist/fetch.d.ts +34 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +14829 -0
- package/dist/notify.d.ts +47 -0
- package/dist/pool/lock.d.ts +39 -0
- package/dist/pool/paths.d.ts +16 -0
- package/dist/pool/store.d.ts +51 -0
- package/dist/prime.d.ts +32 -0
- package/dist/providers/anthropic/adapter.d.ts +8 -0
- package/dist/providers/anthropic/cch.d.ts +20 -0
- package/dist/providers/anthropic/constants.d.ts +64 -0
- package/dist/providers/anthropic/fallback.d.ts +52 -0
- package/dist/providers/anthropic/oauth.d.ts +8 -0
- package/dist/providers/anthropic/transform.d.ts +23 -0
- package/dist/providers/anthropic/usage.d.ts +14 -0
- package/dist/providers/headers.d.ts +7 -0
- package/dist/providers/http-timeouts.d.ts +32 -0
- package/dist/providers/oauth-callback.d.ts +100 -0
- package/dist/providers/openai/adapter.d.ts +9 -0
- package/dist/providers/openai/constants.d.ts +18 -0
- package/dist/providers/openai/jwt.d.ts +5 -0
- package/dist/providers/openai/oauth.d.ts +12 -0
- package/dist/providers/openai/transform.d.ts +18 -0
- package/dist/providers/openai/usage.d.ts +16 -0
- package/dist/providers/pkce.d.ts +10 -0
- package/dist/providers/types.d.ts +122 -0
- package/dist/providers/usage-headers.d.ts +55 -0
- package/dist/providers/usage-http.d.ts +8 -0
- package/dist/refresh.d.ts +13 -0
- package/dist/scheduler/config.d.ts +46 -0
- package/dist/scheduler/score-core.d.ts +167 -0
- package/dist/scheduler/select.d.ts +41 -0
- package/dist/session.d.ts +15 -0
- package/dist/status.d.ts +55 -0
- package/dist/types.d.ts +101 -0
- package/dist/usage-merge.d.ts +23 -0
- package/dist/usage-refresh.d.ts +30 -0
- package/dist/util.d.ts +118 -0
- package/package.json +75 -0
- package/tui/auth-load-balancer-scoring.ts +344 -0
- package/tui/auth-load-balancer-tui.logic.ts +362 -0
- package/tui/auth-load-balancer-tui.ts +39 -0
- 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
|
+
}
|