opencode-auth-load-balancer 0.2.2 → 0.4.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.
package/dist/notify.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ProviderRecovery } from './pending/recovery';
1
2
  import type { PoolAccount } from './types';
2
3
  /** The slice of the opencode SDK client we need to show a toast. */
3
4
  export interface ToastClient {
@@ -45,3 +46,10 @@ export declare function notifyOnSwitch(client: ToastClient, providerID: string,
45
46
  * toast never affects the request.
46
47
  */
47
48
  export declare function notifyModelFallback(client: ToastClient, providerID: string, account: PoolAccount, fromModel: string, toModel: string, fromTier?: string): Promise<void>;
49
+ /** Announce a durable provider wait. Coordinator-level signatures dedupe it. */
50
+ export declare function notifyPending(client: ToastClient, providerID: string, recovery: Extract<ProviderRecovery, {
51
+ state: 'quota-blocked';
52
+ }>, now?: number): Promise<void>;
53
+ export declare function notifyPendingResumed(client: ToastClient, providerID: string): Promise<void>;
54
+ export declare function notifyPendingRestored(client: ToastClient, providerID: string, count: number): Promise<void>;
55
+ export declare function notifyPendingRestoreError(client: ToastClient, providerID: string, messageID: string): Promise<void>;
@@ -0,0 +1,135 @@
1
+ import type { LockHandle } from '../pool/lock';
2
+ import type { ProviderAdapter } from '../providers/types';
3
+ import type { SchedulerConfig } from '../scheduler/config';
4
+ import type { PoolFile } from '../types';
5
+ import { type ProviderRecovery } from './recovery';
6
+ import type { PendingRef, PendingSchedule, PendingTurn } from './types';
7
+ export type CapacityWaitResult = 'available' | 'unusable';
8
+ interface RestoreUserMessage {
9
+ id: string;
10
+ sessionID: string;
11
+ role: 'user';
12
+ agent: string;
13
+ model: {
14
+ providerID: string;
15
+ modelID: string;
16
+ };
17
+ system?: string;
18
+ tools?: Record<string, boolean>;
19
+ }
20
+ interface RestoreAssistantMessage {
21
+ id: string;
22
+ sessionID: string;
23
+ role: 'assistant';
24
+ parentID: string;
25
+ finish?: string;
26
+ error?: unknown;
27
+ }
28
+ type RestoreMessage = RestoreUserMessage | RestoreAssistantMessage;
29
+ interface RestoreResult<T> {
30
+ data?: T;
31
+ error?: unknown;
32
+ response?: {
33
+ status: number;
34
+ };
35
+ }
36
+ interface RestoreReadOptions {
37
+ path: {
38
+ id: string;
39
+ messageID: string;
40
+ };
41
+ query: {
42
+ directory: string;
43
+ };
44
+ }
45
+ interface RestoreSessionOptions {
46
+ path: {
47
+ id: string;
48
+ };
49
+ query: {
50
+ directory: string;
51
+ };
52
+ }
53
+ interface RestorePromptOptions extends RestoreSessionOptions {
54
+ body: {
55
+ messageID: string;
56
+ model: {
57
+ providerID: string;
58
+ modelID: string;
59
+ };
60
+ agent: string;
61
+ system?: string;
62
+ tools?: Record<string, boolean>;
63
+ parts: [];
64
+ };
65
+ }
66
+ export interface PendingRestoreClient {
67
+ session: {
68
+ message(options: RestoreReadOptions): Promise<RestoreResult<{
69
+ info: RestoreMessage;
70
+ parts: unknown[];
71
+ }>>;
72
+ messages(options: RestoreSessionOptions): Promise<RestoreResult<{
73
+ info: RestoreMessage;
74
+ parts: unknown[];
75
+ }[]>>;
76
+ prompt(options: RestorePromptOptions): Promise<RestoreResult<unknown>>;
77
+ };
78
+ }
79
+ export interface PendingCoordinatorDependencies {
80
+ now(): number;
81
+ sleep(ms: number, signal?: AbortSignal): Promise<void>;
82
+ readPool(): Promise<PoolFile>;
83
+ refreshUsage(now: number): Promise<void>;
84
+ upsert(ref: PendingRef, schedule: PendingSchedule): Promise<PendingTurn>;
85
+ remove(ref: PendingRef | string): Promise<boolean>;
86
+ acquireLease(key: string): Promise<LockHandle>;
87
+ list(workspace: string, providerID?: string): Promise<PendingTurn[]>;
88
+ removeSession(workspace: string, sessionID: string): Promise<number>;
89
+ onPending(ref: PendingRef, recovery: Extract<ProviderRecovery, {
90
+ state: 'quota-blocked';
91
+ }>): Promise<void> | void;
92
+ onResumed(ref: PendingRef): Promise<void> | void;
93
+ onRestored(count: number): Promise<void> | void;
94
+ onRestoreError(ref: PendingRef, error: unknown): Promise<void> | void;
95
+ }
96
+ export interface PendingCoordinatorOptions {
97
+ workspace: string;
98
+ adapter: ProviderAdapter;
99
+ config: SchedulerConfig;
100
+ dependencies?: Partial<PendingCoordinatorDependencies>;
101
+ }
102
+ export declare class PendingCoordinator {
103
+ readonly workspace: string;
104
+ readonly providerID: string;
105
+ private readonly adapter;
106
+ private readonly config;
107
+ private readonly dependencies;
108
+ private readonly leases;
109
+ private readonly acquiring;
110
+ private readonly active;
111
+ private readonly restoring;
112
+ private readonly pendingToasts;
113
+ private disposing;
114
+ constructor(options: PendingCoordinatorOptions);
115
+ private ensureLease;
116
+ private releaseLease;
117
+ private notifyPending;
118
+ private clear;
119
+ waitForCapacity(ref: PendingRef, initial: Extract<ProviderRecovery, {
120
+ state: 'quota-blocked';
121
+ }>, outerSignal?: AbortSignal): Promise<CapacityWaitResult>;
122
+ /** Remove a completed/terminal turn and release any execution lease it owns. */
123
+ complete(ref: PendingRef): Promise<void>;
124
+ /** Explicit user cancellation removes durable state; shutdown preserves it. */
125
+ abort(ref: PendingRef): Promise<void>;
126
+ private restoreOnce;
127
+ private restoreWithRetry;
128
+ /** Restore this workspace/provider's durable references independently. */
129
+ restore(client: PendingRestoreClient): Promise<void>;
130
+ /** Remove all pending providers for a deleted session in this workspace. */
131
+ removeSession(sessionID: string): Promise<number>;
132
+ /** Mark shutdown before aborting sleepers so their abort path preserves rows. */
133
+ dispose(): Promise<void>;
134
+ }
135
+ export {};
@@ -0,0 +1,3 @@
1
+ import { type LockHandle, type LockOptions } from '../pool/lock';
2
+ export declare function pendingLeasePath(key: string): string;
3
+ export declare function acquirePendingLease(key: string, options?: LockOptions): Promise<LockHandle>;
@@ -0,0 +1,16 @@
1
+ import type { ScoreConfig } from '../scheduler/score-core';
2
+ import type { PoolAccount } from '../types';
3
+ export type ProviderRecovery = {
4
+ state: 'available';
5
+ } | {
6
+ state: 'quota-blocked';
7
+ nextCheckAt: number;
8
+ resumeAt: number | null;
9
+ } | {
10
+ state: 'unusable';
11
+ };
12
+ /**
13
+ * Determine whether a provider can serve now, is provably blocked only by
14
+ * account-wide quota, or has no account that waiting for quota can repair.
15
+ */
16
+ export declare function classifyProviderRecovery(accounts: readonly PoolAccount[], providerID: string, cfg: Pick<ScoreConfig, 'exhaustedAt'>, now: number, pollMs: number): ProviderRecovery;
@@ -0,0 +1,12 @@
1
+ import type { PendingFile, PendingRef, PendingSchedule, PendingTurn } from './types';
2
+ export declare class PendingReadError extends Error {
3
+ readonly path: string;
4
+ readonly reason: unknown;
5
+ constructor(path: string, reason: unknown);
6
+ }
7
+ export declare function pendingKey(ref: PendingRef): string;
8
+ export declare function readPending(): Promise<PendingFile>;
9
+ export declare function upsertPending(ref: PendingRef, schedule: PendingSchedule): Promise<PendingTurn>;
10
+ export declare function removePending(refOrKey: PendingRef | string): Promise<boolean>;
11
+ export declare function removePendingSession(workspace: string, sessionID: string): Promise<number>;
12
+ export declare function listPendingForWorkspace(workspace: string, providerID?: string): Promise<PendingTurn[]>;
@@ -0,0 +1,23 @@
1
+ export interface PendingRef {
2
+ workspace: string;
3
+ providerID: string;
4
+ sessionID: string;
5
+ messageID: string;
6
+ }
7
+ /** Durable scheduling metadata. Prompt content remains owned by OpenCode. */
8
+ export interface PendingTurn extends PendingRef {
9
+ key: string;
10
+ createdAt: number;
11
+ updatedAt: number;
12
+ nextCheckAt: number;
13
+ resumeAt: number | null;
14
+ }
15
+ export interface PendingFile {
16
+ version: 1;
17
+ turns: PendingTurn[];
18
+ }
19
+ export interface PendingSchedule {
20
+ now: number;
21
+ nextCheckAt: number;
22
+ resumeAt: number | null;
23
+ }
@@ -25,7 +25,7 @@ export interface LockOptions {
25
25
  /** Re-touch the held lock this often so a live holder never looks stale. */
26
26
  readonly heartbeatMs: number;
27
27
  }
28
- interface LockHandle {
28
+ export interface LockHandle {
29
29
  release(): Promise<void>;
30
30
  }
31
31
  export declare class LockTimeoutError extends Error {
@@ -36,4 +36,3 @@ export declare class LockTimeoutError extends Error {
36
36
  export declare function acquireLock(lockDir: string, opts: LockOptions): Promise<LockHandle>;
37
37
  /** Run `fn` while holding `lockDir`, releasing it even if `fn` throws. */
38
38
  export declare function withLock<T>(lockDir: string, opts: LockOptions, fn: () => Promise<T>): Promise<T>;
39
- export {};
@@ -13,4 +13,15 @@ interface DataDirEnv {
13
13
  export declare function resolveDataDir(env: DataDirEnv, home: string): string;
14
14
  /** Path to the load-balancer's credential pool file. */
15
15
  export declare function poolFilePath(): string;
16
+ /** Path to the reference-only durable pending-turn file. */
17
+ export declare function pendingFilePath(): string;
18
+ /**
19
+ * Path to the cached Claude Code version discovered from the npm registry
20
+ * (see `providers/anthropic/version.ts`). A THIRD file rather than a field in
21
+ * the pool: the pool's every read-modify-write takes a cross-process lock, and
22
+ * this cache is a best-effort, provider-owned scratch value that must never
23
+ * contend with — let alone fail — a credential/usage write. Same data dir and
24
+ * same env-keyed memo as the two files above.
25
+ */
26
+ export declare function versionCacheFilePath(): string;
16
27
  export {};
@@ -35,10 +35,15 @@ export declare const DEFAULT_FAMILY_ORDER: readonly string[];
35
35
  /** 429 header naming which rate-limit window is the binding constraint. */
36
36
  export declare const REPRESENTATIVE_CLAIM_HEADER = "anthropic-ratelimit-unified-representative-claim";
37
37
  /**
38
- * `representative-claim` values that name a MODEL-TIER window (a per-model
39
- * weekly/5h cap, e.g. `seven_day_opus`, `seven_day_fable`) rather than an
40
- * account-wide one (bare `five_hour` / `seven_day`). The capture group is the
41
- * tier name — the key of `PoolAccount.modelCooldownsUntil`.
38
+ * `representative-claim` values that name a TIER-scoped window (a per-model
39
+ * weekly/5h cap OR the premium/overage bucket — e.g. `seven_day_opus`,
40
+ * `seven_day_fable`, `seven_day_overage_included`) rather than a bare
41
+ * account-wide one (`five_hour` / `seven_day`). Used ONLY as a GATE by
42
+ * `planReactiveFallback`; the captured suffix is NOT the cooldown key. Anthropic
43
+ * emits non-model-family suffixes (`overage_included`) that no request's model
44
+ * family ever equals, so keying on the suffix records a dead
45
+ * `modelCooldownsUntil` entry the proactive skip can never consult — the cooldown
46
+ * is keyed by the REQUEST's model family instead (see `planReactiveFallback`).
42
47
  */
43
48
  export declare const MODEL_TIER_CLAIM_RE: RegExp;
44
49
  /** 429 header with the unix-seconds reset time of the binding window. */
@@ -48,15 +53,55 @@ export declare const OPENCODE_IDENTITY_PREFIX = "You are OpenCode";
48
53
  export declare const CLAUDE_CODE_IDENTITY = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
49
54
  export declare const CCH_SALT = "59cf53e54c78";
50
55
  export declare const CCH_POSITIONS: number[];
51
- export declare const CLAUDE_CODE_VERSION = "2.1.87";
52
56
  export declare const CLAUDE_CODE_ENTRYPOINT = "sdk-cli";
53
- /** User-Agent for /v1/messages (matches the reference plugin's validated value). */
54
- export declare const USER_AGENT = "claude-cli/2.1.87 (external, cli)";
55
57
  /**
56
- * User-Agent for the /api/oauth/usage endpoint. This endpoint hard-rejects
57
- * (429) requests whose UA is not `claude-code/<version>`.
58
+ * Version used when nothing better is known — the floor the resolver in
59
+ * `version.ts` never goes below.
60
+ *
61
+ * Anthropic GATES NEW MODELS ON THE CLIENT VERSION we report (in the
62
+ * `claude-cli/<version>` UA and the `cc_version=` billing header): requesting a
63
+ * too-new model with a too-old version is rejected outright with
64
+ * `{"error_code":"claude_code_version_too_old"}` — e.g. `claude-fable-5-1`
65
+ * requires ≥ 2.1.251. A hard-pinned constant therefore BREAKS on every future
66
+ * model launch, which is why the live value is resolved from the npm registry
67
+ * (`version.ts`) and this is only the offline floor.
68
+ *
69
+ * Verified against the real API: `claude-fable-5-1` returns 400
70
+ * (`claude_code_version_too_old`) at `2.1.87` and 200 at this value, using the
71
+ * SAME `CCH_SALT`/`CCH_POSITIONS` above — the fingerprint algorithm did not
72
+ * change across that range, so bumping the version string alone is sufficient.
73
+ * Keep this at a version confirmed to work; the resolver only ever moves UP
74
+ * from here.
58
75
  */
59
- export declare const USAGE_USER_AGENT = "claude-code/2.1.87";
76
+ export declare const FALLBACK_CLAUDE_CODE_VERSION = "2.1.258";
77
+ /**
78
+ * Pin the reported Claude Code version explicitly, bypassing BOTH the npm
79
+ * lookup and the `FALLBACK_CLAUDE_CODE_VERSION` floor — the escape hatch for
80
+ * the two directions the resolver cannot guess: pinning FORWARD to a version
81
+ * npm has not tagged `latest` yet, or pinning BACK (e.g. to reproduce this
82
+ * `claude_code_version_too_old` failure, or if a future release changes the
83
+ * `cch` fingerprint algorithm and the newest version starts 400ing).
84
+ */
85
+ export declare const CLAUDE_CODE_VERSION_ENV = "OPENCODE_AUTH_LB_ANTHROPIC_CLAUDE_CODE_VERSION";
86
+ /**
87
+ * npm dist-tags for the Claude Code CLI — `{"latest":"2.1.258",…}`, ~56 bytes.
88
+ * Deliberately NOT `/@anthropic-ai/claude-code/latest`, which returns the whole
89
+ * 3.3 KB version packument for the one field we read.
90
+ */
91
+ export declare const CLAUDE_CODE_REGISTRY_URL = "https://registry.npmjs.org/-/package/@anthropic-ai%2Fclaude-code/dist-tags";
92
+ /**
93
+ * How long a registry answer stays authoritative on disk. Claude Code ships
94
+ * roughly daily, and a stale-by-a-day version only matters on the day a new
95
+ * model launches — so a day-long TTL keeps startup network-free almost always
96
+ * while still converging well inside the window that matters.
97
+ */
98
+ export declare const CLAUDE_CODE_VERSION_TTL_MS: number;
99
+ /**
100
+ * Bound the registry lookup. Shorter than the 30 s OAuth/usage budgets because
101
+ * nothing waits on this: it runs fire-and-forget at startup and a miss simply
102
+ * leaves the previous (cached or fallback) version in place.
103
+ */
104
+ export declare const REGISTRY_HTTP_TIMEOUT_MS = 10000;
60
105
  export declare const PARAGRAPH_REMOVAL_ANCHORS: string[];
61
106
  export declare const TEXT_REPLACEMENTS: {
62
107
  match: string;
@@ -40,7 +40,9 @@ export declare function resolveFallbackSetting(): FallbackSetting;
40
40
  * on a cheap condition (a stored tier cooldown or a response header) BEFORE
41
41
  * this parse.
42
42
  */
43
- export declare function downgradeModel(body: string, models: readonly string[]): ModelFallback | null;
43
+ export declare function downgradeModel(body: string, models: readonly string[]): (ModelFallback & {
44
+ fromTier: string;
45
+ }) | null;
44
46
  /**
45
47
  * REACTIVE: on a rejected response, when the binding limit is a MODEL-TIER cap
46
48
  * (`representative-claim: seven_day_<tier>` / `five_hour_<tier>`) and the sent
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The version to report right now. Reads the env override on every call (like
3
+ * `resolveBaseUrl`/`poolFilePath` do for theirs) so a pin takes effect without
4
+ * a restart.
5
+ */
6
+ export declare function claudeCodeVersion(): string;
7
+ /** User-Agent for /v1/messages. */
8
+ export declare function userAgent(): string;
9
+ /**
10
+ * User-Agent for the /api/oauth/usage endpoint. That endpoint hard-rejects
11
+ * (429) requests whose UA is not `claude-code/<version>` — note the different
12
+ * product token and the absence of the ` (external, cli)` suffix.
13
+ */
14
+ export declare function usageUserAgent(): string;
15
+ /**
16
+ * Ask npm for the newest Claude Code, adopt it if newer, and persist the
17
+ * result. Best-effort throughout: a registry outage, a shape change, or an
18
+ * unwritable data dir all leave the in-memory version untouched and simply
19
+ * defer the next attempt to the next startup (the cache is written only on a
20
+ * usable answer, so a failure does NOT refresh the TTL).
21
+ */
22
+ export declare function refreshClaudeCodeVersion(now: number): Promise<void>;
23
+ /**
24
+ * Startup hook. Awaits only the LOCAL cache read — cheap, and enough to have
25
+ * the right version in hand before the loader returns the fetch — then leaves
26
+ * any registry round-trip running in the background so neither opencode's
27
+ * startup nor the first request ever blocks on npm.
28
+ */
29
+ export declare function primeClaudeCodeVersion(now: number): Promise<void>;
@@ -1,16 +1,22 @@
1
1
  import type { PoolAccount, UsageSnapshot } from '../../types';
2
2
  /**
3
3
  * Parse usage from Codex response headers (x-codex-{primary,secondary}-*).
4
- * primary = ~5h window (hourly), secondary = weekly window. The parse itself
5
- * (null-short-circuit, "no capturedAt", "`{}` collapses to null") lives in the
6
- * shared `parseWindowPairHeaders` — only the header names and the percent
7
- * scale are Codex-specific.
4
+ * The historical default is primary = ~5h and secondary = weekly; when the
5
+ * primary-window-minutes header identifies a seven-day primary, swap the pair
6
+ * before using the shared parser (see `isWeeklyMinutes`). An absent/
7
+ * unparsable duration header keeps the historical default — `Number(null)` is
8
+ * NaN, and every comparison against NaN is false. The parse itself (null-short-circuit,
9
+ * "no capturedAt", "`{}` collapses to null") lives in the shared
10
+ * `parseWindowPairHeaders` — only the header names, the percent scale, and
11
+ * this role detection are Codex-specific.
8
12
  */
9
13
  export declare function parseUsageHeaders(headers: Headers): Partial<UsageSnapshot> | null;
10
14
  /**
11
- * Poll GET /wham/usage for authoritative 5h (primary) + weekly (secondary)
12
- * utilization without consuming inference quota. Null on failure. Sends the Codex
13
- * CLI's `codex-cli` UA and the `chatgpt-account-id` (from the stored id or, as a
14
- * fallback, decoded from the access-token JWT) — required for team/workspace accounts.
15
+ * Poll GET /wham/usage for authoritative 5h + weekly utilization without
16
+ * consuming inference quota, assigning each returned window to its role BY
17
+ * DURATION (see `isWeeklyMinutes`) rather than by position. Null on failure.
18
+ * Sends the Codex CLI's `codex-cli` UA and the `chatgpt-account-id` (from the
19
+ * stored id or, as a fallback, decoded from the access-token JWT) — required
20
+ * for team/workspace accounts.
15
21
  */
16
22
  export declare function fetchUsage(account: PoolAccount, now: number): Promise<UsageSnapshot | null>;
@@ -0,0 +1,26 @@
1
+ import type { ProviderAdapter } from './types';
2
+ /**
3
+ * Every provider adapter this plugin ships, in one place.
4
+ *
5
+ * The per-provider plugin exports in `index.ts` each own exactly ONE adapter
6
+ * (opencode registers an auth hook per provider), but the provider-agnostic
7
+ * surfaces — the usage-endpoint refresh and the `auth_lb_status` dashboard —
8
+ * must reach ALL of them. Without a shared registry those call sites
9
+ * hand-listed the pair and drifted: the request path only ever refreshed the
10
+ * adapter whose fetch it belonged to, so a provider you did not actively send
11
+ * requests to (e.g. Codex while you work in Claude) had NO refresh cycle at
12
+ * all after its startup seed and its usage froze for the whole opencode
13
+ * process lifetime. Adding a third provider now means editing this array only.
14
+ *
15
+ * `readonly` so a consumer cannot mutate the shared list; iteration order is
16
+ * irrelevant (each account is matched to its own adapter by `providerID`).
17
+ */
18
+ export declare const ADAPTERS: readonly ProviderAdapter[];
19
+ /**
20
+ * The adapter that owns `providerID`, or undefined for a pool row whose
21
+ * provider this build does not know (a hand-edited file, or a row left behind
22
+ * by a newer version). A linear scan, not a Map: the registry has a handful of
23
+ * entries and this runs per account on the request hot path, where building a
24
+ * Map per call would cost more than the scan it replaces.
25
+ */
26
+ export declare function adapterFor(adapters: readonly ProviderAdapter[], providerID: string): ProviderAdapter | undefined;
@@ -48,12 +48,16 @@ export interface ModelFallback {
48
48
  }
49
49
  /**
50
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.
51
+ * indicate a MODEL-TIER cap (not an account-wide limit). `tier` is the REQUEST's
52
+ * own model family (e.g. "opus", "fable") — the exact key the fetch loop's
53
+ * proactive skip reads as `requestModelTier(body)` — NOT the raw claim suffix
54
+ * (Anthropic emits non-family suffixes like `overage_included` that no request
55
+ * ever maps to; keying on them recorded a dead `modelCooldownsUntil` entry the
56
+ * skip could never consult). `resetAt` (epoch ms) is when the tier recovers —
57
+ * persisted as `PoolAccount.modelCooldownsUntil[tier]` so subsequent requests
58
+ * for that tier avoid the account without paying a rejected round-trip.
59
+ * `fallback` is the downgraded body the fetch loop adopts once the WHOLE pool
60
+ * proves limited for the tier.
57
61
  */
58
62
  export interface ReactiveModelFallback {
59
63
  tier: string;
@@ -93,6 +97,19 @@ export interface ProviderAdapter {
93
97
  fetchUsage(account: PoolAccount, now: number): Promise<UsageSnapshot | null>;
94
98
  /** Classify an HTTP status for rotation decisions. */
95
99
  classifyError(status: number): ErrorClass;
100
+ /**
101
+ * One-time startup warm-up, awaited by the auth loader BEFORE it returns the
102
+ * load-balanced fetch — so anything a request's shaping depends on is in
103
+ * place before the first request is served. Anthropic uses it to resolve the
104
+ * Claude Code version it must claim (Anthropic gates new models on it; see
105
+ * `providers/anthropic/version.ts`); OpenAI leaves it unset, and the loader
106
+ * treats an absent hook as "nothing to warm up".
107
+ *
108
+ * MUST stay cheap and MUST NOT throw — it sits on opencode's startup path.
109
+ * Implementations do local I/O only and push anything network-bound into the
110
+ * background themselves.
111
+ */
112
+ prime?(now: number): Promise<void>;
96
113
  /**
97
114
  * The model TIER `body` asks for (the `modelCooldownsUntil` key the fetch
98
115
  * loop consults BEFORE sending, to steer the request onto an account with
@@ -1,8 +1,9 @@
1
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.
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.
7
8
  */
8
- export declare function fetchUsageJson<T>(url: string, headers: Record<string, string>, timeoutMs: number): Promise<T | null>;
9
+ export declare function fetchJson<T>(url: string, headers: Record<string, string>, timeoutMs: number): Promise<T | null>;
@@ -13,13 +13,16 @@ 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.
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.
17
20
  * 4. If none are available, fall back to the least-bad account (lowest weekly
18
21
  * utilization) and flag the selection as `degraded`.
19
22
  *
20
23
  * Returns null when the provider has no usable account left.
21
24
  */
22
- export declare function selectAccount(accounts: PoolAccount[], providerID: string, now: number, cfg?: SchedulerConfig, exclude?: ReadonlySet<string>): Selection | null;
25
+ export declare function selectAccount(accounts: PoolAccount[], providerID: string, now: number, cfg?: SchedulerConfig, exclude?: ReadonlySet<string>, incumbentId?: string | null): Selection | null;
23
26
  /**
24
27
  * Session-aware selection. Balances prompt-cache stickiness against headroom safety
25
28
  * and quota perishability:
package/dist/session.d.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  * fetch. Stripped before the request leaves for the provider.
5
5
  */
6
6
  export declare const SESSION_HEADER = "x-allb-session";
7
+ /** OpenCode user-message id paired with SESSION_HEADER for durable turn identity. */
8
+ export declare const MESSAGE_HEADER = "x-allb-message";
7
9
  /**
8
10
  * Derive a stable key identifying the conversation a request belongs to.
9
11
  *
package/dist/status.d.ts CHANGED
@@ -1,5 +1,6 @@
1
+ import type { PendingTurn } from './pending/types';
1
2
  import { type SchedulerConfig } from './scheduler/config';
2
- import type { PoolFile } from './types';
3
+ import { type PoolFile } from './types';
3
4
  interface AccountStatus {
4
5
  id: string;
5
6
  label: string;
@@ -50,6 +51,8 @@ export declare function pct(u: number | null | undefined): string;
50
51
  */
51
52
  export declare function displayWidth(s: string): number;
52
53
  export declare function providerName(providerID: string): string;
54
+ /** Compact workspace-scoped pending summary appended by `auth_lb_status`. */
55
+ export declare function renderPendingStatus(turns: readonly PendingTurn[], now?: number): string;
53
56
  /** Render the status model as a compact text dashboard (for tools/commands/CLI). */
54
57
  export declare function renderStatus(providers: ProviderStatus[], now?: number): string;
55
58
  export {};
package/dist/types.d.ts CHANGED
@@ -35,6 +35,7 @@ export interface TokenSet {
35
35
  accountId?: string;
36
36
  }
37
37
  /** One pooled credential. */
38
+ export type CooldownKind = 'quota' | 'auth' | 'transient';
38
39
  export interface PoolAccount {
39
40
  /** Stable internal id (uuid). */
40
41
  id: string;
@@ -58,6 +59,8 @@ export interface PoolAccount {
58
59
  usage: UsageSnapshot;
59
60
  /** epoch ms; the account is skipped until this time. 0 = no cooldown. */
60
61
  cooldownUntil: number;
62
+ /** Why the active account-wide cooldown was written. Absent on legacy rows. */
63
+ cooldownKind?: CooldownKind;
61
64
  /**
62
65
  * Per MODEL-TIER cooldowns: tier name (e.g. "opus", "fable") → epoch ms until
63
66
  * that tier's separate weekly cap resets. While an entry is `> now`, requests
@@ -78,17 +81,71 @@ export interface PoolAccount {
78
81
  * parse without a cast.
79
82
  */
80
83
  opusCooldownUntil?: number;
81
- /** Non-null when the account needs manual re-login (e.g. revoked refresh token). */
84
+ /**
85
+ * Non-null when the scheduler skips this account. Two sources, both gated by
86
+ * `isAvailable`: the automatic `invalid_grant: re-login required (…)` reason
87
+ * written on a revoked refresh token (`src/refresh.ts`), and the manual
88
+ * `MANUAL_DISABLED_REASON` sentinel written by the disable action (TUI menu /
89
+ * `auth_lb_disable` tool). The dashboards distinguish the two to render
90
+ * `disabled` (a user turned it off) vs `re-login` (needs a fresh OAuth login).
91
+ */
82
92
  disabledReason: string | null;
83
93
  }
94
+ /**
95
+ * Sentinel `disabledReason` written by the MANUAL disable action (the TUI
96
+ * sidebar menu and the `auth_lb_disable` tool), distinct from the automatic
97
+ * `invalid_grant: re-login required (…)` reason `src/refresh.ts` writes when a
98
+ * refresh token is revoked. Both are non-null so `isAvailable` skips the
99
+ * account; the value is kept distinct only so the dashboards can render
100
+ * `disabled` (a user turned it off) rather than `re-login` (needs a fresh login).
101
+ */
102
+ export declare const MANUAL_DISABLED_REASON = "manually disabled";
103
+ /**
104
+ * Re-login intents expire after ten minutes. A bounded lifetime prevents an
105
+ * abandoned TUI OAuth flow from redirecting a later, unrelated provider login
106
+ * onto the account row that happened to be clicked earlier.
107
+ */
108
+ export declare const RELOGIN_TTL_MS = 600000;
109
+ export interface ReloginIntent {
110
+ /** `PoolAccount.id` the freshly exchanged tokens must land on. */
111
+ accountId: string;
112
+ /** Provider the re-login was started for; an intent for another provider is ignored. */
113
+ providerID: string;
114
+ /** epoch ms after which the intent is stale and dropped at the read boundary. */
115
+ expiresAt: number;
116
+ }
84
117
  /** A conversation's sticky account assignment (preserves prompt cache across turns). */
85
118
  export interface SessionAssignment {
86
119
  accountId: string;
87
120
  updatedAt: number;
121
+ /**
122
+ * Set when THIS session's most recent successful request was served on an
123
+ * auto-downgraded fallback model (the requested model tier's weekly cap is
124
+ * exhausted pool-wide, so the request descended the fallback ladder). Read by
125
+ * the TUI bottom bar to PERSISTENTLY surface the degrade — the `onModelFallback`
126
+ * toast is transient and gone by the next turn, yet the session keeps running
127
+ * on the fallback model. `from`/`to` are the raw model ids (original requested
128
+ * model → served model), matching the toast wording. The whole session row is
129
+ * overwritten on every success, so the next request served on the requested
130
+ * model naturally clears this back to `undefined`.
131
+ */
132
+ fallback?: {
133
+ from: string;
134
+ to: string;
135
+ };
88
136
  }
89
137
  export interface PoolFile {
90
138
  version: 1;
91
139
  accounts: PoolAccount[];
140
+ /**
141
+ * Short-lived TUI → server handshake consumed by the first `addAccount` for
142
+ * this provider. It identifies the clicked pool row when a provider rotates
143
+ * refresh tokens and exposes no stable account id (Anthropic); without it a
144
+ * completed re-login appends a duplicate while leaving the revoked row
145
+ * behind. Absent in the steady state so `JSON.stringify` keeps the pool file
146
+ * compact.
147
+ */
148
+ relogin?: ReloginIntent;
92
149
  /**
93
150
  * providerID -> account that most recently served a request. Drives the ▶
94
151
  * "in use" marker in the status tool/CLI and the TUI bottom bar/sidebar;