opencode-auth-load-balancer 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 {};
@@ -53,15 +53,82 @@ export declare const OPENCODE_IDENTITY_PREFIX = "You are OpenCode";
53
53
  export declare const CLAUDE_CODE_IDENTITY = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
54
54
  export declare const CCH_SALT = "59cf53e54c78";
55
55
  export declare const CCH_POSITIONS: number[];
56
- export declare const CLAUDE_CODE_VERSION = "2.1.87";
57
56
  export declare const CLAUDE_CODE_ENTRYPOINT = "sdk-cli";
58
- /** User-Agent for /v1/messages (matches the reference plugin's validated value). */
59
- export declare const USER_AGENT = "claude-cli/2.1.87 (external, cli)";
60
57
  /**
61
- * User-Agent for the /api/oauth/usage endpoint. This endpoint hard-rejects
62
- * (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.
63
75
  */
64
- 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
+ * The structured error code Anthropic answers a version-gated model with:
88
+ *
89
+ * ```json
90
+ * {"type":"error","error":{"type":"invalid_request_error",
91
+ * "message":"Claude Code 2.1.278 does not support this model; version 2.1.280 or newer is required.",
92
+ * "details":{"error_code":"claude_code_version_too_old"}}}
93
+ * ```
94
+ *
95
+ * Matched as a SUBSTRING of the raw body rather than through a parsed path
96
+ * (`error.details.error_code`): the envelope's shape is Anthropic's to change,
97
+ * and this string appearing anywhere in a rejection is both sufficient — no
98
+ * other error carries it — and immune to a reshuffle that would silently break
99
+ * a path lookup. See `recoverClaudeCodeVersion` in version.ts.
100
+ */
101
+ export declare const CLAUDE_CODE_VERSION_GATE_CODE = "claude_code_version_too_old";
102
+ /**
103
+ * The MINIMUM version that rejection's human-readable message demands.
104
+ *
105
+ * Anchored on the `or newer is required` tail rather than the first version in
106
+ * the string, because the message names TWO: the one we just sent ("Claude
107
+ * Code 2.1.278 does not support this model") and the one being asked for
108
+ * ("version 2.1.280 or newer is required"). Capturing the leading one would
109
+ * adopt the version that just failed — a no-op the recovery would correctly
110
+ * refuse to retry, i.e. a silent regression to the old stuck behavior.
111
+ */
112
+ export declare const CLAUDE_CODE_REQUIRED_VERSION_RE: RegExp;
113
+ /**
114
+ * npm dist-tags for the Claude Code CLI — `{"latest":"2.1.258",…}`, ~56 bytes.
115
+ * Deliberately NOT `/@anthropic-ai/claude-code/latest`, which returns the whole
116
+ * 3.3 KB version packument for the one field we read.
117
+ */
118
+ export declare const CLAUDE_CODE_REGISTRY_URL = "https://registry.npmjs.org/-/package/@anthropic-ai%2Fclaude-code/dist-tags";
119
+ /**
120
+ * How long a registry answer stays authoritative on disk. Claude Code ships
121
+ * roughly daily, and a stale-by-a-day version only matters on the day a new
122
+ * model launches — so a day-long TTL keeps startup network-free almost always
123
+ * while still converging well inside the window that matters.
124
+ */
125
+ export declare const CLAUDE_CODE_VERSION_TTL_MS: number;
126
+ /**
127
+ * Bound the registry lookup. Shorter than the 30 s OAuth/usage budgets because
128
+ * nothing waits on this: it runs fire-and-forget at startup and a miss simply
129
+ * leaves the previous (cached or fallback) version in place.
130
+ */
131
+ export declare const REGISTRY_HTTP_TIMEOUT_MS = 10000;
65
132
  export declare const PARAGRAPH_REMOVAL_ANCHORS: string[];
