opencode-auth-load-balancer 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@ import type { PoolAccount, UsageSnapshot } from '../../types';
6
6
  * broken body keeps the last-known snapshot instead of reading as 0% used.
7
7
  */
8
8
  export declare function parseUsages(json: unknown, now: number): UsageSnapshot | null;
9
- /** `GET {baseUrl}/usages` — free, spends no quota. Null on any failure. */
9
+ /** `GET {baseUrl}/usages`: surface 401 for OAuth recovery; other failures return null. */
10
10
  export declare function getUsages(baseUrl: string, key: string): Promise<unknown>;
11
11
  /** The adapter's `fetchUsage` for one deployment. */
12
12
  export declare function usageFetcher(baseUrl: string): (account: PoolAccount, now: number) => Promise<UsageSnapshot | null>;
@@ -8,13 +8,16 @@ import type { PoolAccount, UsageSnapshot } from '../../types';
8
8
  * NaN, and every comparison against NaN is false. The parse itself (null-short-circuit,
9
9
  * "no capturedAt", "`{}` collapses to null") lives in the shared
10
10
  * `parseWindowPairHeaders` — only the header names, the percent scale, and
11
- * this role detection are Codex-specific.
11
+ * this role detection are Codex-specific. The credits headers
12
+ * (x-codex-credits-*) ride along, even on a response without windows.
12
13
  */
13
14
  export declare function parseUsageHeaders(headers: Headers): Partial<UsageSnapshot> | null;
14
15
  /**
15
- * Poll GET /wham/usage for authoritative 5h + weekly utilization without
16
+ * Poll GET /wham/usage for authoritative 5h + weekly utilization (and the
17
+ * credits spent past them) without
16
18
  * consuming inference quota, assigning each returned window to its role BY
17
- * DURATION (see `isWeeklyMinutes`) rather than by position. Null on failure.
19
+ * DURATION (see `isWeeklyMinutes`) rather than by position. A 401 is surfaced
20
+ * for bounded OAuth recovery; other failures return null.
18
21
  * Sends the Codex CLI's `codex-cli` UA and the `chatgpt-account-id` (from the
19
22
  * stored id or, as a fallback, decoded from the access-token JWT) — required
20
23
  * for team/workspace accounts.
@@ -151,6 +151,11 @@ export interface ProviderAdapter {
151
151
  parseUsageHeaders(headers: Headers): Partial<UsageSnapshot> | null;
152
152
  /** Poll a dedicated usage endpoint for authoritative quota, if one exists. */
153
153
  fetchUsage(account: PoolAccount, now: number): Promise<UsageSnapshot | null>;
154
+ /**
155
+ * The usage endpoint reports credits (Codex): a row whose credits are still
156
+ * unknown is polled even when its windows are fresh.
157
+ */
158
+ reportsCredits?: boolean;
154
159
  /** Classify an HTTP status for rotation decisions. */
155
160
  classifyError(status: number): ErrorClass;
156
161
  /**
@@ -1,9 +1,6 @@
1
- /**
2
- * Shared HTTP shell for the plugin's read-only JSON GETs — the provider usage
3
- * endpoints (Anthropic `/usage`, OpenAI `/wham/usage`) and the npm dist-tags
4
- * lookup behind `providers/anthropic/version.ts`. GET with a timeout, null on
5
- * ANY failure — network throw/timeout, non-ok status (body cancelled so the
6
- * connection is released), or a 200 whose body is not JSON. Callers keep only
7
- * their own headers and shape guards; the transport contract lives here ONCE.
8
- */
9
1
  export declare function fetchJson<T>(url: string, headers: Record<string, string>, timeoutMs: number): Promise<T | null>;
2
+ export declare class UsageAuthenticationError extends Error {
3
+ readonly status = 401;
4
+ constructor();
5
+ }
6
+ export declare function fetchUsageJson<T>(url: string, headers: Record<string, string>, timeoutMs: number): Promise<T | null>;
@@ -45,6 +45,13 @@ export interface ScoreWindow {
45
45
  utilization: number;
46
46
  resetAt: number;
47
47
  }
48
+ /** Codex credits, which keep an account serving once its plan windows are exhausted. */
49
+ export interface ScoreCredits {
50
+ hasCredits: boolean;
51
+ unlimited: boolean;
52
+ /** The credit spending cap is hit: the balance cannot be spent. */
53
+ limitReached?: boolean;
54
+ }
48
55
  /**
49
56
  * The slice of an account the scoring reads. The server's full `PoolAccount` and the
50
57
  * TUI's parsed-pool account both satisfy this (the TUI normalizes its looser JSON into
@@ -55,6 +62,7 @@ export interface ScoreAccount {
55
62
  hourly: ScoreWindow | null;
56
63
  weekly: ScoreWindow | null;
57
64
  };
65
+ credits?: ScoreCredits;
58
66
  cooldownUntil: number;
59
67
  disabledReason: string | null;
60
68
  }
@@ -97,7 +105,15 @@ export declare function displayUtil(window: ScoreWindow | null, now: number): nu
97
105
  export declare function maxUtil(account: ScoreAccount, now: number): number;
98
106
  /** An account is exhausted when ANY still-live window is at/over the exhaustion threshold. */
