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/README.md +51 -7
- package/dist/fetch.d.ts +12 -1
- package/dist/index.js +3114 -2286
- package/dist/notify.d.ts +8 -0
- package/dist/pending/coordinator.d.ts +135 -0
- package/dist/pending/lease.d.ts +3 -0
- package/dist/pending/recovery.d.ts +16 -0
- package/dist/pending/store.d.ts +12 -0
- package/dist/pending/types.d.ts +23 -0
- package/dist/pool/lock.d.ts +1 -2
- package/dist/pool/paths.d.ts +11 -0
- package/dist/providers/anthropic/constants.d.ts +73 -6
- package/dist/providers/anthropic/version.d.ts +50 -0
- package/dist/providers/types.d.ts +33 -0
- package/dist/providers/usage-http.d.ts +7 -6
- package/dist/session.d.ts +2 -0
- package/dist/status.d.ts +3 -0
- package/dist/types.d.ts +3 -0
- package/dist/usage-refresh.d.ts +1 -0
- package/package.json +10 -10
- package/tui/auth-load-balancer-tui.logic.ts +9 -1
- package/tui/auth-load-balancer-tui.view.tsx +31 -10
package/README.md
CHANGED
|
@@ -12,10 +12,12 @@ Selection is **not** round-robin. It is weighted primarily by **weekly** usage,
|
|
|
12
12
|
- **Weekly-usage-weighted scheduling** — `urgency = weeklyRemaining / daysUntilWeeklyReset`. A sooner reset (e.g. 3 days) outranks a later one (7 days) at equal headroom; perishable quota is drained progressively, never crammed into the final hour.
|
|
13
13
|
- **Automatic rotation** — on `429`/auth errors an account is cooled down and the next-best is tried; `retry-after` is honored.
|
|
14
14
|
- **Model-tier fallback ladder (Fable, Opus, …)** — Claude Max accounts have *separate* weekly caps per premium model tier. When one is exhausted (a 429 whose `representative-claim` names a tier window, e.g. `seven_day_fable` / `seven_day_opus`), the balancer records a **per-tier** cooldown instead of cooling the whole account down (which used to cascade every account into a false "cooldown" and block *every* model on it). Requests for that tier then **steer to an account with tier headroom** — keeping the model you asked for — and only when the *whole pool* is tier-limited does the request descend **one rung down the fallback ladder**: the next model family in `fable → opus → sonnet → haiku` (order configurable via `OPENCODE_AUTH_LB_ANTHROPIC_FAMILY_ORDER`), picking the **highest-versioned model your provider config actually has** (a capped `claude-fable-5` prefers `claude-opus-4-9` over `claude-opus-4-8`). If that tier is capped too, it descends again (`fable → opus → sonnet`), each step toasted, preferably on the session's pinned account (keeping its prompt cache). Pin a fixed target or disable entirely via `OPENCODE_AUTH_LB_ANTHROPIC_OPUS_FALLBACK_MODEL`.
|
|
15
|
+
- **Durable provider pending** — after model fallback and account rotation are exhausted, a session-bound turn whose whole provider is blocked by account-wide quota stays pending without sending a guaranteed `429`. It resumes when capacity returns, survives an opencode restart as the same user message, and is removed immediately when you cancel with `Esc`. Sessions wait and resume independently; there is no provider-wide FIFO.
|
|
16
|
+
- **Self-updating Claude Code version** — Anthropic gates new models on the client version the request claims, so a pinned version stops working the day a model ships (`Claude Code 2.1.87 does not support this model; version 2.1.251 or newer is required`). The plugin discovers the current version from npm instead, caches it for a day, and never regresses below a version known to work. See [Claude Code version](#claude-code-version).
|
|
15
17
|
- **Session affinity** — a conversation stays pinned to one account so you keep its prompt cache and don't re-send context on every turn.
|
|
16
18
|
- **Proactive migration** — leaves an account at a configurable soft threshold (~95%) instead of waiting for a hard 100% wall (which can break in-flight subagents).
|
|
17
19
|
- **Single-use refresh-token safety** — per-account singleflight refresh; rotated tokens are persisted immediately.
|
|
18
|
-
- **Visibility** —
|
|
20
|
+
- **Visibility** — toasts for account switches, model fallback, pending/recovery, and restart restoration; an on-demand `auth_lb_status` tool; and a `bun run status` CLI dashboard.
|
|
19
21
|
|
|
20
22
|
---
|
|
21
23
|
|
|
@@ -92,6 +94,10 @@ The pool lives in a JSON file you can inspect/edit (e.g. to rename labels):
|
|
|
92
94
|
|
|
93
95
|
opencode resolves its data dir via `xdg-basedir`, which is platform-agnostic — it does **not** use `%LOCALAPPDATA%` on Windows or `Application Support` on macOS (verified with `opencode debug paths`).
|
|
94
96
|
|
|
97
|
+
Durable turn references live beside the pool as `auth-load-balancer-pending.json`. This file contains only workspace/provider/session/message ids and scheduling timestamps — never prompt text, request bodies, attachments, headers, OAuth tokens, or provider responses. Both files use atomic writes and cross-process locks.
|
|
98
|
+
|
|
99
|
+
A third file, `auth-load-balancer-cc-version.json`, caches the discovered Claude Code version (`{"version","fetchedAt"}` — see [Claude Code version](#claude-code-version)). It holds no account data and is safe to delete; it is rebuilt on the next start.
|
|
100
|
+
|
|
95
101
|
> **OpenAI/Codex note:** the OpenAI path assumes opencode is configured to use the **Responses API** (the standard ChatGPT/Codex setup); `/responses` requests are routed to the Codex backend.
|
|
96
102
|
|
|
97
103
|
---
|
|
@@ -120,6 +126,14 @@ Codex — in use: chatgpt-plus
|
|
|
120
126
|
1 ▶ chatgpt-plus 20% 5% 3d18h in use
|
|
121
127
|
```
|
|
122
128
|
|
|
129
|
+
The workspace-aware `auth_lb_status` tool also appends durable waits; the standalone TUI layout remains unchanged:
|
|
130
|
+
|
|
131
|
+
```text
|
|
132
|
+
Pending turns
|
|
133
|
+
Claude 2 sessions · nearest recovery 3h0m
|
|
134
|
+
Codex 1 session · checking again 5m
|
|
135
|
+
```
|
|
136
|
+
|
|
123
137
|
`▶` marks the in-use account; `#` is the rank the scheduler would pick next.
|
|
124
138
|
|
|
125
139
|
### Persistent bottom status bar (TUI)
|
|
@@ -157,7 +171,7 @@ Then register the entry in `~/.config/opencode/tui.json` (this is how opencode l
|
|
|
157
171
|
}
|
|
158
172
|
```
|
|
159
173
|
|
|
160
|
-
> **Why this shape (it matters):** TUI plugins load from the `plugin` array in `tui.json`, **not** from the server's plugins-dir glob. The server *separately* globs `{plugin,plugins}/*.{ts,js}` and loads every match as a **server** plugin — so a TUI entry dropped in `plugins/` is also loaded by the server, rejected (`must default export … server()`), and logs an error on **every** launch (and a stray scoring `.ts` there would be mis-loaded as a plugin and break provider resolution). Keeping the four files OUTSIDE `plugins/` and registering only via `tui.json` avoids that. The split into a `.ts` **entry** that **lazily imports** a `.tsx` **view** is still required because opencode's runtime SolidJS JSX transform (`@opentui/solid`) only matches `*.{tsx,jsx}` — a `.ts` cannot itself contain JSX, and the lazy import keeps the server plugin worker (which never runs `tui()`) from evaluating the SolidJS module graph. Developed against opencode / `@opencode-ai/plugin` `>=1.
|
|
174
|
+
> **Why this shape (it matters):** TUI plugins load from the `plugin` array in `tui.json`, **not** from the server's plugins-dir glob. The server *separately* globs `{plugin,plugins}/*.{ts,js}` and loads every match as a **server** plugin — so a TUI entry dropped in `plugins/` is also loaded by the server, rejected (`must default export … server()`), and logs an error on **every** launch (and a stray scoring `.ts` there would be mis-loaded as a plugin and break provider resolution). Keeping the four files OUTSIDE `plugins/` and registering only via `tui.json` avoids that. The split into a `.ts` **entry** that **lazily imports** a `.tsx` **view** is still required because opencode's runtime SolidJS JSX transform (`@opentui/solid`) only matches `*.{tsx,jsx}` — a `.ts` cannot itself contain JSX, and the lazy import keeps the server plugin worker (which never runs `tui()`) from evaluating the SolidJS module graph. Developed against opencode / `@opencode-ai/plugin` `>=1.18.26` + `@opentui/solid` `0.5.10` (the versions currently pinned in `package.json`). The `.tsx` view **is** typechecked (`tsconfig.tui.json`) and linted here — its JSX deps (`solid-js` + `@opentui/*`) are installed as devDependencies pinned to those versions — so type/import/prop breaks are caught in CI; only its runtime rendering still needs a live opencode TUI to verify. The toast + `auth_lb_status` tool + `bun run status` CLI cover the same information regardless. (`auth-load-balancer-tui.logic.ts` has no JSX and no TUI-runtime dependency, so it is directly unit-tested rather than only typechecked.)
|
|
161
175
|
|
|
162
176
|
---
|
|
163
177
|
|
|
@@ -177,13 +191,38 @@ All knobs are environment variables with sane defaults.
|
|
|
177
191
|
| `OPENCODE_AUTH_LB_DRAIN_MIGRATE` | `false` | Allow switching a healthy session to drain another account whose weekly window is about to reset. |
|
|
178
192
|
| `OPENCODE_AUTH_LB_DRAIN_MIGRATE_MARGIN` | `1.5` | Urgency factor required to justify a drain switch. |
|
|
179
193
|
| `OPENCODE_AUTH_LB_SESSION_TTL_MS` | `21600000` | Session→account assignments older than this are pruned. |
|
|
180
|
-
| `OPENCODE_AUTH_LB_MAX_WAIT_MS` | `305000` |
|
|
194
|
+
| `OPENCODE_AUTH_LB_MAX_WAIT_MS` | `305000` | Bounded wait for requests that do **not** carry both an opencode session id and user-message id (provider-internal or external fetches). Session-bound user turns use durable pending instead and wait until provider quota recovers or the user cancels. Auth (`401`/`403`), disabled/re-login accounts, and network failures are never converted into quota waits. `0` makes anonymous requests fail fast. |
|
|
181
195
|
| `OPENCODE_AUTH_LB_ANTHROPIC_OPUS_FALLBACK_MODEL` | *(unset — ladder mode)* | Claude Max accounts have **separate weekly caps per premium model tier** (Fable, Opus, …). When one is exhausted, that tier's requests 429 (`anthropic-ratelimit-unified-representative-claim: seven_day_fable` / `seven_day_opus` / …) even though the account's aggregate 5h/7d windows still have headroom and every other model works. Instead of cooling the **whole account** down (which cascaded every account into "cooldown"), the balancer records a **per-tier** cooldown: requests for that tier steer to accounts with tier headroom, and once the whole pool is tier-limited they descend the **fallback ladder** (next family down, best version in your provider's model list; toasted, never silent). **Unset** = ladder mode (recommended). Set to a **model id** to pin a fixed downgrade target (bypassing the ladder). Set to an **empty string** to disable (revert to the account-wide cooldown). The env name keeps `OPUS` for compatibility but applies to **every** tier. |
|
|
182
196
|
| `OPENCODE_AUTH_LB_ANTHROPIC_FAMILY_ORDER` | `fable,opus,sonnet,haiku` | The fallback ladder's model families, **best first**. A tier-capped request downgrades to the highest-versioned configured model of the next family below the capped one (e.g. capped `fable` → newest `opus`; capped `opus` → newest `sonnet`). A family not in the list (a future top tier) is treated as above the first entry — so when Anthropic ships a new premium tier, a config tweak (or nothing at all, if it slots on top) keeps the ladder correct without a code change. |
|
|
197
|
+
| `OPENCODE_AUTH_LB_ANTHROPIC_CLAUDE_CODE_VERSION` | *(unset — auto)* | Pin the Claude Code version the plugin claims to be, bypassing both the npm lookup and the built-in floor. Anthropic **gates new models on this version** — asking for a model newer than the version you report is rejected with `claude_code_version_too_old` — so by default the plugin resolves it automatically, and relearns it from a rejection when the gate moves before npm does (see [Claude Code version](#claude-code-version)). A pin also disables that reactive recovery, since the retry would send the version you pinned. Set this only to pin **forward** (a version npm hasn't tagged `latest` yet) or **back** (to reproduce a failure, or if a future release changes the request fingerprint and the newest version starts failing). Must be a plain `x.y.z`; anything else is ignored. |
|
|
183
198
|
| `OPENCODE_AUTH_LB_DIR` | — | Override the pool-file directory (handy for tests). |
|
|
184
199
|
| `OPENCODE_AUTH_LB_DEBUG` | — | `1`/`true` logs each selection to stderr. |
|
|
185
200
|
| `ANTHROPIC_BASE_URL` | — | Route Anthropic requests through a custom base URL. |
|
|
186
201
|
|
|
202
|
+
### Claude Code version
|
|
203
|
+
|
|
204
|
+
Anthropic only accepts OAuth (subscription) requests that look like they came from Claude Code, so the plugin reports a version in the `claude-cli/<version>` User-Agent and in the `cc_version=` billing fingerprint. **That version is a gate, not decoration:** requesting a model newer than the version you claim is rejected outright.
|
|
205
|
+
|
|
206
|
+
```json
|
|
207
|
+
{"type":"error","error":{"type":"invalid_request_error",
|
|
208
|
+
"message":"Claude Code 2.1.87 does not support this model; version 2.1.251 or newer is required.",
|
|
209
|
+
"details":{"error_code":"claude_code_version_too_old"}}}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
A hard-coded version therefore breaks on *every* model launch, so the plugin resolves it, best-first:
|
|
213
|
+
|
|
214
|
+
1. **`OPENCODE_AUTH_LB_ANTHROPIC_CLAUDE_CODE_VERSION`** — an explicit pin. Skips the lookup entirely.
|
|
215
|
+
2. **npm** — the `latest` dist-tag of [`@anthropic-ai/claude-code`](https://www.npmjs.com/package/@anthropic-ai/claude-code), cached in the data dir for 24 h.
|
|
216
|
+
3. **A built-in floor** — a version verified to work, used offline and on a cold first run.
|
|
217
|
+
|
|
218
|
+
The resolved version only ever moves **up**, so a registry blip or a rolled-back `latest` can never drag the plugin below a version already known to work; use the env pin to go down deliberately. The lookup is a single ~56-byte request fired at startup: the loader awaits only the local cache read, never the network, so neither opencode's startup nor your first request waits on npm — and a registry outage just leaves the previous version in place.
|
|
219
|
+
|
|
220
|
+
**The gate's own rejection is the fourth route, and the only one that cannot lag.** A cache is only as fresh as its TTL, so a model launching *inside* that window is rejected for as long as the TTL has left to run — precisely when you need the new version. No polling cadence fixes that in general either: Anthropic can gate a model before npm tags the release. So the plugin learns from the failure instead. A `claude_code_version_too_old` response is parsed for the minimum it demands, npm is asked for the real `latest` in the same breath (the minimum only clears the model that just failed), the result is cached, and the request is **transparently retried once** — with the new version in both the User-Agent and the body's `cc_version=` fingerprint. The turn succeeds instead of failing, and every later request starts from what was just learned.
|
|
221
|
+
|
|
222
|
+
The retry is spent only when the reported version actually *changed*, so a 400 for any other reason is passed straight through with its body untouched, and a pinned deployment is unaffected: under an explicit `OPENCODE_AUTH_LB_ANTHROPIC_CLAUDE_CODE_VERSION` the rejection is returned as-is, because a retry would send the very version you pinned.
|
|
223
|
+
|
|
224
|
+
> Only the version *string* is discovered. The request fingerprint's salt is still compiled in, so if Anthropic ever changes that algorithm, pin a known-good version with the env var and open an issue.
|
|
225
|
+
|
|
187
226
|
---
|
|
188
227
|
|
|
189
228
|
## Development
|
|
@@ -243,6 +282,7 @@ src/
|
|
|
243
282
|
notify.ts # toast on account switch
|
|
244
283
|
usage-refresh.ts # cold-start usage seeding via the usage endpoint
|
|
245
284
|
prime.ts # point the in-use marker at the top-ranked account at startup
|
|
285
|
+
pending/ # recovery classifier, reference store, per-turn lease, restart coordinator
|
|
246
286
|
scheduler/ # config, score-core (shared scorer), select
|
|
247
287
|
pool/ # data-dir resolution + atomic, serialized pool store
|
|
248
288
|
providers/ # ProviderAdapter contract + headers
|
|
@@ -263,21 +303,25 @@ tui/
|
|
|
263
303
|
|
|
264
304
|
opencode lets an auth plugin's `loader` return a custom `fetch` that **every** request for a provider flows through. That single choke point ([`src/fetch.ts`](src/fetch.ts)) is where the magic happens, per request:
|
|
265
305
|
|
|
266
|
-
1.
|
|
306
|
+
1. capture opencode's session and user-message ids, then derive the session-affinity key;
|
|
267
307
|
2. pick the session's pinned account — or, if it's unavailable / over the soft threshold, the highest **weekly-urgency** account ([`src/scheduler/select.ts`](src/scheduler/select.ts));
|
|
268
308
|
3. refresh the OAuth token if needed (singleflight, rotated token persisted);
|
|
269
309
|
4. apply provider-specific auth + request transforms (Claude Code identity / Codex Responses quirks);
|
|
270
310
|
5. send the request, then record usage from the response headers;
|
|
271
|
-
6. on `429
|
|
311
|
+
6. on a model-tier `429`, steer across accounts and descend the configured model-family fallback ladder before considering provider pending;
|
|
312
|
+
7. on an account-wide `429`/`402`, cool that account and rotate through the remaining accounts;
|
|
313
|
+
8. only when every usable account is provider-quota blocked, persist the turn reference before waiting — known exhaustion sends no speculative provider request;
|
|
314
|
+
9. on recovery, retry selection; on `Esc`, delete the reference; on restart, reconcile OpenCode's persisted message/history and resume the same message id without duplicating its prompt parts.
|
|
272
315
|
|
|
273
|
-
The pool
|
|
316
|
+
The account pool and durable pending references are separate JSON files (opencode's native auth store holds only one credential per provider). Writes are atomic and serialized both in-process and across processes. No daemon runs while opencode is closed; restoration begins only when opencode starts again.
|
|
274
317
|
|
|
275
318
|
---
|
|
276
319
|
|
|
277
320
|
## Limitations
|
|
278
321
|
|
|
279
|
-
- **Bottom status bar** ([`tui/auth-load-balancer-tui.ts`](tui/auth-load-balancer-tui.ts) + [`.view.tsx`](tui/auth-load-balancer-tui.view.tsx) + [`.logic.ts`](tui/auth-load-balancer-tui.logic.ts) + [`auth-load-balancer-scoring.ts`](tui/auth-load-balancer-scoring.ts)) is a SolidJS TUI artifact compiled by opencode (its JSX deps — `solid-js` + `@opentui/*` — are installed as devDependencies, so the `.tsx` view **is** typechecked via `tsconfig.tui.json` and linted, though not render-tested here since that needs a live opencode TUI; its scorer is a byte-identical copy of the unit-tested [`src/scheduler/score-core.ts`](src/scheduler/score-core.ts), enforced by a sync test). It is written against opencode `>=1.
|
|
322
|
+
- **Bottom status bar** ([`tui/auth-load-balancer-tui.ts`](tui/auth-load-balancer-tui.ts) + [`.view.tsx`](tui/auth-load-balancer-tui.view.tsx) + [`.logic.ts`](tui/auth-load-balancer-tui.logic.ts) + [`auth-load-balancer-scoring.ts`](tui/auth-load-balancer-scoring.ts)) is a SolidJS TUI artifact compiled by opencode (its JSX deps — `solid-js` + `@opentui/*` — are installed as devDependencies, so the `.tsx` view **is** typechecked via `tsconfig.tui.json` and linted, though not render-tested here since that needs a live opencode TUI; its scorer is a byte-identical copy of the unit-tested [`src/scheduler/score-core.ts`](src/scheduler/score-core.ts), enforced by a sync test). It is written against opencode `>=1.18.26` internals (the `app_bottom` slot + the `subagent-footer` usage computation, verified against source); confirm it renders in your opencode build. The toast/tool/CLI cover the account info regardless.
|
|
280
323
|
- **OpenAI/Codex** assumes the Responses API; chat-completions → responses conversion is out of scope.
|
|
324
|
+
- **Plugin reloads require restart**: opencode loads server plugins once at process startup. After installing a new build, restart the running opencode process once; durable turns are restored on that next start and are never sent while opencode is closed.
|
|
281
325
|
- **Cross-process refresh**: per-process singleflight protects token rotation within one opencode instance. Running two opencode instances at once could still race the single-use refresh token.
|
|
282
326
|
- **TUI pool writes**: the TUI sidebar's Rename / Delete actions write the pool file atomically (temp + rename) but WITHOUT the cross-process file lock the server uses around its own read-modify-write — so a server usage / cooldown / session / `tokenGen` update committed between the TUI's `readFileSync` and `renameSync` can be silently overwritten. Impact is bounded: the next request re-records usage from response headers, so the window is one cycle of staleness on the affected account; correctness recovers on its own.
|
|
283
327
|
- Live OAuth/login and real-account end-to-end behavior should be smoke-tested in your environment; the test suite mocks the network.
|
package/dist/fetch.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type ProviderRecovery } from './pending/recovery';
|
|
2
|
+
import type { PendingRef } from './pending/types';
|
|
1
3
|
import type { ProviderAdapter } from './providers/types';
|
|
2
4
|
import type { PoolAccount } from './types';
|
|
3
5
|
export declare const ACCOUNT_COOLDOWN_MS: number;
|
|
@@ -20,6 +22,15 @@ interface FetchHooks {
|
|
|
20
22
|
*/
|
|
21
23
|
onModelFallback?: (providerID: string, account: PoolAccount, fromModel: string, toModel: string, fromTier?: string) => void;
|
|
22
24
|
}
|
|
25
|
+
/** Durable quota-wait boundary supplied by the provider plugin instance. */
|
|
26
|
+
export interface PendingFetchCoordinator {
|
|
27
|
+
readonly workspace: string;
|
|
28
|
+
waitForCapacity(ref: PendingRef, recovery: Extract<ProviderRecovery, {
|
|
29
|
+
state: 'quota-blocked';
|
|
30
|
+
}>, signal?: AbortSignal): Promise<'available' | 'unusable'>;
|
|
31
|
+
complete(ref: PendingRef): Promise<void>;
|
|
32
|
+
abort(ref: PendingRef): Promise<void>;
|
|
33
|
+
}
|
|
23
34
|
/**
|
|
24
35
|
* Build a `fetch`-compatible function that load-balances every request for one
|
|
25
36
|
* provider across the pooled accounts. Returned from an adapter's auth.loader and
|
|
@@ -31,5 +42,5 @@ interface FetchHooks {
|
|
|
31
42
|
* error the account is cooled down and the next-best account is tried; a successful
|
|
32
43
|
* response re-pins the session to the account that served it and reports it via onUse.
|
|
33
44
|
*/
|
|
34
|
-
export declare function createLoadBalancedFetch(adapter: ProviderAdapter, hooks?: FetchHooks, models?: readonly string[]): typeof fetch;
|
|
45
|
+
export declare function createLoadBalancedFetch(adapter: ProviderAdapter, hooks?: FetchHooks, models?: readonly string[], pending?: PendingFetchCoordinator): typeof fetch;
|
|
35
46
|
export {};
|