opencode-auth-load-balancer 0.8.1 → 0.9.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.
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The shared version of a row's OAuth login. Gist sync orders the copies of one
3
+ * login that different machines hold by it, so every change of the access +
4
+ * refresh pair (login, refresh, minted rotation) bumps it in the same pool
5
+ * transaction as the credential.
6
+ */
7
+ import type { PoolAccount } from './types';
8
+ /** Record that `row`'s login pair just changed. */
9
+ export declare function bumpLoginRev(row: PoolAccount, now?: number): void;
10
+ /** The version of a row's login; a row that never recorded one is revision 1, at time 0. */
11
+ export declare function loginVersion(row: PoolAccount): {
12
+ rev: number;
13
+ at: number;
14
+ };
@@ -17,8 +17,8 @@ export declare function sendUsageProbe(access: string): Promise<Response>;
17
17
  export declare function probeSnapshot(headers: Headers, now: number): UsageSnapshot | null;
18
18
  /**
19
19
  * Poll the dedicated usage endpoint for authoritative 5h + 7d utilization without
20
- * consuming inference quota. Returns null on any failure — including a 200 whose
21
- * body is not the usage shape — so the caller keeps the last-known snapshot.
20
+ * consuming inference quota. A 401 is surfaced for bounded OAuth recovery;
21
+ * other failures, including an invalid 200 body, keep the last-known snapshot.
22
22
  *
23
23
  * A row without a refresh token is a `claude setup-token` bearer, scoped to
24
24
  * `user:inference` alone; the endpoint demands `user:profile` and answers it
@@ -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>;
@@ -14,7 +14,8 @@ export declare function parseUsageHeaders(headers: Headers): Partial<UsageSnapsh
14
14
  /**
15
15
  * Poll GET /wham/usage for authoritative 5h + weekly utilization without
16
16
  * consuming inference quota, assigning each returned window to its role BY
17
- * DURATION (see `isWeeklyMinutes`) rather than by position. Null on failure.
17
+ * DURATION (see `isWeeklyMinutes`) rather than by position. A 401 is surfaced
18
+ * for bounded OAuth recovery; other failures return null.
18
19
  * Sends the Codex CLI's `codex-cli` UA and the `chatgpt-account-id` (from the
19
20
  * stored id or, as a fallback, decoded from the access-token JWT) — required
20
21
  * for team/workspace accounts.
@@ -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>;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Hooks gist sync plugs into the refresh path. A leaf module on purpose:
3
+ * `refresh.ts` stays free of GitHub and crypto code, and sync can register
4
+ * without an import cycle. No hooks are set unless sync registers some.
5
+ */
6
+ import type { PoolAccount } from './types';
7
+ export interface RefreshHooks {
8
+ /**
9
+ * Called inside the account's refresh lock, before the provider is asked:
10
+ * resolves true when it put a newer shared login on the row (re-read it).
11
+ * `recovery` is set once the provider refused the refresh token.
12
+ */
13
+ pull?: (account: PoolAccount, recovery: boolean) => Promise<boolean>;
14
+ /** Called outside the lock after a refresh this call performed. */
15
+ afterRefresh?: () => void;
16
+ }
17
+ export declare function setRefreshHooks(hooks: RefreshHooks | null): void;
18
+ export declare function getRefreshHooks(): RefreshHooks | null;
@@ -23,6 +23,8 @@ export type Landing = {
23
23
  blocker: string;
24
24
  holds: string;
25
25
  };
26
+ /** `label`, or `label (n)` when another row already carries it (labels are unique pool-wide). */
27
+ export declare function uniqueLabel(pool: PoolFile, label: string): string;
26
28
  /**
27
29
  * Land one verified credential, deciding everything from the pool as it is
28
30
  * under the lock (callers run this inside `mutatePool`):
@@ -11,6 +11,8 @@ export interface CycleReport {
11
11
  }
12
12
  /** After GitHub refuses the account's write, nothing is attempted for this long (downloads go on). */
13
13
  export declare const WRITE_BACKOFF_MS = 3600000;