66
133
  export declare const TEXT_REPLACEMENTS: {
67
134
  match: string;
@@ -0,0 +1,50 @@
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
+ * REACTIVE recovery, driven by the gate's own rejection.
25
+ *
26
+ * `primeClaudeCodeVersion` resolves once at startup and then trusts the cache
27
+ * for `CLAUDE_CODE_VERSION_TTL_MS`. A model that launches INSIDE that window is
28
+ * therefore rejected for as long as the TTL has left to run — up to a full day
29
+ * of hard failures — even when npm already tags the version Anthropic is asking
30
+ * for, and even though the rejection itself NAMES that version. No polling
31
+ * cadence fixes this in general either: Anthropic can gate a model before npm
32
+ * tags the release, which is why the env pin exists as a manual escape hatch.
33
+ *
34
+ * So learn from the rejection instead. Adopt the minimum it demands, then ask
35
+ * npm for the real `latest` in the same breath — the minimum only clears the
36
+ * model that just failed, whereas `latest` is what the NEXT launch will want.
37
+ *
38
+ * Returns whether the reported version actually MOVED, which is precisely the
39
+ * condition under which a retry can succeed; the caller spends a second
40
+ * round-trip on true and passes the rejection through untouched on false.
41
+ * `res` is consumed, so callers that still need the body must pass a clone.
42
+ */
43
+ export declare function recoverClaudeCodeVersion(res: Response, now: number): Promise<boolean>;
44
+ /**
45
+ * Startup hook. Awaits only the LOCAL cache read — cheap, and enough to have
46
+ * the right version in hand before the loader returns the fetch — then leaves
47
+ * any registry round-trip running in the background so neither opencode's
48
+ * startup nor the first request ever blocks on npm.
49
+ */
50
+ export declare function primeClaudeCodeVersion(now: number): Promise<void>;
@@ -97,6 +97,19 @@ export interface ProviderAdapter {
97
97
  fetchUsage(account: PoolAccount, now: number): Promise<UsageSnapshot | null>;
98
98
  /** Classify an HTTP status for rotation decisions. */
99
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>;
100
113
  /**
101
114
  * The model TIER `body` asks for (the `modelCooldownsUntil` key the fetch
102
115
  * loop consults BEFORE sending, to steer the request onto an account with
@@ -123,4 +136,24 @@ export interface ProviderAdapter {
123
136
  * downgraded body. null = treat the 429 as a normal account rotation.
124
137
  */
125
138
  planReactiveFallback?(res: Response, body: string, now: number, models: readonly string[]): ReactiveModelFallback | null;
139
+ /**
140
+ * REACTIVE recovery from a CLIENT-VERSION gate: a provider may reject an
141
+ * otherwise-valid request until the plugin claims to be a newer client
142
+ * (Anthropic answers a too-new model with 400 `claude_code_version_too_old`,
143
+ * naming the minimum it wants). Such a status classifies as `ok` — the fetch
144
+ * loop is one statement away from handing it to the caller — so this is the
145
+ * last point at which it can become a recoverable event instead of a failed
146
+ * turn.
147
+ *
148
+ * Return true ONLY when the version the adapter will now report actually
149
+ * changed: the fetch loop reads that as "a retry can succeed" and restarts
150
+ * the request once. Returning true without a change would buy a second,
151
+ * byte-identical round-trip and the same rejection.
152
+ *
153
+ * `res` is a CLONE the implementation may consume freely; the original is
154
+ * still returned to the caller when this resolves false. Absent on providers
155
+ * with no such gate (OpenAI) — the fetch loop treats that as "nothing to
156
+ * recover".
157
+ */
158
+ recoverClientVersion?(res: Response, now: number): Promise<boolean>;
126
159
  }
@@ -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>;
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,3 +1,4 @@
1
+ import type { PendingTurn } from './pending/types';
1
2
  import { type SchedulerConfig } from './scheduler/config';
2
3
  import { type PoolFile } from './types';
3
4
  interface AccountStatus {
@@ -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
@@ -1,5 +1,6 @@
1
1
  import type { ProviderAdapter } from './providers/types';
2
2
  import type { PoolFile } from './types';
3
+ export declare const USAGE_REFRESH_TTL_MS: number;
3
4
  /**
4
5
  * Max lifetime of a TRANSIENT (non-quota) cooldown. fetch.ts cools a thrown
5
6
  * network error or a header-less 429/402 for at most `ACCOUNT_COOLDOWN_MS`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-auth-load-balancer",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Load-balance opencode requests across multiple Anthropic (Claude) and OpenAI (Codex) OAuth accounts, weighted primarily by weekly usage with drain-before-reset scheduling.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,18 +41,18 @@
41
41
  "prepare": "husky"
42
42
  },
43
43
  "peerDependencies": {
44
- "@opencode-ai/plugin": ">=1.17.18"
44
+ "@opencode-ai/plugin": ">=1.18.26"
45
45
  },
46
46
  "devDependencies": {
47
- "@opencode-ai/plugin": "^1.17.18",
48
- "@opentui/core": "0.4.3",
49
- "@opentui/solid": "0.4.3",
50
- "@types/bun": "^1.3.14",
51
- "@types/node": "^26.1.1",
52
- "eslint-plugin-devup": "^2.0.19",
47
+ "@opencode-ai/plugin": "^1.18.26",
48
+ "@opentui/core": "0.5.10",
49
+ "@opentui/solid": "0.5.10",
50
+ "@types/bun": "^1.4.0",
51
+ "@types/node": "^26.4.1",
52
+ "eslint-plugin-devup": "^2.1.0",
53
53
  "husky": "^9.1",
54
- "oxlint": "1.73",
55
- "solid-js": "1.9.14",
54
+ "oxlint": "1.81",
55
+ "solid-js": "1.9.15",
56
56
  "typescript": "^7.0.2"
57
57
  },
58
58
  "keywords": [
@@ -242,10 +242,18 @@ export const MANUAL_DISABLED_REASON = 'manually disabled'
242
242
  */
243
243
  export const RELOGIN_TTL_MS = 600_000
244
244
 
245
+ /**
246
+ * `now` DEFAULTS to the wall clock so the view can fire this without reading
247
+ * the clock itself: the call sits in an async re-login handler, and a bare
248
+ * `Date.now()` written inside a component body is flagged by `react(purity)`
249
+ * even on a non-render path the rule cannot distinguish. Tests still pass an
250
+ * explicit `now` — stamping the TTL from an injectable instant is what keeps
251
+ * the expiry assertion deterministic.
252
+ */
245
253
  export function setReloginTargetInPool(
246
254
  accountId: string,
247
255
  providerID: string,
248
- now: number,
256
+ now: number = Date.now(),
249
257
  path: string = POOL_FILE,
250
258
  ): void {
251
259
  mutatePoolFile((pool) => {
@@ -82,11 +82,33 @@ function routeSessionId(api: TuiPluginApi): string | undefined {
82
82
  // shared with auth-load-balancer-tui.logic.ts (imported above as `cfg`) so it is
83
83
  // computed exactly once.
84
84
 
85
+ /** One poll sample: the pool, plus the instant it was read. */
86
+ interface PoolSample {
87
+ pool: PoolShape
88
+ now: number
89
+ }
90
+
91
+ /**
92
+ * Capture the pool AND the clock together, beside each other.
93
+ *
94
+ * The timestamp deliberately lives HERE rather than in each consuming memo.
95
+ * A memo that calls `Date.now()` itself is impure — it depends on something
96
+ * Solid cannot track, so its countdowns refresh only by the coincidence that
97
+ * the poll signal happens to change on the same tick — and it measures every
98
+ * `until()` against an instant slightly LATER than the snapshot it is
99
+ * describing. Sampling both at once makes the memos pure functions of one
100
+ * reactive input and keeps each countdown consistent with the pool state it
101
+ * is rendered from.
102
+ */
103
+ function pollSample(): PoolSample {
104
+ return { pool: readPool(), now: Date.now() }
105
+ }
106
+
85
107
  function usePool() {
86
- const [pool, setPool] = createSignal<PoolShape>(readPool())
87
- const timer = setInterval(() => setPool(readPool()), POLL_MS)
108
+ const [sample, setSample] = createSignal<PoolSample>(pollSample())
109
+ const timer = setInterval(() => setSample(pollSample()), POLL_MS)
88
110
  onCleanup(() => clearInterval(timer))
89
- return pool
111
+ return sample
90
112
  }
91
113
 
92
114
  /** Shared theme-color accessor used identically by `BottomBar` and `SidebarPanel`. */
@@ -111,12 +133,11 @@ interface Chip extends WindowDisplay {
111
133
 
112
134
  /** Always-visible bottom bar (app_bottom): the in-use account per provider. */
113
135
  function BottomBar(props: { api: TuiPluginApi }) {
114
- const pool = usePool()
136
+ const sample = usePool()
115
137
  const color = themeColor(props.api)
116
138
  const chips = createMemo<Chip[]>(() => {
117
- const p = pool()
139
+ const { pool: p, now } = sample()
118
140
  const accounts = p.accounts ?? []
119
- const now = Date.now()
120
141
  const out: Chip[] = []
121
142
  const sid = routeSessionId(props.api)
122
143
  // Show the account THIS session is using per provider — NOT the global
@@ -190,12 +211,11 @@ function SidebarPanel(props: {
190
211
  api: TuiPluginApi
191
212
  sessionId: string | undefined
192
213
  }) {
193
- const pool = usePool()
214
+ const sample = usePool()
194
215
  const color = themeColor(props.api)
195
216
  const groups = createMemo<Group[]>(() => {
196
- const p = pool()
217
+ const { pool: p, now } = sample()
197
218
  const accounts = p.accounts ?? []
198
- const now = Date.now()
199
219
  const providerIds = [...new Set(accounts.map((a) => a.providerID))].sort(
200
220
  compareAscii,
201
221
  )
@@ -314,7 +334,8 @@ function SidebarPanel(props: {
314
334
  return
315
335
  }
316
336
 
317
- setReloginTargetInPool(id, providerID, Date.now())
337
+ // Timestamp intentionally left to the callee's default — see its doc.
338
+ setReloginTargetInPool(id, providerID)
318
339
 
319
340
  const completeRelogin = async (code?: string): Promise<void> => {
320
341
  try {