99
107
  export declare function isExhausted(account: ScoreAccount, cfg: ScoreConfig, now: number): boolean;
100
- /** Available = not disabled, not in cooldown, and not exhausted. */
108
+ /**
109
+ * Whether credits keep the account serving past exhausted plan windows. Same test as
110
+ * Codex itself (`unlimited || has_credits`, the backend then bills the credits), unless
111
+ * the account's credit spending cap is hit.
112
+ */
113
+ export declare function hasCreditHeadroom(account: ScoreAccount): boolean;
114
+ /** Plan headroom, or credits to spend once the plan is exhausted. */
115
+ export declare function hasHeadroom(account: ScoreAccount, cfg: ScoreConfig, now: number): boolean;
116
+ /** Available = not disabled, not in cooldown, and with plan or credit headroom. */
101
117
  export declare function isAvailable(account: ScoreAccount, cfg: ScoreConfig, now: number): boolean;
102
118
  /**
103
119
  * Weekly "urgency" — how perishable an account's remaining weekly quota is. It favors
@@ -150,18 +166,21 @@ export declare function scoreAccount(account: ScoreAccount, cfg: ScoreConfig, no
150
166
  * Sort comparator shared by `src/status.ts` and the TUI sidebar ranking pipeline, so the
151
167
  * dashboard's *ordering* rule can never silently drift from what the server displays —
152
168
  * 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:
169
+ * pipelines map each account into `{ available, onCredits, score, weeklyUtil }` (the
170
+ * per-pipeline `weeklyUtil` field is computed locally — `src/status.ts` uses the raw
171
+ * stored value to keep tie-breaking deterministic for cooling-down accounts, the TUI uses
172
+ * `utilOf` to discard a window that already reset) and pass that shape here:
157
173
  *
158
174
  * - available accounts come first (they're the only ones the scheduler will pick);
159
- * - among available accounts, higher `score` wins (matches `selectAccount`'s pick);
175
+ * - among available accounts, plan headroom comes before serving on credits alone
176
+ * (`onCredits`: credits cost money), then higher `score` wins (matches
177
+ * `selectAccount`'s pick);
160
178
  * - among unavailable accounts, lower `weeklyUtil` wins — the "least bad cooling-down
161
179
  * account" surfaces first so a forced fall-through stays predictable.
162
180
  */
163
181
  export declare function compareRanked<T extends {
164
182
  available: boolean;
183
+ onCredits: boolean;
165
184
  score: number;
166
185
  weeklyUtil: number;
167
186
  }>(x: T, y: T): number;
@@ -13,10 +13,13 @@ export interface Selection {
13
13
  * 1. Restrict to the provider's non-disabled accounts, minus `exclude` (already
14
14
  * tried this request).
15
15
  * 2. Prefer the subset that is currently available (not exhausted / cooling down).