14
+ /** "Uploaded 2 credentials and 3 logins." (the logins only when there are some). */
15
+ export declare function uploadedMessage(entries: number, logins: number): string;
14
16
  /**
15
17
  * Run one cycle. `force` is "Upload now": it needs a GitHub token (and says so
16
18
  * if there is none), reads the gist afresh, and tries to write even inside the
@@ -0,0 +1,2 @@
1
+ /** On unless `OPENCODE_AUTH_LB_SYNC` is `0` / `false` / `no` / `off`. */
2
+ export declare function syncEnabled(env?: NodeJS.ProcessEnv): boolean;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The sync side of the refresh hooks: before a machine spends a login's
3
+ * single-use refresh token it looks at the gist, because another machine may
4
+ * have refreshed that login already.
5
+ *
6
+ * Runs inside the caller's refresh lock, so it never takes that lock again and
7
+ * never takes the cycle lock or writes the sync state (the cycle takes refresh
8
+ * locks while holding the cycle lock; the other order could deadlock).
9
+ */
10
+ import type { PoolAccount } from '../types';
11
+ import { type GhRunner } from './github-auth';
12
+ /** The hook `refresh.ts` calls; `runGh` finds the GitHub token (a test replaces it). */
13
+ export declare function createPullLogin(runGh?: GhRunner): (account: PoolAccount, _recovery: boolean) => Promise<boolean>;
14
+ export declare const pullLogin: (account: PoolAccount, _recovery: boolean) => Promise<boolean>;
@@ -0,0 +1,40 @@
1
+ export declare const MAX_LOGINS = 64;
2
+ /** A set, not an object: a provider id from a gist must never resolve to an inherited member such as `constructor`. */
3
+ export declare const LOGIN_PROVIDERS: ReadonlySet<string>;
4
+ /** One shared login: the newest `(rev, at, refresh fingerprint)` of a key wins on every machine. */
5
+ export interface LoginRecord {
6
+ /** `${providerID}:${accountId}`: the identity of the login across machines. */
7
+ key: string;
8
+ providerID: string;
9
+ label: string;
10
+ access: string;
11
+ refresh: string;
12
+ /** epoch ms the access token lapses. */
13
+ expires: number;
14
+ accountId: string;
15
+ orgId?: string;
16
+ /** epoch ms the login itself lapses, when known. */
17
+ refreshExpires?: number;
18
+ rev: number;
19
+ at: number;
20
+ }
21
+ export declare function loginKey(providerID: string, accountId: string): string;
22
+ export declare function parseLogin(raw: unknown): LoginRecord | null;
23
+ /** The logins in a stable order (by key), so equal lists produce equal payloads. */
24
+ export declare function sortLogins(logins: readonly LoginRecord[]): LoginRecord[];
25
+ /** A digest of the logins: when it changes, the gist is out of date. */
26
+ export declare function loginsDigest(logins: readonly LoginRecord[]): string;
27
+ /**
28
+ * The total order two machines share to pick the winner among copies of one
29
+ * login: revision, then time, then the refresh token's fingerprint (so even a
30
+ * tie of both is settled the same way everywhere). Positive when `a` is newer.
31
+ */
32
+ export declare function compareLogins(a: Pick<LoginRecord, 'at' | 'refresh' | 'rev'>, b: Pick<LoginRecord, 'at' | 'refresh' | 'rev'>): number;
33
+ /** What a payload's login list yields: the valid logins, every key listed, and how many could not be read. */
34
+ export interface ParsedLogins {
35
+ logins: LoginRecord[];
36
+ loginsListed: Set<string>;
37
+ loginsUnreadable: number;
38
+ }
39
+ /** Parse a raw login list. A duplicate key keeps the first valid login; an unreadable one is counted, never imported. */
40
+ export declare function parseLoginList(rawList: readonly unknown[]): ParsedLogins;
@@ -0,0 +1,63 @@
1
+ import { type PoolAccount, type PoolFile } from '../types';
2
+ import { type LoginRecord } from './login-payload';
3
+ import { type SyncEntry } from './payload';
4
+ /** On unless `OPENCODE_AUTH_LB_SYNC_LOGINS` is `0` / `false` / `no` / `off`. */
5
+ export declare function loginsSyncEnabled(env?: NodeJS.ProcessEnv): boolean;
6
+ /** The login keys the pool's rows hold. */
7
+ export declare function heldMarks(accounts: readonly PoolAccount[]): Record<string, true>;
8
+ /**
9
+ * This machine's shareable logins, one per key: a real OAuth login (a refresh
10
+ * token, not a Kimi API key or a setup-token), of a known account, that still
11
+ * works. Usage, cooldowns, sessions, `tokenGen`, the inference token and local
12
+ * ids never go in. Every login passes the receiver's own validation.
13
+ */
14
+ export declare function ownLogins(pool: PoolFile): LoginRecord[];
15
+ /**
16
+ * The logins to write: per key the winner of the gist's and this machine's
17
+ * copy. A gist login this machine has no copy of is carried over untouched; a
18
+ * login of this machine the gist lacks is added while there is room.
19
+ */
20
+ export declare function mergeLogins(gist: readonly LoginRecord[], own: readonly LoginRecord[]): LoginRecord[];
21
+ /** How many of this machine's logins are, as they are, what `next` lists. */
22
+ export declare function ownCopies(next: readonly LoginRecord[], own: readonly LoginRecord[]): number;
23
+ /**
24
+ * The digest of what this machine would publish: when it differs from the one
25
+ * stored at the last upload, the gist may be out of date. Without logins it is
26
+ * the entries' digest alone, so a machine that shares none keeps its old one.
27
+ */
28
+ export declare function publishDigest(entries: readonly SyncEntry[], logins: readonly LoginRecord[]): string;
29
+ /** Whether `row` is this machine's copy of `login`: the same key, and the same organization when both know it. */
30
+ export declare function sameLogin(row: PoolAccount, login: LoginRecord): boolean;
31
+ /** The version a row holds, comparable with a login's. */
32
+ export declare function versionOf(row: PoolAccount): Pick<LoginRecord, 'at' | 'refresh' | 'rev'>;
33
+ /**
34
+ * Put the gist's login onto the row that holds the same login, in one pool
35
+ * transaction that re-checks the row's (refresh, tokenGen) as
36
+ ow was read:
37
+ * a row that moved on in the meantime is left to the next cycle. The bumped
38
+ * okenGen makes a refresh that is in flight lose its CAS. The row keeps its
39
+ * inference token, usage, cooldowns, sessions and label. The caller holds the
40
+ * row's refresh lock (the lock is not re-entrant).
41
+ */
42
+ export declare function overwriteLocked(row: PoolAccount, login: LoginRecord): Promise<boolean>;
43
+ /** What sync remembers about deleted rows, in and out of `applyLogins`. */
44
+ export interface LoginMarks {
45
+ known: Readonly<Record<string, true>> | undefined;
46
+ suppressed: Readonly<Record<string, true>> | undefined;
47
+ }
48
+ export interface LoginsApplied {
49
+ /** Rows the gist's logins were put onto, or created. */
50
+ adopted: number;
51
+ known: Record<string, true>;
52
+ suppressed: Record<string, true>;
53
+ }
54
+ /**
55
+ * Bring the pool's logins in line with the gist's. First the deletion guard: a
56
+ * key that was held and has no row any more was deleted by the user, so it is
57
+ * suppressed and never imported; a suppressed key with a row again was logged
58
+ * in to again, so it is released (and outranks the gist's copy). Then every
59
+ * gist login that wins over this machine's copy is put onto the pool. A login
60
+ * that is not newer is ignored: this machine's copy is then the winner, and
61
+ * is written back.
62
+ */
63
+ export declare function applyLogins(gist: readonly LoginRecord[], marks: LoginMarks, now: number): Promise<LoginsApplied>;
@@ -1,6 +1,8 @@
1
- export declare const PAYLOAD_VERSION = 2;
1
+ import { type LoginRecord } from './login-payload';
2
+ import { MAX_LABEL, printable } from './text';
3
+ export { MAX_LABEL, printable };
4
+ export declare const PAYLOAD_VERSION = 3;
2
5
  export declare const MAX_ENTRIES = 64;
