opencode-auth-load-balancer 0.2.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/LICENSE +21 -0
- package/README.md +289 -0
- package/dist/accounts.d.ts +20 -0
- package/dist/cli/status.d.ts +2 -0
- package/dist/fetch.d.ts +34 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +14829 -0
- package/dist/notify.d.ts +47 -0
- package/dist/pool/lock.d.ts +39 -0
- package/dist/pool/paths.d.ts +16 -0
- package/dist/pool/store.d.ts +51 -0
- package/dist/prime.d.ts +32 -0
- package/dist/providers/anthropic/adapter.d.ts +8 -0
- package/dist/providers/anthropic/cch.d.ts +20 -0
- package/dist/providers/anthropic/constants.d.ts +64 -0
- package/dist/providers/anthropic/fallback.d.ts +52 -0
- package/dist/providers/anthropic/oauth.d.ts +8 -0
- package/dist/providers/anthropic/transform.d.ts +23 -0
- package/dist/providers/anthropic/usage.d.ts +14 -0
- package/dist/providers/headers.d.ts +7 -0
- package/dist/providers/http-timeouts.d.ts +32 -0
- package/dist/providers/oauth-callback.d.ts +100 -0
- package/dist/providers/openai/adapter.d.ts +9 -0
- package/dist/providers/openai/constants.d.ts +18 -0
- package/dist/providers/openai/jwt.d.ts +5 -0
- package/dist/providers/openai/oauth.d.ts +12 -0
- package/dist/providers/openai/transform.d.ts +18 -0
- package/dist/providers/openai/usage.d.ts +16 -0
- package/dist/providers/pkce.d.ts +10 -0
- package/dist/providers/types.d.ts +122 -0
- package/dist/providers/usage-headers.d.ts +55 -0
- package/dist/providers/usage-http.d.ts +8 -0
- package/dist/refresh.d.ts +13 -0
- package/dist/scheduler/config.d.ts +46 -0
- package/dist/scheduler/score-core.d.ts +167 -0
- package/dist/scheduler/select.d.ts +41 -0
- package/dist/session.d.ts +15 -0
- package/dist/status.d.ts +55 -0
- package/dist/types.d.ts +101 -0
- package/dist/usage-merge.d.ts +23 -0
- package/dist/usage-refresh.d.ts +30 -0
- package/dist/util.d.ts +118 -0
- package/package.json +75 -0
- package/tui/auth-load-balancer-scoring.ts +344 -0
- package/tui/auth-load-balancer-tui.logic.ts +362 -0
- package/tui/auth-load-balancer-tui.ts +39 -0
- package/tui/auth-load-balancer-tui.view.tsx +327 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 owjs3901
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# opencode-auth-load-balancer
|
|
2
|
+
|
|
3
|
+
Load-balance [opencode](https://opencode.ai) across **multiple Claude (Anthropic) and Codex (OpenAI/ChatGPT) OAuth accounts** so you never have to stop and re-login when one account runs out of quota.
|
|
4
|
+
|
|
5
|
+
Selection is **not** round-robin. It is weighted primarily by **weekly** usage, with a continuous "drain the soonest-resetting account first" rule, per-conversation **session affinity** (to preserve prompt caching), and a proactive switch **before** an account hits 100%.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Account pool** — register many Claude / Codex OAuth accounts; the plugin manages and rotates them.
|
|
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
|
+
- **Automatic rotation** — on `429`/auth errors an account is cooled down and the next-best is tried; `retry-after` is honored.
|
|
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
|
+
- **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
|
+
- **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
|
+
- **Single-use refresh-token safety** — per-account singleflight refresh; rotated tokens are persisted immediately.
|
|
18
|
+
- **Visibility** — a toast when the in-use account switches, an on-demand `auth_lb_status` tool, and a `bun run status` CLI dashboard.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Requirements
|
|
23
|
+
|
|
24
|
+
- [Bun](https://bun.com) ≥ 1.3
|
|
25
|
+
- opencode (TUI), with the Anthropic and/or OpenAI providers available
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Install & build
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
bun install
|
|
33
|
+
bun run build # → dist/index.js (a single self-contained file)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The bundle imports only Node built-ins, so it can be dropped into opencode as one file.
|
|
37
|
+
|
|
38
|
+
### Load it into opencode (local / dev)
|
|
39
|
+
|
|
40
|
+
opencode auto-loads any `.ts`/`.js` file in a **plugins directory**:
|
|
41
|
+
|
|
42
|
+
- `.opencode/plugins/` — project-level (recommended; unambiguous on every OS)
|
|
43
|
+
- `~/.config/opencode/plugins/` — global
|
|
44
|
+
|
|
45
|
+
Copy or symlink the built bundle into your opencode project's plugin dir:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# macOS / Linux — symlink so rebuilds are picked up automatically
|
|
49
|
+
mkdir -p .opencode/plugins
|
|
50
|
+
ln -sf "$(pwd)/dist/index.js" .opencode/plugins/auth-load-balancer.js
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```powershell
|
|
54
|
+
# Windows (PowerShell) — copy (symlinks need Developer Mode / admin)
|
|
55
|
+
New-Item -ItemType Directory -Force -Path .opencode\plugins | Out-Null
|
|
56
|
+
Copy-Item dist\index.js .opencode\plugins\auth-load-balancer.js
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Restart opencode to load it. (opencode does **not** hot-reload plugins — see the dev loop below.)
|
|
60
|
+
|
|
61
|
+
### Install it from npm (once published)
|
|
62
|
+
|
|
63
|
+
```jsonc
|
|
64
|
+
// opencode.json
|
|
65
|
+
{
|
|
66
|
+
"$schema": "https://opencode.ai/config.json",
|
|
67
|
+
"plugin": ["opencode-auth-load-balancer"]
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
opencode installs the package and its dependencies automatically at startup.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Register your accounts
|
|
76
|
+
|
|
77
|
+
Run opencode's auth flow once **per account**:
|
|
78
|
+
|
|
79
|
+
1. `opencode auth login`
|
|
80
|
+
2. Choose **"Claude Pro/Max (add account to load balancer)"** (or **"ChatGPT/Codex …"**).
|
|
81
|
+
3. Open the URL, authorize, and paste the resulting code/URL back.
|
|
82
|
+
4. Repeat for every account you want in the pool.
|
|
83
|
+
|
|
84
|
+
Each login **appends** to the pool (it does not overwrite opencode's single auth slot). If you already logged in with the upstream `opencode-anthropic-auth` plugin, that credential is imported automatically on first run.
|
|
85
|
+
|
|
86
|
+
The pool lives in a JSON file you can inspect/edit (e.g. to rename labels):
|
|
87
|
+
|
|
88
|
+
| When | Path |
|
|
89
|
+
|------|------|
|
|
90
|
+
| Default (**every** OS, incl. Windows) | `~/.local/share/opencode/auth-load-balancer.json` |
|
|
91
|
+
| `$XDG_DATA_HOME` set | `$XDG_DATA_HOME/opencode/auth-load-balancer.json` |
|
|
92
|
+
|
|
93
|
+
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
|
+
|
|
95
|
+
> **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
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## See which account is in use
|
|
100
|
+
|
|
101
|
+
Three surfaces, in increasing detail:
|
|
102
|
+
|
|
103
|
+
1. **Toast on switch** — when the in-use account changes, opencode shows a toast: `Claude account ▶ claude-work · weekly 45% · 5h 10%`.
|
|
104
|
+
2. **`auth_lb_status` tool** — ask the agent to "show auth load balancer status"; it prints the full dashboard in chat.
|
|
105
|
+
3. **CLI** — run it directly in a terminal:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
bun run status
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```text
|
|
112
|
+
Claude — in use: claude-personal
|
|
113
|
+
# account weekly 5h resets state
|
|
114
|
+
1 claude-work 45% 10% 2d2h ready
|
|
115
|
+
2 ▶ claude-personal 72% 38% 1d6h in use
|
|
116
|
+
3 claude-burner 100% - 30m exhausted
|
|
117
|
+
|
|
118
|
+
Codex — in use: chatgpt-plus
|
|
119
|
+
# account weekly 5h resets state
|
|
120
|
+
1 ▶ chatgpt-plus 20% 5% 3d18h in use
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`▶` marks the in-use account; `#` is the rank the scheduler would pick next.
|
|
124
|
+
|
|
125
|
+
### Persistent bottom status bar (TUI)
|
|
126
|
+
|
|
127
|
+
A SolidJS TUI plugin renders a persistent bottom status bar (opencode's always-visible `app_bottom` slot) showing both the in-use account(s) per provider (polled from the pool, e.g. `Claude anthropic-1 58% · 5h 12%`) **and** the current session usage — tokens, % of the model context window, and `$` cost — computed the same way opencode's own footer does. It is **four files**:
|
|
128
|
+
|
|
129
|
+
| File | Role |
|
|
130
|
+
|------|------|
|
|
131
|
+
| [`tui/auth-load-balancer-tui.ts`](tui/auth-load-balancer-tui.ts) | Plugin **entry** (no JSX). Registered in `tui.json` (below). Inside `tui()` it **lazily** imports the view. |
|
|
132
|
+
| [`tui/auth-load-balancer-tui.view.tsx`](tui/auth-load-balancer-tui.view.tsx) | The SolidJS **view** (JSX). Compiled by opencode's TUI runtime Solid transform, whose loader matches `*.{tsx,jsx}`. |
|
|
133
|
+
| [`tui/auth-load-balancer-tui.logic.ts`](tui/auth-load-balancer-tui.logic.ts) | Pure, non-JSX pool-file logic (read/normalize the pool file, the sidebar's rename/delete mutations, and the `pct`/`until`/`winPct`/`tierResets`/`stateOf` display-formatting helpers) split out of the view so it's directly unit-testable, imported unchanged by `.view.tsx`. |
|
|
134
|
+
| [`tui/auth-load-balancer-scoring.ts`](tui/auth-load-balancer-scoring.ts) | A byte-identical copy of [`src/scheduler/score-core.ts`](src/scheduler/score-core.ts) (kept in sync by `bun run build` + a test) so the dashboard ranks accounts with the **exact same** scorer as the server — never a drifting re-implementation. |
|
|
135
|
+
|
|
136
|
+
Install the four files into a directory the **server does not scan** (anything other than `plugin/` / `plugins/`) and register the entry in `tui.json`:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
# macOS / Linux
|
|
140
|
+
mkdir -p ~/.config/opencode/tui-plugins
|
|
141
|
+
cp tui/auth-load-balancer-tui.ts tui/auth-load-balancer-tui.view.tsx tui/auth-load-balancer-tui.logic.ts tui/auth-load-balancer-scoring.ts ~/.config/opencode/tui-plugins/
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
```powershell
|
|
145
|
+
# Windows
|
|
146
|
+
New-Item -ItemType Directory -Force -Path $env:USERPROFILE\.config\opencode\tui-plugins | Out-Null
|
|
147
|
+
Copy-Item tui\auth-load-balancer-tui.ts,tui\auth-load-balancer-tui.view.tsx,tui\auth-load-balancer-tui.logic.ts,tui\auth-load-balancer-scoring.ts $env:USERPROFILE\.config\opencode\tui-plugins\
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Then register the entry in `~/.config/opencode/tui.json` (this is how opencode loads TUI plugins) and restart:
|
|
151
|
+
|
|
152
|
+
```jsonc
|
|
153
|
+
{
|
|
154
|
+
"plugin": [
|
|
155
|
+
"file:///absolute/path/to/.config/opencode/tui-plugins/auth-load-balancer-tui.ts"
|
|
156
|
+
]
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
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.17.13` + `@opentui/solid` `0.4.2` (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
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Configuration
|
|
165
|
+
|
|
166
|
+
All knobs are environment variables with sane defaults.
|
|
167
|
+
|
|
168
|
+
| Variable | Default | Meaning |
|
|
169
|
+
|----------|---------|---------|
|
|
170
|
+
| `OPENCODE_AUTH_LB_HOURLY_INFLUENCE` | `0.5` | How much 5h headroom modulates the weekly-urgency score (0–1). |
|
|
171
|
+
| `OPENCODE_AUTH_LB_MIN_RESET_MS` | `300000` | Floor on time-to-reset (caps urgency near a reset). |
|
|
172
|
+
| `OPENCODE_AUTH_LB_WEEK_WINDOW_MS` | `604800000` | Baseline horizon used when a weekly reset time is unknown. |
|
|
173
|
+
| `OPENCODE_AUTH_LB_EXHAUSTED_AT` | `0.999` | Hard exhaustion: at/above this utilization an account is excluded. |
|
|
174
|
+
| `OPENCODE_AUTH_LB_MIGRATE_AT` | `0.95` | Soft threshold to proactively leave a pinned account (before 100%). |
|
|
175
|
+
| `OPENCODE_AUTH_LB_WEEKLY_DRAIN_TARGET` | `0.98` | Soft threshold for the WEEKLY window: scoring treats weekly quota as "fully drained" past this utilization, and a pinned session proactively migrates once its weekly util crosses it. The 5h window uses `MIGRATE_AT` (~0.95); the weekly window uses this (~0.98). Must be in (`MIGRATE_AT`, `EXHAUSTED_AT`]. |
|
|
176
|
+
| `OPENCODE_AUTH_LB_CHEAP_SWITCH_MAX_BYTES` | `65536` | For non-forced (proactive/drain) switches, only switch when the request body ≤ this many bytes, so a grown conversation isn't re-sent onto a fresh (uncached) account — a full per-account prompt-cache write. `0` disables the gate (always switch). A proactive switch bypasses this gate when the pinned account is within ~1% of hard exhaustion (a forced switch is imminent anyway and the context only grows); forced switches always ignore it. |
|
|
177
|
+
| `OPENCODE_AUTH_LB_DRAIN_MIGRATE` | `false` | Allow switching a healthy session to drain another account whose weekly window is about to reset. |
|
|
178
|
+
| `OPENCODE_AUTH_LB_DRAIN_MIGRATE_MARGIN` | `1.5` | Urgency factor required to justify a drain switch. |
|
|
179
|
+
| `OPENCODE_AUTH_LB_SESSION_TTL_MS` | `21600000` | Session→account assignments older than this are pruned. |
|
|
180
|
+
| `OPENCODE_AUTH_LB_MAX_WAIT_MS` | `305000` | When **every** account is rate-limited (a `429`/`402` cooldown), how long a single request may **block** waiting for the soonest account's cooldown to expire (honoring `Retry-After`) before auto-retrying — instead of failing the turn abruptly. A client abort (cancelling the turn) interrupts the wait immediately. Must exceed the 5-min account cooldown to cover a `429` with no `Retry-After`. `0` disables waiting (fail fast). Auth (`401`/`403`) errors are never waited on. |
|
|
181
|
+
| `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
|
+
| `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. |
|
|
183
|
+
| `OPENCODE_AUTH_LB_DIR` | — | Override the pool-file directory (handy for tests). |
|
|
184
|
+
| `OPENCODE_AUTH_LB_DEBUG` | — | `1`/`true` logs each selection to stderr. |
|
|
185
|
+
| `ANTHROPIC_BASE_URL` | — | Route Anthropic requests through a custom base URL. |
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Development
|
|
190
|
+
|
|
191
|
+
### Scripts
|
|
192
|
+
|
|
193
|
+
| Script | What it does |
|
|
194
|
+
|--------|--------------|
|
|
195
|
+
| `bun run build` | Bundle `src/index.ts` → `dist/index.js` + emit `.d.ts`. |
|
|
196
|
+
| `bun run dev` | Rebuild the bundle on every change (watch). |
|
|
197
|
+
| `bun run typecheck` | `tsc --noEmit`. |
|
|
198
|
+
| `bun run lint` | Lint with oxlint (DevFive shared config). |
|
|
199
|
+
| `bun run lint:fix` | Auto-fix lint + formatting. |
|
|
200
|
+
| `bun test` | Run the suite **with 100% coverage enforced**. |
|
|
201
|
+
| `bun run test:watch` | Re-run tests on change. |
|
|
202
|
+
| `bun run status` | Print the dashboard for the current pool. |
|
|
203
|
+
|
|
204
|
+
### Linting & formatting
|
|
205
|
+
|
|
206
|
+
[oxlint](https://oxc.rs) with the DevFive shared config — `oxlint.config.ts` re-exports `eslint-plugin-devup/oxlint-config` (single quotes, no semicolons, sorted imports, `interface` over `type`). A husky `pre-commit` hook runs `bun lint`.
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
bun run lint # check
|
|
210
|
+
bun run lint:fix # auto-fix
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Testing
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
bun test
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
The unit/integration suite runs with coverage **gated at 100%** (lines and functions) via `bunfig.toml`. They mock the network and isolate the pool file per test, so **no real accounts are needed** to test the logic. Tests live in `src/__tests__/`.
|
|
220
|
+
|
|
221
|
+
### Dev loop (trying it in a real opencode)
|
|
222
|
+
|
|
223
|
+
opencode loads plugins once at startup, so the loop is:
|
|
224
|
+
|
|
225
|
+
1. `bun run dev` — keeps `dist/index.js` rebuilt on every edit.
|
|
226
|
+
2. Symlink `dist/index.js` into your opencode project's `.opencode/plugins/` once (see install above).
|
|
227
|
+
3. Edit code → the watcher rebuilds → **restart opencode** to reload the plugin.
|
|
228
|
+
4. Use it; watch the toasts, run `bun run status`, and set `OPENCODE_AUTH_LB_DEBUG=1` to log selections.
|
|
229
|
+
|
|
230
|
+
### Project structure
|
|
231
|
+
|
|
232
|
+
```
|
|
233
|
+
src/
|
|
234
|
+
index.ts # plugin entry: 3 exports (Anthropic, OpenAI, Status-tool)
|
|
235
|
+
fetch.ts # load-balanced fetch — the per-request choke point
|
|
236
|
+
refresh.ts # singleflight OAuth refresh, invalid_grant handling
|
|
237
|
+
accounts.ts # append / bootstrap accounts into the pool
|
|
238
|
+
session.ts # derive a stable session key (affinity)
|
|
239
|
+
types.ts # provider-agnostic data model (accounts, usage windows, pool file)
|
|
240
|
+
usage-merge.ts # fixed weekly-anchor preservation / roll-forward
|
|
241
|
+
util.ts # shared helpers (sleep, clamp01, JSON guards)
|
|
242
|
+
status.ts # ranked status model + text renderer
|
|
243
|
+
notify.ts # toast on account switch
|
|
244
|
+
usage-refresh.ts # cold-start usage seeding via the usage endpoint
|
|
245
|
+
prime.ts # point the in-use marker at the top-ranked account at startup
|
|
246
|
+
scheduler/ # config, score-core (shared scorer), select
|
|
247
|
+
pool/ # data-dir resolution + atomic, serialized pool store
|
|
248
|
+
providers/ # ProviderAdapter contract + headers
|
|
249
|
+
anthropic/ # Claude OAuth + Claude Code request transforms + usage
|
|
250
|
+
openai/ # ChatGPT/Codex OAuth + Responses transforms + usage
|
|
251
|
+
cli/status.ts # `bun run status`
|
|
252
|
+
__tests__/ # all tests
|
|
253
|
+
tui/
|
|
254
|
+
auth-load-balancer-tui.ts # TUI plugin ENTRY (registered in tui.json; no JSX)
|
|
255
|
+
auth-load-balancer-tui.view.tsx # SolidJS view (lazily imported; app_bottom + sidebar slots)
|
|
256
|
+
auth-load-balancer-tui.logic.ts # pure pool-file logic + display helpers (unit-tested)
|
|
257
|
+
auth-load-balancer-scoring.ts # byte copy of src/scheduler/score-core.ts (shared scorer)
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## How it works
|
|
263
|
+
|
|
264
|
+
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
|
+
|
|
266
|
+
1. derive a session key (from opencode's session id, or a hash of the request prefix);
|
|
267
|
+
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
|
+
3. refresh the OAuth token if needed (singleflight, rotated token persisted);
|
|
269
|
+
4. apply provider-specific auth + request transforms (Claude Code identity / Codex Responses quirks);
|
|
270
|
+
5. send the request, then record usage from the response headers;
|
|
271
|
+
6. on `429`/auth errors, cool the account down and try the next; on success, re-pin the session and toast if the account changed.
|
|
272
|
+
|
|
273
|
+
The pool is its own JSON file (opencode's native auth store holds only one credential per provider), written atomically and serialized by an in-process mutex.
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## Limitations
|
|
278
|
+
|
|
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.17.13` 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
|
+
- **OpenAI/Codex** assumes the Responses API; chat-completions → responses conversion is out of scope.
|
|
281
|
+
- **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
|
+
- **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
|
+
- Live OAuth/login and real-account end-to-end behavior should be smoke-tested in your environment; the test suite mocks the network.
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## License
|
|
288
|
+
|
|
289
|
+
MIT
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type PoolAccount, type TokenSet } from './types';
|
|
2
|
+
/** The credential getter opencode passes to an auth loader. */
|
|
3
|
+
export type OpencodeAuthGetter = () => Promise<{
|
|
4
|
+
type: string;
|
|
5
|
+
access?: string;
|
|
6
|
+
refresh?: string;
|
|
7
|
+
expires?: number;
|
|
8
|
+
}>;
|
|
9
|
+
/**
|
|
10
|
+
* Append a freshly-authorized account to the pool, deduped by refresh token
|
|
11
|
+
* or provider account id. Re-authorizing the same account refreshes its
|
|
12
|
+
* tokens instead of duplicating it.
|
|
13
|
+
*/
|
|
14
|
+
export declare function addAccount(providerID: string, tokens: TokenSet, label?: string): Promise<PoolAccount>;
|
|
15
|
+
/**
|
|
16
|
+
* Seed the pool from opencode's existing single-slot OAuth credential (e.g. left by
|
|
17
|
+
* the single-account anthropic-auth plugin) so the user keeps working without
|
|
18
|
+
* re-login. No-op once the pool already has an account for this provider.
|
|
19
|
+
*/
|
|
20
|
+
export declare function bootstrapFromOpencodeAuth(providerID: string, getAuth: OpencodeAuthGetter): Promise<void>;
|
package/dist/fetch.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ProviderAdapter } from './providers/types';
|
|
2
|
+
import type { PoolAccount } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Run a post-response bookkeeping write that must NEVER fail the request it follows.
|
|
5
|
+
* Usage/cooldown/session writes are best-effort: if the cross-process pool lock times
|
|
6
|
+
* out, the pool file cannot be read, or an atomic write fails, skip silently (the next
|
|
7
|
+
* request self-corrects) rather than discarding an already-served response.
|
|
8
|
+
* Non-infrastructure errors still propagate.
|
|
9
|
+
*/
|
|
10
|
+
export declare function bestEffort(what: string, op: () => Promise<unknown>): Promise<void>;
|
|
11
|
+
interface FetchHooks {
|
|
12
|
+
/** Called with the account that successfully served a request (for toasts/logs). */
|
|
13
|
+
onUse?: (providerID: string, account: PoolAccount) => void;
|
|
14
|
+
/**
|
|
15
|
+
* Called on a successful request whose model was auto-downgraded to a fallback
|
|
16
|
+
* (Opus/Fable→Sonnet) because the requested MODEL TIER's weekly cap is
|
|
17
|
+
* exhausted on every candidate account. Drives the user-facing "model
|
|
18
|
+
* switched" toast so the downgrade is never silent.
|
|
19
|
+
*/
|
|
20
|
+
onModelFallback?: (providerID: string, account: PoolAccount, fromModel: string, toModel: string, fromTier?: string) => void;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Build a `fetch`-compatible function that load-balances every request for one
|
|
24
|
+
* provider across the pooled accounts. Returned from an adapter's auth.loader and
|
|
25
|
+
* handed to opencode's provider SDK, so it sits on the path of every API call.
|
|
26
|
+
*
|
|
27
|
+
* Per call: derive the session key -> pick the session's pinned account (or, if it
|
|
28
|
+
* is unavailable, the highest weekly-urgency account) -> ensure a fresh token ->
|
|
29
|
+
* apply auth + body/url transforms -> fetch -> record usage. On a rate-limit/auth
|
|
30
|
+
* error the account is cooled down and the next-best account is tried; a successful
|
|
31
|
+
* response re-pins the session to the account that served it and reports it via onUse.
|
|
32
|
+
*/
|
|
33
|
+
export declare function createLoadBalancedFetch(adapter: ProviderAdapter, hooks?: FetchHooks, models?: readonly string[]): typeof fetch;
|
|
34
|
+
export {};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Plugin } from '@opencode-ai/plugin';
|
|
2
|
+
export declare const AnthropicLoadBalancerPlugin: Plugin;
|
|
3
|
+
export declare const OpenAILoadBalancerPlugin: Plugin;
|
|
4
|
+
/**
|
|
5
|
+
* A standalone plugin that registers the `auth_lb_status` tool — an on-demand
|
|
6
|
+
* dashboard (in-use account per provider, each account's weekly/5h usage,
|
|
7
|
+
* cooldowns, and the ranked next candidates) across ALL providers. Registered once
|
|
8
|
+
* (not per provider) so the tool name doesn't collide.
|
|
9
|
+
*/
|
|
10
|
+
export declare const AuthLoadBalancerStatusPlugin: Plugin;
|