16
- * 3. Among those, pick the highest urgency score — but KEEP the incumbent
17
- * (`incumbentId`, the provider's current in-use account) when its score is
18
- * within `INCUMBENT_HYSTERESIS` of the best, so near-equal scores don't churn
19
- * the account and waste prompt cache.
16
+ * 3. Among those, prefer plan headroom over serving on credits alone (an
17
+ * exhausted Codex account whose credits keep it usable — credits cost money,
18
+ * plan quota is already paid for), then pick the highest urgency score — but
19
+ * KEEP the incumbent (`incumbentId`, the provider's current in-use account)
20
+ * when it is in that same group and its score is within
21
+ * `INCUMBENT_HYSTERESIS` of the best, so near-equal scores don't churn the
22
+ * account and waste prompt cache.
20
23
  * 4. If none are available, fall back to the least-bad account (lowest weekly
21
24
  * utilization) and flag the selection as `degraded`.
22
25
  *
package/dist/status.d.ts CHANGED
@@ -18,6 +18,8 @@ interface AccountStatus {
18
18
  warnings: string[];
19
19
  /** Why each lost login stopped working, printed under the table. */
20
20
  lostNotes: string[];
21
+ /** What credits leave once the plan is exhausted (`creditsText`); '' otherwise. */
22
+ credits: string;
21
23
  /** The account that most recently served a request for this provider. */
22
24
  current: boolean;
23
25
  /** 1 = next candidate the scheduler would pick. */
package/dist/types.d.ts CHANGED
@@ -9,6 +9,18 @@ export interface UsageWindow {
9
9
  utilization: number;
10
10
  resetAt: number;
11
11
  }
12
+ /**
13
+ * Codex credits: what a ChatGPT account spends once its plan's usage windows
14
+ * are exhausted. `balance` is the amount left, when the backend reports it;
15
+ * `limitReached` (`overage_limit_reached`) means the account's credit
16
+ * spending cap is hit, so the balance cannot be spent.
17
+ */
18
+ export interface UsageCredits {
19
+ hasCredits: boolean;
20
+ unlimited: boolean;
21
+ balance?: number;
22
+ limitReached?: boolean;
23
+ }
12
24
  /** Normalized usage snapshot for one account. */
13
25
  export interface UsageSnapshot {
14
26
  /** Short rolling window (~5h for both Anthropic and Codex). */
@@ -23,6 +35,11 @@ export interface UsageSnapshot {
23
35
  * snapshot fresh, or an out-of-band weekly reset is never picked up.
24
36
  */
25
37
  capturedAt: number;
38
+ /**
39
+ * Codex credits as just measured. Stored on the row (`PoolAccount.credits`),
40
+ * not here: versions before credits rebuild `usage` and would drop them.
41
+ */
42
+ credits?: UsageCredits;
26
43
  }
27
44
  export declare function emptyUsage(): UsageSnapshot;
28
45
  /** Result of an OAuth authorization-code exchange or refresh. */
@@ -142,6 +159,8 @@ export interface PoolAccount {
142
159
  */
143
160
  orgId?: string;
144
161
  usage: UsageSnapshot;
162
+ /** Codex credits, absent until the provider reports them. */
163
+ credits?: UsageCredits;
145
164
  /** epoch ms; the account is skipped until this time. 0 = no cooldown. */
146
165
  cooldownUntil: number;
147
166
  /** Why the active account-wide cooldown was written. Absent on legacy rows. */
@@ -1,4 +1,8 @@
1
- import type { UsageWindow } from './types';
1
+ import { type ScoreConfig } from './scheduler/score-core';
2
+ import type { PoolAccount, UsageWindow } from './types';
3
+ export declare const MAX_TRANSIENT_COOLDOWN_MS: number;
4
+ /** Call only after fresh usage was measured; cached headroom cannot disprove a later 429. */
5
+ export declare function reconcileQuotaCooldown(account: PoolAccount, cfg: ScoreConfig, now: number): void;
2
6
  /**
3
7
  * Roll a FIXED weekly reset anchor forward to its next occurrence strictly after
4
8
  * `now`. Weekly windows are anchored per account (verified live: Anthropic's
@@ -0,0 +1,11 @@
1
+ import { type LoopTimers } from './sync/loop';
2
+ interface UsagePollDeps {
3
+ now?: () => number;
4
+ refresh?: (now: number) => Promise<void>;
5
+ timers?: LoopTimers;
6
+ }
7
+ export declare function startUsagePoll(deps?: UsagePollDeps): {
8
+ tick: () => Promise<void>;
9
+ dispose(): Promise<void>;
10
+ };
11
+ export {};
@@ -1,18 +1,7 @@
1
1
  import type { ProviderAdapter } from './providers/types';
2
2
  import { type PoolFile } from './types';
3
3
  export declare const USAGE_REFRESH_TTL_MS: number;
4
- /**
5
- * Max lifetime of a TRANSIENT (non-quota) cooldown. fetch.ts cools a thrown
6
- * network error or a header-less 429/402 for at most `ACCOUNT_COOLDOWN_MS`
7
- * (5min) and an auth 401/403 for `AUTH_COOLDOWN_MS` (2min); ONLY a quota 429's
8
- * `Retry-After` writes a cooldown beyond this. So a cooldown whose remainder
9
- * still exceeds this bound is necessarily quota-derived — the one class that can
10
- * outlive its own usage window after an out-of-band reset (see the stale-cooldown
11
- * reconciliation below). Kept `=== ACCOUNT_COOLDOWN_MS` by a drift-guard test;
12
- * defined locally because importing it from fetch.ts (which imports THIS module)
13
- * would form a cycle.
14
- */
15
- export declare const MAX_TRANSIENT_COOLDOWN_MS: number;
4
+ export { MAX_TRANSIENT_COOLDOWN_MS } from './usage-merge';
16
5
  /**
17
6
  * Test-only window into the module-private `lastPoll` throttle map. Never
18
7
  * imported by production code — only by usage-refresh tests that need to