3
- export declare const MAX_LABEL = 80;
4
6
  /** A machine's origin: 128 random bits as hex. Not a secret, and never derived from a host name. */
5
7
  export declare const ORIGIN_RE: RegExp;
6
8
  /** The providers a credential may sync for, and the shape its secret must have. */
@@ -21,16 +23,14 @@ export interface SyncEntry {
21
23
  }
22
24
  /** The identity of an entry across machines. */
23
25
  export declare function entryKey(entry: Pick<SyncEntry, 'origin' | 'id'>): string;
24
- /** `text` with those characters turned into spaces, trimmed. */
25
- export declare function printable(text: string): string;
26
26
  export declare function parseEntry(raw: unknown): SyncEntry | null;
27
27
  /** The entries in a stable order (by key), so equal lists produce equal payloads. */
28
28
  export declare function sortEntries(entries: readonly SyncEntry[]): SyncEntry[];
29
29
  /** A digest of the entries: when it changes, the gist is out of date. */
30
30
  export declare function entriesDigest(entries: readonly SyncEntry[]): string;
31
- /** The plaintext to encrypt. */
32
- export declare function buildPayload(entries: readonly SyncEntry[], at: number): string;
33
- /** A snapshot read back: when it was written, its valid entries, and every key it still lists. */
31
+ /** The plaintext to encrypt: always the current version, entries and logins each in a stable order. */
32
+ export declare function buildPayload(entries: readonly SyncEntry[], at: number, logins?: readonly LoginRecord[]): string;
33
+ /** A snapshot read back: when it was written, its valid entries and logins, and every key it still lists. */
34
34
  export interface ParsedPayload {
35
35
  at: number;
36
36
  entries: SyncEntry[];
@@ -41,6 +41,12 @@ export interface ParsedPayload {
41
41
  listed: ReadonlySet<string>;
42
42
  /** How many entries could not be read at all: a writer must not rewrite what it cannot parse. */
43
43
  unreadable: number;
44
+ /** The valid logins (none in a version 2 snapshot). */
45
+ logins: LoginRecord[];
46
+ /** Keys of every listed login, valid or not. */
47
+ loginsListed: ReadonlySet<string>;
48
+ /** How many logins could not be read at all: as for entries, a writer must not rewrite them. */
49
+ loginsUnreadable: number;
44
50
  }
45
51
  /**
46
52
  * The snapshot in a decrypted payload. Unknown fields, unknown providers,
@@ -1,5 +1,23 @@
1
+ import { syncEnabled } from './enabled';
2
+ import { type SyncEngine } from './engine';
1
3
  import { type SyncLoop } from './loop';
2
- /** On unless `OPENCODE_AUTH_LB_SYNC` is `0` / `false` / `no` / `off`. */
3
- export declare function syncEnabled(env?: NodeJS.ProcessEnv): boolean;
4
- /** Start the gist-sync schedule for this process; null when sync is switched off. */
4
+ export { syncEnabled };
5
+ /**
6
+ * Start the gist-sync schedule for this process; null when sync is switched
7
+ * off. With login sharing on it also plugs into the refresh path: a refresh is
8
+ * published at once, and a refresh about to happen first looks at the gist.
9
+ */
5
10
  export declare function startSync(): SyncLoop | null;
11
+ export interface LoginPublishOptions {
12
+ engine?: SyncEngine;
13
+ timeoutMs?: number;
14
+ }
15
+ /**
16
+ * Publish a login that just landed, in the same breath: one sync cycle, so the
17
+ * machines that follow the gist get it without waiting for the schedule (the
18
+ * process may be a login command that exits right after). A no-op unless sync
19
+ * and login sharing are on and this machine follows a gist. Bounded, and it
20
+ * swallows every failure: a login never waits long for, or fails because of,
21
+ * its upload.
22
+ */
23
+ export declare function publishAfterLogin(options?: LoginPublishOptions): Promise<void>;
@@ -61,6 +61,10 @@ export interface SyncState {
61
61
  imported: Record<string, ImportedRef>;
62
62
  /** Entry key to why it was not imported. */
63
63
  skipped: Record<string, SkippedRef>;
64
+ /** Login keys this machine held a row for at the last pass: a key that has no row any more was deleted by the user. */
65
+ loginKnown?: Record<string, true>;
66
+ /** Login keys the user deleted here: never imported again until a row for the key exists once more. */
67
+ loginSuppressed?: Record<string, true>;
64
68
  }
65
69
  /**
66
70
  * A key-indexed map. Keys come from another machine, so the record has no
@@ -69,6 +73,8 @@ export interface SyncState {
69
73
  */
70
74
  export declare function newMap<T>(from?: Readonly<Record<string, T>>): Record<string, T>;
71
75
  export declare function newRefs(from?: Readonly<Record<string, ImportedRef>>): Record<string, ImportedRef>;
76
+ /** A map of marks, or undefined when it holds none: a state without logins stays as compact as it was. */
77
+ export declare function marks(from: Readonly<Record<string, true>> | undefined): Record<string, true> | undefined;
72
78
  export declare function readSyncState(): Promise<SyncState | null>;
73
79
  /**
74
80
  * Read-modify-write the state under a cross-process lock. `fn` returns the
@@ -0,0 +1,5 @@
1
+ /** `text` with those characters turned into spaces, trimmed. */
2
+ export declare function printable(text: string): string;
3
+ export declare function boundedString(value: unknown, max: number): string | undefined;
4
+ /** The longest label a synced credential or login may carry. */
5
+ export declare const MAX_LABEL = 80;
@@ -1,6 +1,15 @@
1
1
  import type { ProviderAdapter } from './providers/types';
2
2
  import type { PoolAccount, PoolFile } from './types';
3
- /** On unless `OPENCODE_AUTH_LB_ANTHROPIC_AUTO_TOKEN` is `0` / `false` / `no` / `off`. */
3
+ /**
4
+ * Off unless `OPENCODE_AUTH_LB_ANTHROPIC_AUTO_TOKEN` is `1` / `true` / `yes` /
5
+ * `on`. Verified against Anthropic's token endpoint: a refresh grant for
6
+ * `scope=user:inference&expires_in=31536000` is answered with an 8-hour token
7
+ * (the lifetime is ignored), and the refresh token it rotates to is narrowed
8
+ * to `user:inference` for good, so the login can no longer read its usage
9
+ * (`403 … user:profile`) and a full-scope refresh is refused (`invalid_scope`).
10
+ * The token is then too short to pool, so the grant only does harm. A
11
+ * long-lived token comes from a pasted `claude setup-token`.
12
+ */
4
13
  export declare function autoTokenEnabled(env?: NodeJS.ProcessEnv): boolean;
5
14
  /** A row whose OAuth login should mint it a token: it has none, or its minted one nears its end. */
6
15
  export declare function tokenDue(account: PoolAccount, now: number): boolean;
package/dist/types.d.ts CHANGED
@@ -124,6 +124,16 @@ export interface PoolAccount {
124
124
  * days, as Claude Code does.
125
125
  */
126
126
  refreshExpires?: number;
127
+ /**
128
+ * The SHARED version of the row's OAuth login (gist sync orders machines'
129
+ * copies of one login by it): 1 when the login was created, plus one for
130
+ * every re-login, committed refresh rotation and minted rotation (see
131
+ * `src/login-rev.ts`). Absent on rows without an OAuth login and on legacy
132
+ * rows (read as 1).
133
+ */
134
+ loginRev?: number;
135
+ /** epoch ms `loginRev` was last bumped (a tie-break between equal revisions). Absent reads as 0. */
136
+ loginAt?: number;
127
137
  /**
128
138
  * Organization the row's credentials act for (Anthropic), when known. A
129
139
  * setup-token carries no account identity — only the organization its
@@ -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