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,122 @@
1
+ import type { PoolAccount, TokenSet, UsageSnapshot } from '../types';
2
+ export type FetchInput = string | URL | Request;
3
+ /**
4
+ * Error classification drives rotation:
5
+ * "account" — this account is rate-limited/over quota (429/402). Cool it down, try next.
6
+ * "auth" — this account's credential is bad (401/403). Cool it down, try next.
7
+ * "service" — provider-side/transient (5xx). Return as-is.
8
+ * "ok" — success or any other status (including 4xx client errors like
9
+ * 400/404): return the response as-is.
10
+ */
11
+ export type ErrorClass = 'account' | 'auth' | 'service' | 'ok';
12
+ /**
13
+ * Shared HTTP-status → ErrorClass mapping. Both Anthropic and OpenAI adapters
14
+ * delegate to this single source of truth so a future status (e.g. 408, 425)
15
+ * being added in one provider but missed in the other cannot silently diverge
16
+ * rotation behavior between Claude and Codex.
17
+ */
18
+ export declare function classifyHttpStatus(status: number): ErrorClass;
19
+ /**
20
+ * Parse a fetch input into a URL, or null when it is not a valid absolute URL.
21
+ * Shared by both providers' `rewriteUrl` so the `FetchInput → URL` prologue
22
+ * (string/URL vs Request handling, malformed-input bail) can never drift
23
+ * between Claude and Codex — same rationale as `classifyHttpStatus` above.
24
+ */
25
+ export declare function urlFromInput(input: FetchInput): URL | null;
26
+ export interface AuthorizeRequest {
27
+ url: string;
28
+ verifier: string;
29
+ state: string;
30
+ redirectUri: string;
31
+ }
32
+ /**
33
+ * A request-body rewrite that downgrades the requested model to a cheaper
34
+ * fallback (e.g. Claude Opus → Sonnet) when the primary model's tier quota is
35
+ * exhausted on the chosen account. `body` is the rewritten JSON string to send;
36
+ * `fromModel`/`toModel` drive the user-facing switch toast/log. `fromTier` is
37
+ * the specific tier name (e.g. "opus", "fable") that triggered the downgrade,
38
+ * when the adapter can identify it — optional so providers/callers that don't
39
+ * supply it (OpenAI, or tests predating this field) stay unaffected; consumers
40
+ * that key off a tier (`notifyModelFallback`'s de-dupe window) fall back to
41
+ * their previous heuristic when it's absent.
42
+ */
43
+ export interface ModelFallback {
44
+ body: string;
45
+ fromModel: string;
46
+ toModel: string;
47
+ fromTier?: string;
48
+ }
49
+ /**
50
+ * Reactive-fallback plan returned from a rejected (429) response whose headers
51
+ * indicate a MODEL-TIER cap (not an account-wide limit). `tier` names the
52
+ * limited tier (e.g. "opus", "fable") and `resetAt` (epoch ms) is when it
53
+ * recovers — persisted as `PoolAccount.modelCooldownsUntil[tier]` so subsequent
54
+ * requests for that tier avoid the account without paying a rejected
55
+ * round-trip. `fallback` is the downgraded body the fetch loop adopts once the
56
+ * WHOLE pool proves limited for the tier.
57
+ */
58
+ export interface ReactiveModelFallback {
59
+ tier: string;
60
+ resetAt: number;
61
+ fallback: ModelFallback;
62
+ }
63
+ /**
64
+ * A provider adapter encapsulates everything provider-specific: OAuth, request
65
+ * shaping (headers/body/url transforms), usage parsing, and error classification.
66
+ * The scheduler, pool store, and load-balanced fetch are all provider-agnostic and
67
+ * drive adapters through this interface.
68
+ */
69
+ export interface ProviderAdapter {
70
+ /** opencode provider id, e.g. "anthropic" | "openai". */
71
+ readonly id: string;
72
+ /** Begin an authorization-code (PKCE) flow. */
73
+ authorize(): Promise<AuthorizeRequest>;
74
+ /** Exchange a pasted code/URL for tokens. Returns null on failure. */
75
+ exchange(input: string, verifier: string, redirectUri: string, state: string): Promise<TokenSet | null>;
76
+ /** Refresh an access token. Throws on failure (callers handle invalid_grant). */
77
+ refresh(refreshToken: string): Promise<TokenSet>;
78
+ /** Set auth + provider headers on an outgoing request for the chosen account. */
79
+ applyAuth(headers: Headers, account: PoolAccount): void;
80
+ /** Optionally rewrite the request URL (e.g. add ?beta=true, swap base URL). */
81
+ transformUrl(input: FetchInput): FetchInput;
82
+ /** Optionally rewrite a string request body (system-prompt spoof, tool prefixing). */
83
+ transformBody(body: string): string;
84
+ /** Optionally wrap the response (e.g. strip tool prefixes from the stream). */
85
+ transformResponse(response: Response): Response;
86
+ /**
87
+ * Parse usage from a normal inference response's headers (free, passive).
88
+ * Deliberately takes NO timestamp: the capture time is stamped downstream by
89
+ * `applyUsagePartial` (see the doc comments in each provider's usage.ts).
90
+ */
91
+ parseUsageHeaders(headers: Headers): Partial<UsageSnapshot> | null;
92
+ /** Poll a dedicated usage endpoint for authoritative quota, if one exists. */
93
+ fetchUsage(account: PoolAccount, now: number): Promise<UsageSnapshot | null>;
94
+ /** Classify an HTTP status for rotation decisions. */
95
+ classifyError(status: number): ErrorClass;
96
+ /**
97
+ * The model TIER `body` asks for (the `modelCooldownsUntil` key the fetch
98
+ * loop consults BEFORE sending, to steer the request onto an account with
99
+ * tier headroom), or null when the body has no recognizable tier. Absent on
100
+ * providers without per-tier caps (OpenAI) — the fetch loop treats an absent
101
+ * hook as "no tier logic".
102
+ */
103
+ requestModelTier?(body: string): string | null;
104
+ /**
105
+ * Pure body downgrade: rewrite `body`'s model ONE RUNG DOWN the fallback
106
+ * ladder — the best model of the next family below, chosen from `models`
107
+ * (the provider's catalog, e.g. opencode's configured model list) — or null
108
+ * when disabled / not downgradable. The fetch loop adopts it when EVERY
109
+ * candidate account is limited for the current body's tier — so the
110
+ * downgraded request can then reuse any of them, preferably the session's
111
+ * pinned account (keeping its prompt cache). Each call descends at least
112
+ * one family, so repeated adoption terminates.
113
+ */
114
+ planModelFallback?(body: string, models: readonly string[]): ModelFallback | null;
115
+ /**
116
+ * REACTIVE classification (post-429): when a rejected response's headers
117
+ * indicate a MODEL-TIER cap (not account-wide) that `body`'s model can fall
118
+ * down the ladder from, return the tier + its `resetAt` to persist and the
119
+ * downgraded body. null = treat the 429 as a normal account rotation.
120
+ */
121
+ planReactiveFallback?(res: Response, body: string, now: number, models: readonly string[]): ReactiveModelFallback | null;
122
+ }
@@ -0,0 +1,55 @@
1
+ import type { UsageSnapshot, UsageWindow } from '../types';
2
+ /**
3
+ * Header names + scale for one provider's hourly/weekly rate-limit header pair.
4
+ * `divisor` maps the utilization header onto the normalized 0..1 fraction:
5
+ * 1 when the header already IS a fraction (Anthropic), 100 when it is a
6
+ * percent (Codex).
7
+ */
8
+ export interface WindowPairHeaderSpec {
9
+ hourlyUtil: string;
10
+ hourlyReset: string;
11
+ weeklyUtil: string;
12
+ weeklyReset: string;
13
+ divisor: number;
14
+ }
15
+ /**
16
+ * Parse an hourly/weekly usage pair from response headers (free, on every
17
+ * response). Shared by both providers — the parse is structurally identical,
18
+ * only the header names and the utilization scale (`spec`) differ.
19
+ *
20
+ * Deliberately NO `capturedAt` here: `applyUsagePartial` (fetch.ts) stamps its
21
+ * own timestamp, and ONLY when a weekly window arrived — the staleness gate
22
+ * `refreshUsageInBackground` relies on. A parser-side stamp would be dead data
23
+ * at best and, if a future consumer trusted it, would resurrect the
24
+ * weekly-less-partial-marks-fresh bug locked by plugin.test.ts.
25
+ *
26
+ * Returns null when no utilization header is present, AND when headers were
27
+ * present but NEITHER window parsed (e.g. a malformed utilization value) — the
28
+ * contract consumers gate on (`if (partial)`) is "null = nothing usable",
29
+ * never a truthy empty `{}`.
30
+ */
31
+ export declare function parseWindowPairHeaders(headers: Headers, spec: WindowPairHeaderSpec): Partial<UsageSnapshot> | null;
32
+ /**
33
+ * Build a single `UsageWindow` from ONE raw usage-endpoint window, sharing the
34
+ * three-step contract both providers' endpoint parsers need — only reached
35
+ * AFTER the caller's own shape guard confirmed the response body is
36
+ * recognizably the usage-endpoint shape (a genuinely broken/foreign body
37
+ * never reaches here; the caller discards the whole poll instead):
38
+ *
39
+ * - `w` absent (null/undefined): the server is deliberately reporting NO
40
+ * usage recorded in that window (e.g. right after an out-of-band quota
41
+ * reset wipes the record, or an idle account past the window) — a TRUE
42
+ * 0%, not "unknown". Synthesize a zero window (`resetAt` 0 = no reset
43
+ * scheduled yet) so dashboards render "0%" instead of "-" ("-" stays
44
+ * reserved for never-polled accounts). Scoring is unaffected either way:
45
+ * `utilOf`/`weeklyUrgency` already read a missing window as 0.
46
+ * - `w` present but its utilization field is missing/non-finite: unusable —
47
+ * return `null` (NOT 0%) so the scheduler never treats a malformed
48
+ * response as "full headroom" and ranks the account first.
49
+ * - otherwise: `clamp01(util / divisor)` plus the provider's own reset
50
+ * parser (`resetOf`) — the one piece that genuinely differs between
51
+ * providers (Anthropic's ISO/epoch-seconds/epoch-ms heuristic vs OpenAI's
52
+ * plain-seconds `secondsToMs`), so it stays a caller-supplied callback
53
+ * rather than something this helper tries to unify too.
54
+ */
55
+ export declare function endpointWindowFrom<W>(w: W | null | undefined, utilOf: (w: W) => unknown, divisor: number, resetOf: (w: W) => number): UsageWindow | null;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Shared HTTP shell for the provider usage endpoints (Anthropic `/usage`,
3
+ * OpenAI `/wham/usage`): GET with a timeout, null on ANY failure — network
4
+ * throw/timeout, non-ok status (body cancelled so the connection is released),
5
+ * or a 200 whose body is not JSON. Providers keep only their own headers,
6
+ * shape guards, and window mapping; the transport contract lives here ONCE.
7
+ */
8
+ export declare function fetchUsageJson<T>(url: string, headers: Record<string, string>, timeoutMs: number): Promise<T | null>;
@@ -0,0 +1,13 @@
1
+ import type { ProviderAdapter } from './providers/types';
2
+ import type { PoolAccount } from './types';
3
+ export declare function needsRefresh(account: PoolAccount, now: number): boolean;
4
+ /**
5
+ * Ensure the account has a fresh access token, refreshing if needed. The rotated
6
+ * refresh token is persisted immediately. On a genuine invalid_grant (the current
7
+ * token was revoked) the account is marked disabled and the error rethrown; a token
8
+ * merely superseded by a concurrent refresh is adopted, not disabled.
9
+ *
10
+ * Mutates the passed `account` in place so the caller sees the new token. Concurrent
11
+ * callers that reuse the in-flight refresh have their OWN local objects updated too.
12
+ */
13
+ export declare function ensureAccessToken(adapter: ProviderAdapter, account: PoolAccount, now: number): Promise<string>;
@@ -0,0 +1,46 @@
1
+ import { type ScoreConfig } from './score-core';
2
+ /**
3
+ * Tunable parameters for the account scheduler. All overridable via env vars. Extends
4
+ * the scoring knobs (`ScoreConfig`, the single source shared with the TUI view) with the
5
+ * session/migration parameters that only the server scheduler needs.
6
+ */
7
+ export interface SchedulerConfig extends ScoreConfig {
8
+ /** Session->account assignments older than this (ms) are pruned. */
9
+ sessionTtlMs: number;
10
+ /**
11
+ * Cost gate for NON-forced switches (proactive `migrateAt` and `drainMigrate`):
12
+ * only switch when the outgoing request body is <= this many bytes, so a large
13
+ * conversation isn't re-sent onto a fresh account (which has no prompt cache and
14
+ * would burn a big chunk of its quota in one shot). <= 0 disables the gate, i.e.
15
+ * always allow the switch. Forced switches (hard exhaustion) ignore this gate;
16
+ * so does a PROACTIVE switch when the pin is within `IMMINENT_EXHAUSTION_BAND` of
17
+ * hard exhaustion (see select.ts) — a forced, still-ungated switch is coming next
18
+ * turn anyway and the re-sent conversation only grows, so moving now is cheaper.
19
+ * Drain switches always stay byte-gated.
20
+ */
21
+ cheapSwitchMaxBytes: number;
22
+ /**
23
+ * Allow migrating a still-healthy session to drain another account whose weekly
24
+ * window is about to reset (use-it-or-lose-it). Off by default — when off, a
25
+ * healthy session only ever leaves on the proactive/forced thresholds above.
26
+ */
27
+ drainMigrate: boolean;
28
+ /**
29
+ * When drainMigrate is on, the candidate account's weekly urgency must exceed the
30
+ * pinned account's by at least this factor to justify breaking session affinity.
31
+ */
32
+ drainMigrateMargin: number;
33
+ /**
34
+ * Max wall-clock time (ms) a single request may BLOCK waiting for the pool to recover
35
+ * when EVERY account is rate-limited (a 429/402 `account`-class cooldown). Rather than
36
+ * failing the turn abruptly, the load-balanced fetch sleeps until the soonest account's
37
+ * cooldown expires (honoring `Retry-After`) — bounded by this budget — then retries. A
38
+ * client abort (opencode cancelling the turn) interrupts the wait immediately. Must
39
+ * exceed `ACCOUNT_COOLDOWN_MS` (5 min) to cover a 429 that carries no `Retry-After`.
40
+ * <= 0 disables waiting (fail fast, the old behavior).
41
+ */
42
+ maxWaitMs: number;
43
+ }
44
+ export declare const DEFAULT_CONFIG: SchedulerConfig;
45
+ /** Read overrides from env (OPENCODE_AUTH_LB_*), falling back to defaults. */
46
+ export declare function loadConfig(env?: NodeJS.ProcessEnv): SchedulerConfig;
@@ -0,0 +1,167 @@
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
+ /** Tunable scoring knobs (the subset of the scheduler config that affects ranking). */
13
+ export interface ScoreConfig {
14
+ /**
15
+ * How much the 5h-window headroom modulates the weekly-urgency score, in [0,1].
16
+ * 0 = ignore the 5h window; 1 = an account with no 5h headroom scores 0. Applied
17
+ * multiplicatively so it de-rates within the weekly ordering but never flips it.
18
+ */
19
+ hourlyInfluence: number;
20
+ /** Floor for time-until-reset (ms). Caps how large urgency can grow near a reset. */
21
+ minResetMs: number;
22
+ /** Baseline horizon (ms) used when an account's weekly reset time is unknown/stale. */
23
+ weekWindowMs: number;
24
+ /**
25
+ * Hard exhaustion: utilization >= this excludes the account entirely while others
26
+ * have headroom, and forces a pinned session off it regardless of switch cost.
27
+ */
28
+ exhaustedAt: number;
29
+ /**
30
+ * Soft threshold for the SHORT 5h window: proactively migrate a pinned session once
31
+ * its 5h utilization crosses this — before it hard-exhausts at `exhaustedAt`. Must be
32
+ * < exhaustedAt. (The WEEKLY window migrates at the higher `weeklyDrainTarget`.)
33
+ */
34
+ migrateAt: number;
35
+ /**
36
+ * Weekly drain target (default 0.98): scoring treats weekly quota as "fully drained"
37
+ * at this utilization (the account stops being favored past it), and a pinned session
38
+ * proactively migrates once its WEEKLY util crosses it — so perishable weekly quota is
39
+ * drained close to full before the window resets. In (migrateAt, exhaustedAt].
40
+ */
41
+ weeklyDrainTarget: number;
42
+ }
43
+ /** A single rate-limit window slice the scoring reads. */
44
+ export interface ScoreWindow {
45
+ utilization: number;
46
+ resetAt: number;
47
+ }
48
+ /**
49
+ * The slice of an account the scoring reads. The server's full `PoolAccount` and the
50
+ * TUI's parsed-pool account both satisfy this (the TUI normalizes its looser JSON into
51
+ * this shape before scoring).
52
+ */
53
+ export interface ScoreAccount {
54
+ usage: {
55
+ hourly: ScoreWindow | null;
56
+ weekly: ScoreWindow | null;
57
+ };
58
+ cooldownUntil: number;
59
+ disabledReason: string | null;
60
+ }
61
+ /**
62
+ * Weekly windows repeat on FIXED per-account anchors every 7 days (see
63
+ * usage-merge.ts). Exported so `usage-merge.ts` (an ordinary `src/` file with
64
+ * no dependency-free constraint) can import this single source instead of
65
+ * computing the same 604800000ms constant independently — `score-core.ts`
66
+ * itself still imports nothing, so the TUI's byte-identical copy stays
67
+ * dependency-free.
68
+ */
69
+ export declare const WEEK_MS: number;
70
+ /** Scoring-config defaults (the scoring subset of the scheduler's DEFAULT_CONFIG). */
71
+ export declare const SCORE_DEFAULTS: ScoreConfig;
72
+ /**
73
+ * Parse one numeric env override: missing/empty → default, non-finite → default.
74
+ * The SINGLE parsing contract for every numeric `OPENCODE_AUTH_LB_*` knob — shared
75
+ * by `loadScoreConfig` below and the server-only `loadConfig` in
76
+ * `src/scheduler/config.ts`, so the two loaders can never silently diverge.
77
+ */
78
+ export declare function readEnvNumber(env: Record<string, string | undefined>, key: string, dflt: number): number;
79
+ /** Read scoring overrides from env (OPENCODE_AUTH_LB_*), falling back to SCORE_DEFAULTS. */
80
+ export declare function loadScoreConfig(env?: Record<string, string | undefined>): ScoreConfig;
81
+ /**
82
+ * Effective utilization of a window RIGHT NOW (0..1). A missing window — or one that has
83
+ * already reset (see `isWindowExpired`) — contributes 0, so a stale 100% left over from
84
+ * an elapsed window never makes an account look busy or exhausted (which would exclude it
85
+ * from selection, so it would never be picked, never re-polled, and stay "full" forever).
86
+ */
87
+ export declare function utilOf(window: ScoreWindow | null, now: number): number;
88
+ /**
89
+ * Effective utilization for DISPLAY: `null` when there is no window (dashboards render
90
+ * "-"), `0` once the window has reset (the stale stored value is discarded → "0%"),
91
+ * otherwise the stored utilization. Mirrors `utilOf`'s expiry rule but keeps the
92
+ * unknown-vs-zero distinction the dashboards need ("-" for no data, "0%" for a freshly
93
+ * reset window with full headroom).
94
+ */
95
+ export declare function displayUtil(window: ScoreWindow | null, now: number): number | null;
96
+ /** The busiest window's utilization right now (whichever of 5h / weekly is higher). */
97
+ export declare function maxUtil(account: ScoreAccount, now: number): number;
98
+ /** An account is exhausted when ANY still-live window is at/over the exhaustion threshold. */
99
+ export declare function isExhausted(account: ScoreAccount, cfg: ScoreConfig, now: number): boolean;
100
+ /** Available = not disabled, not in cooldown, and not exhausted. */
101
+ export declare function isAvailable(account: ScoreAccount, cfg: ScoreConfig, now: number): boolean;
102
+ /**
103
+ * Weekly "urgency" — how perishable an account's remaining weekly quota is. It favors
104
+ * draining the SOONEST-resetting account first (earliest-deadline-first), because unused
105
+ * weekly quota is LOST at the reset:
106
+ *
107
+ * drainable = max(0, weeklyDrainTarget - weeklyUtil) // quota left to safely burn
108
+ * urgency = drainable / (daysToReset + cushion)^2
109
+ *
110
+ * The SQUARED time term is the key: it approximates the cost of DELAYING — as a reset
111
+ * nears, the drain rate needed to avoid waste worsens nonlinearly, so a near-reset
112
+ * account outranks one that merely has more quota left but resets far away (which is what
113
+ * `remaining / time` failed to do). `drainable` caps the chase at `weeklyDrainTarget`
114
+ * (stop favoring an account near the top of its window) and the cushion prevents a blow-up
115
+ * at the reset instant.
116
+ *
117
+ * Imperfect reset metadata splits in three:
118
+ * - Window PRESENT but `resetAt === 0` (utilization known, anchor unknown — the
119
+ * post-quota-reset shape): weekly resets are FIXED per-account anchors, so
120
+ * "unknown" does NOT imply "a full window away" — it may be hours away, wasting a
121
+ * refillable budget every idle hour. Assume the reset is IMMINENT (`minResetMs`)
122
+ * so the account is ranked FIRST and serves one request ASAP; that response's
123
+ * headers reveal the real anchor and the score self-corrects immediately.
124
+ * (`drainable` still gates this: an unknown-anchor account with no headroom
125
+ * scores 0, so probing never routes to an exhausted account.)
126
+ * - Window PRESENT with an ELAPSED anchor (`resetAt` in the past — e.g. an idle
127
+ * account behind a failing usage endpoint): because the anchor is FIXED and
128
+ * repeats every 7 days, the TRUE next reset is `WEEK_MS - (elapsed % WEEK_MS)`
129
+ * away — roll it forward (same model as `rollWeeklyAnchorForward` in
130
+ * usage-merge.ts, inlined here to keep score-core dependency-free). An anchor
131
+ * that lapsed 6 days ago really resets in ~1 day and must rank accordingly,
132
+ * not as "a full window away".
133
+ * - Window MISSING entirely (never polled — utilization unknown too): keep the
134
+ * conservative full-window baseline. The free usage-endpoint poll resolves this
135
+ * case within seconds without routing any traffic to it.
136
+ */
137
+ export declare function weeklyUrgency(account: ScoreAccount, cfg: ScoreConfig, now: number): number;
138
+ /**
139
+ * Score one candidate account (higher is better):
140
+ * score = weeklyUrgency * (1 - hourlyInfluence * hourlyPressure)
141
+ *
142
+ * The 5h window is only a SAFETY de-rate (avoid a short-window hard limit), NOT a quota
143
+ * objective — and it is RESET-AWARE: `hourlyPressure = hourlyUtil * (msToReset / 5h)`, so
144
+ * an account whose 5h window is busy but resets in minutes is barely penalized (its
145
+ * short-term pressure is about to clear), letting the perishable weekly urgency drive the
146
+ * choice. An unknown 5h reset is treated as a full window (full penalty).
147
+ */
148
+ export declare function scoreAccount(account: ScoreAccount, cfg: ScoreConfig, now: number): number;
149
+ /**
150
+ * Sort comparator shared by `src/status.ts` and the TUI sidebar ranking pipeline, so the
151
+ * dashboard's *ordering* rule can never silently drift from what the server displays —
152
+ * the same single-source guarantee `scoreAccount` provides for the *formula*. Both
153
+ * pipelines map each account into `{ available, score, weeklyUtil }` (the per-pipeline
154
+ * `weeklyUtil` field is computed locally — `src/status.ts` uses the raw stored value to
155
+ * keep tie-breaking deterministic for cooling-down accounts, the TUI uses `utilOf` to
156
+ * discard a window that already reset) and pass that shape here:
157
+ *
158
+ * - available accounts come first (they're the only ones the scheduler will pick);
159
+ * - among available accounts, higher `score` wins (matches `selectAccount`'s pick);
160
+ * - among unavailable accounts, lower `weeklyUtil` wins — the "least bad cooling-down
161
+ * account" surfaces first so a forced fall-through stays predictable.
162
+ */
163
+ export declare function compareRanked<T extends {
164
+ available: boolean;
165
+ score: number;
166
+ weeklyUtil: number;
167
+ }>(x: T, y: T): number;
@@ -0,0 +1,41 @@
1
+ import type { PoolAccount, PoolFile } from '../types';
2
+ import { type SchedulerConfig } from './config';
3
+ export interface Selection {
4
+ account: PoolAccount;
5
+ /** true when every account was exhausted/cooling-down and we picked the least-bad one. */
6
+ degraded: boolean;
7
+ /** true when the account came from this session's existing assignment (no switch). */
8
+ sticky: boolean;
9
+ }
10
+ /**
11
+ * Pick the best account for a provider by weekly urgency.
12
+ *
13
+ * 1. Restrict to the provider's non-disabled accounts, minus `exclude` (already
14
+ * tried this request).
15
+ * 2. Prefer the subset that is currently available (not exhausted / cooling down).
16
+ * 3. Among those, pick the highest urgency score.
17
+ * 4. If none are available, fall back to the least-bad account (lowest weekly
18
+ * utilization) and flag the selection as `degraded`.
19
+ *
20
+ * Returns null when the provider has no usable account left.
21
+ */
22
+ export declare function selectAccount(accounts: PoolAccount[], providerID: string, now: number, cfg?: SchedulerConfig, exclude?: ReadonlySet<string>): Selection | null;
23
+ /**
24
+ * Session-aware selection. Balances prompt-cache stickiness against headroom safety
25
+ * and quota perishability:
26
+ *
27
+ * - No pin / pin excluded / pin unavailable (cooldown / disabled / hard-exhausted at
28
+ * `exhaustedAt`) -> FORCED switch to the best urgency pick.
29
+ * - Pin healthy but a NON-forced switch is worthwhile AND it's a cheap moment
30
+ * (small `requestBytes`):
31
+ * (a) proactive: pin crossed the soft `migrateAt` (~95%) and another account has
32
+ * more headroom -> move before slamming into the 100% wall (subagent safety);
33
+ * (b) drain (opt-in `drainMigrate`): another account's weekly reset is imminent
34
+ * and its urgency dominates the pin's by `drainMigrateMargin` -> switch to
35
+ * drain that use-it-or-lose-it quota.
36
+ * - Otherwise keep the pin (sticky), preserving its prompt cache.
37
+ *
38
+ * Forced switches ignore the cost gate; proactive/drain switches honor it so a large
39
+ * conversation isn't re-sent onto a fresh account all at once.
40
+ */
41
+ export declare function selectForSession(pool: PoolFile, providerID: string, sessionKey: string | null, now: number, cfg?: SchedulerConfig, exclude?: ReadonlySet<string>, requestBytes?: number | (() => number)): Selection | null;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Header carrying opencode's session id. Injected by the plugin's `chat.headers`
3
+ * hook (which has the real sessionID) and read back here in the load-balanced
4
+ * fetch. Stripped before the request leaves for the provider.
5
+ */
6
+ export declare const SESSION_HEADER = "x-allb-session";
7
+ /**
8
+ * Derive a stable key identifying the conversation a request belongs to.
9
+ *
10
+ * Prefers the real opencode session id (via the SESSION_HEADER). Falls back to a
11
+ * hash of the request's stable prefix (system prompt + first user message), which
12
+ * is constant across the turns of one conversation. Returns null when neither is
13
+ * available (selection then proceeds without affinity).
14
+ */
15
+ export declare function deriveSessionKey(headers: Headers, body: string | undefined): string | null;
@@ -0,0 +1,55 @@
1
+ import { type SchedulerConfig } from './scheduler/config';
2
+ import type { PoolFile } from './types';
3
+ interface AccountStatus {
4
+ id: string;
5
+ label: string;
6
+ weeklyUtil: number | null;
7
+ hourlyUtil: number | null;
8
+ weeklyResetAt: number;
9
+ available: boolean;
10
+ cooldownUntil: number;
11
+ /** tier name → epoch ms until that model tier's cap resets (empty = none). */
12
+ modelCooldownsUntil: Record<string, number>;
13
+ disabledReason: string | null;
14
+ /** The account that most recently served a request for this provider. */
15
+ current: boolean;
16
+ /** 1 = next candidate the scheduler would pick. */
17
+ rank: number;
18
+ }
19
+ interface ProviderStatus {
20
+ providerID: string;
21
+ accounts: AccountStatus[];
22
+ }
23
+ /**
24
+ * Build the ranked status model: per provider, the candidate accounts sorted in
25
+ * the order the scheduler would pick them (available accounts by urgency score,
26
+ * then unavailable ones by weekly utilization). Mirrors `selectAccount`'s ordering.
27
+ */
28
+ export declare function buildStatus(pool: PoolFile, now: number, cfg?: SchedulerConfig): ProviderStatus[];
29
+ /** Read the live pool and build its ranked status. */
30
+ export declare function readStatus(now?: number, cfg?: SchedulerConfig): Promise<ProviderStatus[]>;
31
+ /**
32
+ * Render a utilization as `"45%"` (or `"-"` for null/undefined). Shared with
33
+ * `notify.ts` so the dashboard, the `auth_lb_status` tool, and the switch
34
+ * toast cannot silently disagree on the same account's percentage rounding.
35
+ * `tui/auth-load-balancer-tui.logic.ts` keeps its own copy
36
+ * (the TUI runtime cannot import `src/`).
37
+ */
38
+ export declare function pct(u: number | null | undefined): string;
39
+ /**
40
+ * Approximate on-screen terminal width of a string, treating common East-Asian
41
+ * "wide" characters (Hangul, CJK Unified Ideographs + Extension blocks,
42
+ * Hiragana/Katakana, fullwidth forms) as 2 columns and everything else as 1.
43
+ * Account labels are user-editable free-form text (rename tool / TUI / a
44
+ * hand-edited pool file), and this project's own stated locale is `ko-KR` —
45
+ * a CJK/Hangul label is plausible, and `.length` (UTF-16 code units) undercounts
46
+ * each such character by one column, breaking `renderStatus`'s table alignment.
47
+ * Not a full Unicode-grapheme-cluster/emoji-aware implementation — a pragmatic,
48
+ * dependency-free improvement over the previous zero handling for the common
49
+ * CJK case. Degenerates to `.length` for pure-ASCII input.
50
+ */
51
+ export declare function displayWidth(s: string): number;
52
+ export declare function providerName(providerID: string): string;
53
+ /** Render the status model as a compact text dashboard (for tools/commands/CLI). */
54
+ export declare function renderStatus(providers: ProviderStatus[], now?: number): string;
55
+ export {};
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Provider-agnostic data model shared by the scheduler, pool store, and adapters.
3
+ *
4
+ * Both Anthropic and OpenAI usage is normalized into the same shape so a single
5
+ * scheduler can rank accounts regardless of provider.
6
+ */
7
+ /** A single rate-limit window. utilization in [0,1]; resetAt is epoch ms (0 = unknown). */
8
+ export interface UsageWindow {
9
+ utilization: number;
10
+ resetAt: number;
11
+ }
12
+ /** Normalized usage snapshot for one account. */
13
+ export interface UsageSnapshot {
14
+ /** Short rolling window (~5h for both Anthropic and Codex). */
15
+ hourly: UsageWindow | null;
16
+ /** Weekly (7-day rolling) window — the PRIMARY scheduling signal. */
17
+ weekly: UsageWindow | null;
18
+ /**
19
+ * epoch ms when the WEEKLY window was last captured (0 = never). Deliberately
20
+ * weekly-scoped: this is the staleness gate for the usage-endpoint re-poll
21
+ * (`refreshUsageInBackground`), and weekly is the primary signal that poll
22
+ * backfills — a response that updates only hourly must not mark the
23
+ * snapshot fresh, or an out-of-band weekly reset is never picked up.
24
+ */
25
+ capturedAt: number;
26
+ }
27
+ export declare function emptyUsage(): UsageSnapshot;
28
+ /** Result of an OAuth authorization-code exchange or refresh. */
29
+ export interface TokenSet {
30
+ access: string;
31
+ refresh: string;
32
+ /** epoch ms expiry of the access token. */
33
+ expires: number;
34
+ /** Provider account id (e.g. chatgpt-account-id), when present. */
35
+ accountId?: string;
36
+ }
37
+ /** One pooled credential. */
38
+ export interface PoolAccount {
39
+ /** Stable internal id (uuid). */
40
+ id: string;
41
+ /** opencode provider id, e.g. "anthropic" | "openai". */
42
+ providerID: string;
43
+ /** User-facing label (email / nickname). Editable in the pool file. */
44
+ label: string;
45
+ access: string;
46
+ refresh: string;
47
+ /** Access-token expiry, epoch ms. */
48
+ expires: number;
49
+ /**
50
+ * Monotonic token-rotation generation. Bumped on every successful refresh so a
51
+ * cross-process refresher that lost the single-use-token race can tell its token
52
+ * was superseded — and adopt the winner's token instead of permanently disabling
53
+ * a now-valid account. Absent on legacy pool files (read as 0).
54
+ */
55
+ tokenGen?: number;
56
+ /** Provider account id (e.g. chatgpt-account-id), or null. */
57
+ accountId: string | null;
58
+ usage: UsageSnapshot;
59
+ /** epoch ms; the account is skipped until this time. 0 = no cooldown. */
60
+ cooldownUntil: number;
61
+ /**
62
+ * Per MODEL-TIER cooldowns: tier name (e.g. "opus", "fable") → epoch ms until
63
+ * that tier's separate weekly cap resets. While an entry is `> now`, requests
64
+ * for that tier's models avoid this account (another account with tier
65
+ * headroom is preferred) and — when the WHOLE pool is tier-limited — are
66
+ * auto-downgraded to the fallback model (see
67
+ * `OPENCODE_AUTH_LB_ANTHROPIC_OPUS_FALLBACK_MODEL`) instead of cooling the
68
+ * account down: the account still serves every other model. Distinct from
69
+ * `cooldownUntil` (account-wide) and NOT a scheduling signal: scoring /
70
+ * `isAvailable` ignore it, so it never sidelines the account. Absent = no
71
+ * tier is known-exhausted. Anthropic-only; absent for OpenAI accounts.
72
+ */
73
+ modelCooldownsUntil?: Record<string, number>;
74
+ /**
75
+ * LEGACY (pre-tier-map) Opus-only cooldown. Folded into
76
+ * `modelCooldownsUntil.opus` and deleted by the pool-store normalizer on
77
+ * every read; never written anymore. Kept in the type so old pool files
78
+ * parse without a cast.
79
+ */
80
+ opusCooldownUntil?: number;
81
+ /** Non-null when the account needs manual re-login (e.g. revoked refresh token). */
82
+ disabledReason: string | null;
83
+ }
84
+ /** A conversation's sticky account assignment (preserves prompt cache across turns). */
85
+ export interface SessionAssignment {
86
+ accountId: string;
87
+ updatedAt: number;
88
+ }
89
+ export interface PoolFile {
90
+ version: 1;
91
+ accounts: PoolAccount[];
92
+ /**
93
+ * providerID -> account that most recently served a request. Drives the ▶
94
+ * "in use" marker in the status tool/CLI and the TUI bottom bar/sidebar;
95
+ * written on every fetch success and by `primeInUse` at startup. Never read
96
+ * by scheduling.
97
+ */
98
+ lastSelected: Record<string, string>;
99
+ /** sessionKey -> assignment. Keeps a conversation pinned to one account. */
100
+ sessions: Record<string, SessionAssignment>;
101
+ }
@@ -0,0 +1,23 @@
1
+ import type { UsageWindow } from './types';
2
+ /**
3
+ * Roll a FIXED weekly reset anchor forward to its next occurrence strictly after
4
+ * `now`. Weekly windows are anchored per account (verified live: Anthropic's
5
+ * promotional quota reset zeroed usage but the account's next reset stayed on
6
+ * its pre-reset anchor), so a reset time observed in the PAST still predicts
7
+ * every future reset — it repeats every 7 days. Returns 0 when there is no
8
+ * usable anchor (unknown stays unknown).
9
+ */
10
+ export declare function rollWeeklyAnchorForward(anchor: number, now: number): number;
11
+ /**
12
+ * Merge helper for the weekly window: when an incoming window LOST the reset
13
+ * time (`resetAt === 0`) but we have previously seen this account's anchor,
14
+ * keep the anchor (rolled forward past `now`) instead of downgrading to
15
+ * "unknown". This happens on every out-of-band quota reset — the usage
16
+ * endpoint reports `resets_at: null` until usage restarts, and a header
17
+ * response can carry utilization without its reset — and losing the anchor
18
+ * used to make the scheduler assume a full week of headroom for an account
19
+ * whose (fixed) reset could be hours away. Incoming utilization always wins;
20
+ * only the reset metadata is preserved. An incoming window that carries its
21
+ * own reset time is returned untouched (fresh server data beats memory).
22
+ */
23
+ export declare function preserveWeeklyAnchor(incoming: UsageWindow | null, stored: UsageWindow | null | undefined, now: number): UsageWindow | null;