opencode-auth-load-balancer 0.5.1 → 0.7.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/README.md +152 -12
- package/dist/accounts.d.ts +22 -2
- package/dist/index.d.ts +7 -12
- package/dist/index.js +46026 -14563
- package/dist/legacy.d.ts +25 -0
- package/dist/login-health.d.ts +12 -0
- package/dist/notify.d.ts +6 -0
- package/dist/pairing.d.ts +22 -0
- package/dist/pending/coordinator.d.ts +5 -1
- package/dist/pending/v2.d.ts +3 -0
- package/dist/pool/paths.d.ts +8 -0
- package/dist/providers/anthropic/constants.d.ts +21 -0
- package/dist/providers/anthropic/oauth.d.ts +9 -0
- package/dist/providers/anthropic/setup-token.d.ts +14 -0
- package/dist/providers/anthropic/usage.d.ts +13 -0
- package/dist/providers/types.d.ts +27 -0
- package/dist/refresh.d.ts +12 -0
- package/dist/status.d.ts +6 -0
- package/dist/sync/apply.d.ts +66 -0
- package/dist/sync/crypto.d.ts +19 -0
- package/dist/sync/cycle.d.ts +19 -0
- package/dist/sync/engine.d.ts +44 -0
- package/dist/sync/errors.d.ts +9 -0
- package/dist/sync/gist.d.ts +33 -0
- package/dist/sync/github-auth.d.ts +15 -0
- package/dist/sync/intent.d.ts +29 -0
- package/dist/sync/loop.d.ts +40 -0
- package/dist/sync/merge.d.ts +49 -0
- package/dist/sync/origin.d.ts +2 -0
- package/dist/sync/own.d.ts +31 -0
- package/dist/sync/payload.d.ts +50 -0
- package/dist/sync/start.d.ts +5 -0
- package/dist/sync/state.d.ts +79 -0
- package/dist/sync/timing.d.ts +4 -0
- package/dist/token-mint.d.ts +25 -0
- package/dist/types.d.ts +75 -0
- package/dist/util.d.ts +7 -0
- package/dist/v1.d.ts +1 -0
- package/dist/v1.js +18393 -0
- package/dist/v2-rpc.d.ts +62 -0
- package/dist/v2.d.ts +2 -0
- package/index.js +2 -0
- package/package.json +46 -8
- package/tui/auth-load-balancer-rpc.ts +60 -0
- package/tui/auth-load-balancer-tui.logic.ts +363 -7
- package/tui/auth-load-balancer-tui.view.tsx +299 -107
- package/tui/auth-load-balancer-v2.ts +10 -0
- package/tui/auth-load-balancer-v2.view.tsx +242 -0
- package/tui.ts +2 -0
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ Selection is **not** round-robin. It is weighted primarily by **weekly** usage,
|
|
|
8
8
|
|
|
9
9
|
## Features
|
|
10
10
|
|
|
11
|
-
- **Account pool** — register many Claude / Codex / Kimi Code accounts (Kimi also by API key); the plugin manages and rotates them.
|
|
11
|
+
- **Account pool** — register many Claude / Codex / Kimi Code accounts (Claude also by a long-lived `claude setup-token` token, which a Claude OAuth login now mints itself; Kimi also by API key); the plugin manages and rotates them.
|
|
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`.
|
|
@@ -18,6 +18,7 @@ Selection is **not** round-robin. It is weighted primarily by **weekly** usage,
|
|
|
18
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).
|
|
19
19
|
- **Single-use refresh-token safety** — per-account singleflight refresh; rotated tokens are persisted immediately.
|
|
20
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.
|
|
21
|
+
- **Gist sync of static credentials** — machines share their Claude tokens and Kimi API keys, encrypted, through a secret GitHub gist, both ways: every machine that follows the link downloads, and one with a GitHub token also uploads its own. See [Sync static credentials between machines](#sync-static-credentials-between-machines-github-gist).
|
|
21
22
|
|
|
22
23
|
---
|
|
23
24
|
|
|
@@ -26,18 +27,68 @@ Selection is **not** round-robin. It is weighted primarily by **weekly** usage,
|
|
|
26
27
|
- [Bun](https://bun.com) ≥ 1.3
|
|
27
28
|
- opencode (TUI), with the Anthropic, OpenAI, and/or Kimi For Coding providers available
|
|
28
29
|
|
|
30
|
+
### OpenCode 2 compatibility
|
|
31
|
+
|
|
32
|
+
The main entry targets **OpenCode 2.x**, verified against **2.0.27**. OpenCode 2
|
|
33
|
+
uses a default `setup` entry, integration authentication methods, and domain
|
|
34
|
+
transforms instead of the V1 `auth.loader` and `chat.headers` hooks.
|
|
35
|
+
The existing account pool, scheduling, token refresh, fallback and durable
|
|
36
|
+
pending files are reused; accounts do not need to be registered again.
|
|
37
|
+
|
|
38
|
+
Configure the **package directory** in `opencode.json` after building:
|
|
39
|
+
|
|
40
|
+
```jsonc
|
|
41
|
+
{
|
|
42
|
+
"$schema": "https://opencode.ai/config.json",
|
|
43
|
+
"plugins": ["C:/path/to/opencode-auth-load-balancer"]
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
For a published npm release, use `"plugins": ["opencode-auth-load-balancer"]`.
|
|
48
|
+
The package's `./tui` entry loads the V2 footer and sidebar automatically.
|
|
49
|
+
A remote terminal can also load it through `cli.json`'s `plugins` list. Status,
|
|
50
|
+
notifications and account management use server RPC, so the terminal never
|
|
51
|
+
needs access to the server's credential files.
|
|
52
|
+
|
|
53
|
+
Remove the previous V1 server entry and separately registered V1 TUI entry when
|
|
54
|
+
switching to V2. Its configuration key is `plugins` (plural); V1's `plugin`
|
|
55
|
+
entry and `tui.json` extension use different host APIs. Explicit local V2 entries
|
|
56
|
+
must point to a directory, not a copied `dist/index.js` file.
|
|
57
|
+
|
|
58
|
+
V2 keeps HTTP transport for pooled providers, including Codex's Responses API,
|
|
59
|
+
so requests pass through account rotation and usage tracking. Providers without
|
|
60
|
+
a pooled account keep their normal native driver. V2 can resume an unfinished
|
|
61
|
+
pending turn by its original durable prompt ID after a restart.
|
|
62
|
+
|
|
63
|
+
For **OpenCode 1.x**, use `dist/v1.js` as the local server plugin and the existing
|
|
64
|
+
`tui/auth-load-balancer-tui.ts` entry in `tui.json`. The instructions below about
|
|
65
|
+
copying a standalone bundle and registering TUI files describe this V1 setup.
|
|
66
|
+
The V2 sidebar supports rename, enable/disable, delete, and re-login/pairing;
|
|
67
|
+
the existing Gist sync engine runs on the server, but its V1 sidebar controls
|
|
68
|
+
have not yet been ported to the V2 UI.
|
|
69
|
+
|
|
70
|
+
Verification:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
bun run typecheck
|
|
74
|
+
bun test
|
|
75
|
+
bun run build
|
|
76
|
+
bun run scripts/smoke-v2.ts # isolated home/config/database/pool, requires opencode 2
|
|
77
|
+
```
|
|
78
|
+
|
|
29
79
|
---
|
|
30
80
|
|
|
31
81
|
## Install & build
|
|
32
82
|
|
|
33
83
|
```bash
|
|
34
84
|
bun install
|
|
35
|
-
bun run build #
|
|
85
|
+
bun run build # V2: dist/index.js; V1: dist/v1.js
|
|
36
86
|
```
|
|
37
87
|
|
|
38
|
-
The bundle imports only Node built-ins
|
|
88
|
+
The V1 bundle imports only Node built-ins and can be installed as one file.
|
|
89
|
+
OpenCode 2 should load the package directory, which also includes its TUI entry.
|
|
39
90
|
|
|
40
|
-
### Load it into
|
|
91
|
+
### Load it into OpenCode 1 (local / dev)
|
|
41
92
|
|
|
42
93
|
opencode auto-loads any `.ts`/`.js` file in a **plugins directory**:
|
|
43
94
|
|
|
@@ -49,13 +100,13 @@ Copy or symlink the built bundle into your opencode project's plugin dir:
|
|
|
49
100
|
```bash
|
|
50
101
|
# macOS / Linux — symlink so rebuilds are picked up automatically
|
|
51
102
|
mkdir -p .opencode/plugins
|
|
52
|
-
ln -sf "$(pwd)/dist/
|
|
103
|
+
ln -sf "$(pwd)/dist/v1.js" .opencode/plugins/auth-load-balancer.js
|
|
53
104
|
```
|
|
54
105
|
|
|
55
106
|
```powershell
|
|
56
107
|
# Windows (PowerShell) — copy (symlinks need Developer Mode / admin)
|
|
57
108
|
New-Item -ItemType Directory -Force -Path .opencode\plugins | Out-Null
|
|
58
|
-
Copy-Item dist\
|
|
109
|
+
Copy-Item dist\v1.js .opencode\plugins\auth-load-balancer.js
|
|
59
110
|
```
|
|
60
111
|
|
|
61
112
|
Restart opencode to load it. (opencode does **not** hot-reload plugins — see the dev loop below.)
|
|
@@ -66,7 +117,7 @@ Restart opencode to load it. (opencode does **not** hot-reload plugins — see t
|
|
|
66
117
|
// opencode.json
|
|
67
118
|
{
|
|
68
119
|
"$schema": "https://opencode.ai/config.json",
|
|
69
|
-
"
|
|
120
|
+
"plugins": ["opencode-auth-load-balancer"]
|
|
70
121
|
}
|
|
71
122
|
```
|
|
72
123
|
|
|
@@ -98,8 +149,39 @@ Durable turn references live beside the pool as `auth-load-balancer-pending.json
|
|
|
98
149
|
|
|
99
150
|
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
151
|
|
|
152
|
+
[Gist sync](#sync-static-credentials-between-machines-github-gist) adds four more, none of them part of the pool: `auth-load-balancer-sync.json` (gist id, the **encryption key**, and which rows were imported — treat it like the pool file), `auth-load-balancer-sync-origin.json` (this machine's random id in the gist; not secret), `auth-load-balancer-sync-status.json` (the latest outcome, for the TUI; no secrets) and `auth-load-balancer-sync-intent.json` (a one-shot request from the TUI, deleted the moment the plugin reads it).
|
|
153
|
+
|
|
101
154
|
> **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.
|
|
102
155
|
|
|
156
|
+
### Claude long-lived token (`claude setup-token`)
|
|
157
|
+
|
|
158
|
+
An OAuth login lives on a refresh token that rotates on every refresh, and once it is revoked the row drops to `re-login`. Claude Code's `claude setup-token` mints a token valid for a year that never refreshes, so it cannot be logged out that way. A Claude account can hold both on **one** row, each with one job:
|
|
159
|
+
|
|
160
|
+
- **setup-token → inference.** Every request is sent with it; the OAuth login is never refreshed on the request path.
|
|
161
|
+
- **OAuth login → usage.** It polls Anthropic's usage endpoint, which a setup-token cannot use (it is scoped to inference only, and the endpoint answers it `403 OAuth token does not meet scope requirement user:profile`).
|
|
162
|
+
|
|
163
|
+
**An OAuth login brings its own.** Once an OAuth login (**"Claude Pro/Max (add account to load balancer)"**) lands, the plugin mints the account its token, with no Claude Code and no second approval, and the row is tagged `token+oauth` right away (see **Minted tokens** below). To paste one instead:
|
|
164
|
+
|
|
165
|
+
1. Run `claude setup-token` and approve it in the browser; it prints a token (`sk-ant-oat01-…`).
|
|
166
|
+
2. `opencode auth login` → **Anthropic** → **"Claude Pro/Max setup-token (add account to load balancer)"**, and paste the token at the prompt. The CLI labels it "authorization code", but paste the token; line breaks from a wrapped terminal are ignored.
|
|
167
|
+
3. Optionally add the OAuth login too (**"Claude Pro/Max (add account to load balancer)"**), before or after the token.
|
|
168
|
+
|
|
169
|
+
**Pairing.** The token is checked with one request before it joins the pool (the same request seeds its usage), and that request names the organization the token acts for and when the account's usage windows reset. A setup-token has no account identity beyond that. An OAuth login is measured the same way before it lands, so the two halves pair automatically only when they belong to exactly one Claude row lacking that half. The organization must match, and the reset times must not disagree: the weekly window resets at a fixed per-account anchor, and a live 5h window starts with the account's own first request. That is exact for personal Pro/Max accounts (one organization each), and it tells Team/Enterprise seats apart whenever their reset times differ. A working credential is replaced only when the reset times confirm the same account, as with a token renewed before its year is up. To pair explicitly, click the row in the TUI sidebar: a Claude row offers one item per login (**Re-login OAuth / Add OAuth login**, **Replace / Re-login / Add setup-token**). Every completed pair is toasted, and the dashboards tag such rows `token` or `token+oauth`.
|
|
170
|
+
|
|
171
|
+
**When one half stops working**, the row stays in rotation on the other. The dashboards show why, with the server's own reason (e.g. `400 invalid_grant: …`, `401 authentication_error: OAuth token has expired`) as a `!` line under the `auth_lb_status` table and under the row in the TUI sidebar:
|
|
172
|
+
|
|
173
|
+
| What happened | Result |
|
|
174
|
+
|---|---|
|
|
175
|
+
| The OAuth login is revoked or expires | The row keeps serving inference on its token and is marked **`oauth re-login`** — in the dashboard state, in the TUI sidebar (with a **Re-login OAuth** menu item), and in the bottom bar — until an OAuth login is paired again. Meanwhile usage falls back to the response headers and, while the account sits idle, Claude Code's own quota check: a one-token `claude-haiku-4-5` request, sent at most once per 5 minutes per account. |
|
|
176
|
+
| The token is revoked or expires (after a year) | The token is dropped, the same request is retried on the OAuth login, and the row is marked **`token re-login`** until a token is added again — with minting on, by the next request, which mints a new one in the background. |
|
|
177
|
+
| Both are gone, or a token-only row's token answers `401` | The row switches to `re-login`; the TUI's re-login asks for a new token on a token-only row. |
|
|
178
|
+
|
|
179
|
+
**OAuth logins expire on their own.** Anthropic gives an OAuth login a fixed lifetime that refreshes do not extend: `refresh_token_expires_in` when the token endpoint states it, otherwise the 30 days Claude Code itself assumes. In its last 3 days a row shows **`oauth expires Nd`**, mirroring Claude Code's own "Your login expires in N days" notice. Re-login OAuth then, or pair a setup-token so the expiry only costs usage measurement.
|
|
180
|
+
|
|
181
|
+
**Minted tokens.** The plugin asks for the token with the refresh grant Claude Code's own `claude auth login` uses to mint its one-year token: the OAuth login's refresh token, with `scope=user:inference` and `expires_in=31536000`, the scope and lifetime `claude setup-token` asks for. Rows already in the pool are minted theirs when opencode starts, and a row whose token was dropped gets a new one on its next request. A minted token is renewed in its last 30 days while its OAuth login works; a pasted token's lifetime is unknown, so it is replaced only once it is dropped. The grant spends the refresh token like any refresh, under the same lock, and the row keeps the rotated one. A refused grant changes nothing (a dead login is left for the regular refresh to find) and is retried at most every 6 hours per opencode process, and a token granted less than 60 days is not pooled. Set `OPENCODE_AUTH_LB_ANTHROPIC_AUTO_TOKEN=0` to turn minting off.
|
|
182
|
+
|
|
183
|
+
> Not yet verified against a live account: whether a minted token keeps working once the OAuth login it came from is revoked or expires. A pasted `claude setup-token` comes from its own approval, independent of any OAuth login. If a minted token dies with its login, the row goes to `re-login` as in the table above, and pasting a `claude setup-token` still gives it an independent token.
|
|
184
|
+
|
|
103
185
|
### Kimi Code
|
|
104
186
|
|
|
105
187
|
A Kimi Code subscription joins the pool either through an **OAuth sign-in** (device code) or as a **static API key**. The plugin hooks both deployments in opencode's model catalog, each with its own pool: **Kimi For Coding (kimi.com)** (`kimi-code-plan-cn`, `api.kimi.com/coding/v1`, sign-in at `auth.kimi.com`) and **Kimi For Coding (kimi.ai)** (`kimi-code-plan-global`, `api.kimi.ai/coding/v1`, sign-in at `auth.kimi.ai`).
|
|
@@ -113,6 +195,56 @@ Every login is checked against the account behind it (`GET /me`), so one subscri
|
|
|
113
195
|
|
|
114
196
|
The sign-in identifies itself to Kimi's OAuth host the way Kimi's own clients do, with `X-Msh-*` headers: platform `opencode_auth_load_balancer`, this machine's host name and OS, and a device id hashed from the host name and home directory.
|
|
115
197
|
|
|
198
|
+
### Sync static credentials between machines (GitHub gist)
|
|
199
|
+
|
|
200
|
+
Logging in on every machine is tedious, and for Claude every OAuth login is an approval in the browser. Machines can share their *static* credentials — the long-lived Claude tokens (pasted, or minted by this plugin) and Kimi API keys — through one secret GitHub gist, **both ways**: a token minted on any machine reaches the others, and a token removed on the machine that owns it leaves them.
|
|
201
|
+
|
|
202
|
+
**Members.** A machine joins a gist by creating it (**Create a new secret gist…**) or by following its link (**Follow a gist link**). Every member **always downloads and applies** the gist (at start, every 15 minutes, and on **Sync now**). A member also **uploads its own credentials** when it can: when a GitHub token is discoverable (`GITHUB_TOKEN`, `GH_TOKEN`, or a logged-in [GitHub CLI](https://cli.github.com), whose `gh auth token` is run) *and* that GitHub account may write the gist. A machine with only the link — no GitHub account, no login — downloads and never writes. The sidebar says which it is: `syncing (upload + download)`, `syncing (download only: no GitHub login)`, `syncing (download only: this GitHub account cannot update the gist)`.
|
|
203
|
+
|
|
204
|
+
**What syncs, and what never does.**
|
|
205
|
+
|
|
206
|
+
| Syncs | Never syncs |
|
|
207
|
+
|---|---|
|
|
208
|
+
| Claude `inferenceToken` (and its `inferenceExpires`) | OAuth access and refresh tokens — Claude, Codex, Kimi sign-in |
|
|
209
|
+
| Kimi Code API keys (a row with the static-credential expiry and no refresh token — never an OAuth sign-in, even one that left no refresh token) | `refreshExpires`, usage windows, cooldowns, sessions, `tokenGen`, pending turns |
|
|
210
|
+
| Each row's label and provider, so it can be registered | |
|
|
211
|
+
|
|
212
|
+
OAuth refresh tokens are single-use and rotate on every refresh; two machines sharing one would race, and the loser kills the token family for both. So every machine keeps its **own** OAuth logins, and only the credentials that cannot rotate are shared.
|
|
213
|
+
|
|
214
|
+
**Who owns what (origins).** Every machine has a random **origin** (128 bits, kept in `auth-load-balancer-sync-origin.json`; not secret, not derived from the host name; it survives *Stop syncing*, so rejoining lists as the same machine). An entry in the gist is identified by *(origin, row id)* and is added, changed and removed **only by the machine with that origin**: a machine never deletes another machine's entry. A row's static credential counts as this machine's **own** — and is uploaded — unless sync itself put it there (it is then *imported*, and travels from the machine that has it, never back). If another machine with a smaller origin already lists the same secret, this one does not list a second copy; if that machine later removes it while this one still holds it, this one takes over listing it.
|
|
215
|
+
|
|
216
|
+
**What each machine does with the others' entries.** A token is verified with the same probe as a pasted `claude setup-token`, then registered on this machine's own OAuth row for that organization if it has one (the usual pairing rules, described above), else as a token-only row. A Kimi key becomes a key row; if this machine is already signed in to that Kimi account by OAuth, that sign-in is left alone. **An account that already has a working token on this machine is never given a second one, and its token is never replaced**: two machines that each minted a token for the same account keep *both* (each uses its own), and an incoming token for it is skipped as redundant. The skip is remembered together with the row that was in the way, so the entry is not probed again every sync; the moment that row loses its token or goes (for example the token it shared was removed on its owner), the skipped entry is imported in the same sync. A machine with no row for the account at all gets exactly one token for it, whichever entry comes first. (This relies on the organization the probe reports: a Team/Enterprise organization with several seats, or a probe that reports none, cannot be matched to one account, and then the token gets a row of its own.)
|
|
217
|
+
|
|
218
|
+
**One cycle.** Each sync cycle (download, then upload) is in this order: (1) read the **current** gist — with the GitHub token when there is one, which avoids the 60/hour anonymous limit; the token goes only to `api.github.com`; (2) **apply** that snapshot locally; (3) only if this machine may write: list *every other origin's entries untouched* plus this machine's own current entries; (4) PATCH **only if that list differs from what the gist holds**. A machine therefore never uploads on top of a snapshot it has not merged, and an idle machine writes nothing. A write is made a few seconds after this machine's own static credentials change (a token minted, a key added, a row removed), or on **Upload now**. Each write is stamped later than the snapshot it was based on and than this machine's last write, so a machine whose clock runs behind never produces a snapshot the others refuse as "older"; a snapshot older than one a machine already applied is still refused (a gist owner restoring an old ciphertext cannot roll anyone back) — but a member that can write repairs such a gist by writing a newer one over it, and a link-only machine waits for that.
|
|
219
|
+
|
|
220
|
+
**When a machine cannot write.** There is no extra permission check: the PATCH is attempted, and a `403`/`404` answer means *this GitHub account cannot update the gist* (only the gist's owner account can). The machine then says so once, stops trying for an hour (the back-off is kept in the sync state; **Upload now** tries anyway), and goes on downloading. No GitHub token at all is a quiet download-only state — no message every cycle, only the sidebar line. If the gist holds entries this version cannot read (a newer plugin added a provider), the machine **does not write** — it would silently drop them — and says once: *update this plugin to upload*.
|
|
221
|
+
|
|
222
|
+
**Two machines writing at once.** A gist has no compare-and-swap, so if two machines write between each other's reads, the later PATCH wins and the earlier one's change is lost from the gist. This heals by itself: every machine re-asserts *its own* entries each cycle (the next one is within 15 minutes, or sooner for a machine whose credentials changed), so a lost entry is written back, and an entry a machine removed that a stale write brought back is removed again — a machine is authoritative for its own origin, and only its own. In the meantime other machines just see the older list.
|
|
223
|
+
|
|
224
|
+
**Why two machines holding tokens for the same account keep both.** Replacing one token with the other would be correct for one cycle and wrong for the next: each machine would see the other's token as "newer" and take it, for ever. Each machine keeps the token it minted or pasted, shares it, and ignores the other's for that account. A machine that has *no* token for an account (a link-only machine, or one whose token was removed) takes the first one it sees.
|
|
225
|
+
|
|
226
|
+
**Encryption.** The payload is encrypted with AES-256-GCM (`node:crypto`) under a random 32-byte key, a fresh random nonce on every upload, and the format version authenticated. The key lives only in the link's fragment — `https://gist.github.com/<user>/<id>#<key>` — and a URL fragment is never sent to a server, so GitHub holds ciphertext only. (That also keeps GitHub's secret scanning, and Anthropic's scanning partnership, from seeing a plaintext `sk-ant-…` token and revoking it.) The gist is created **secret** (unlisted, not private: anyone with the URL can read the ciphertext, which is why the key matters). The key is shared by everyone who follows the link, and every member of the gist can read every entry.
|
|
227
|
+
|
|
228
|
+
> **The link is the secret, and deleting the gist does not take it back.** Anyone who has the whole link, `#key` included, can read these credentials, and anyone who has already read them keeps what they read: deleting the gist or uploading to a new one (new key, new link) only stops *later* reads. If a link may have leaked, treat every credential in it as exposed. Replace the Kimi API keys in Kimi's console (the old ones then stop working). For Claude tokens, this plugin has no way to revoke a setup-token or a minted token and cannot verify that one was revoked elsewhere: check your Anthropic account for what is available, and until you have, assume the token still works. Then delete the gist and create a new one so the next snapshot is not readable with the old link.
|
|
229
|
+
|
|
230
|
+
**Set up.**
|
|
231
|
+
|
|
232
|
+
1. On the machine that has the credentials and a GitHub token: click **Gist sync** at the bottom of the account list in the sidebar → **Create a new secret gist from this pool's static credentials**. The share link is shown in a dialog, once, because you asked for it; **Show the share link** brings it back on this machine only (the machine that created the gist is the only one that has it to show).
|
|
233
|
+
2. On every other machine: **Gist sync** → **Follow a gist link**, and paste the link. That is all; with a GitHub token on that machine it uploads its own credentials too, without one it only downloads.
|
|
234
|
+
|
|
235
|
+
**Leaving.**
|
|
236
|
+
|
|
237
|
+
- An entry that **disappears** from the gist is removed locally **only if it was imported from that gist**. A paired row keeps its own OAuth login and loses just the imported token; a row that existed only for that credential goes. Rows you created yourself are never deleted, and a credential you already held locally is left as yours.
|
|
238
|
+
- If you deleted or re-pointed an imported row yourself, the next sync does not undo it until the gist's token itself changes.
|
|
239
|
+
- An unreachable, deleted, rotated (new key), tampered, or other-version gist **leaves the pool exactly as it was** and shows one short line in the sidebar. Nothing falls back to plaintext. A gist written by the previous (one-way, version 1) format is reported as written by another version; create a new one.
|
|
240
|
+
- **Stop syncing** forgets the link (and the key) on this machine and nothing else: accounts already imported stay in the pool as ordinary rows, **which this machine will then list as its own if it rejoins** (the origin is kept, so rejoining the same gist does not duplicate entries, but the formerly imported credentials now travel from here).
|
|
241
|
+
|
|
242
|
+
**Limits.** An unauthenticated download counts against GitHub's 60 requests/hour/IP limit; a poll every 15 minutes uses 4 an hour, and `ETag`/`If-None-Match` makes an unchanged gist a cheap `304` (a machine with a GitHub token reads authenticated, which is not limited that way). On a rate limit (`403`/`429`) the plugin waits as long as GitHub says (at least a minute, at most an hour) instead of retrying; a deleted gist (`404`) is reported and left for the next poll. When the next cycle is due, and any back-off, is kept in the sync-state file, so several opencode windows (and restarts) on one machine share one schedule and one lock: only one of them fetches, and any one of them serves the TUI's request. A response larger than 1 MiB is refused unread. The gist holds at most 64 entries in total. Set `OPENCODE_AUTH_LB_SYNC=0` to turn sync off entirely.
|
|
243
|
+
|
|
244
|
+
**Deliberately not an agent tool.** The link embeds the key, so a tool the model could call would put it in the chat history and in every model request. Sync is driven from the TUI sidebar only, the link is never logged, toasted, or written to the pool or pending files (the sync-state file holds it, owner-only where the OS supports modes), and the one-shot request file that carries a pasted link from the TUI to the plugin is deleted as soon as it is read. Until it is read that file holds the link, key included: the TUI withdraws a request nobody answered within 25 seconds, a request older than 10 minutes (or stamped more than a minute ahead) is ignored and deleted, and a server that is busy with another window's run leaves it on disk rather than losing it. On Windows the file mode `0600` gives no ACL guarantee; its protection is the data directory's inherited ACL (the same as the pool file's), so keep that directory private.
|
|
245
|
+
|
|
246
|
+
> Not verified against a live GitHub account or a real second machine: the suite and the bundle run against an in-memory (and, for the end-to-end script, file-backed) fake of the gist API. Also unverified, as in [Minted tokens](#claude-long-lived-token-claude-setup-token): whether a minted Claude token keeps working once the OAuth login it came from is revoked — a machine that gets such a token inherits that uncertainty. The TUI rendering of the new sidebar lines and menu has been type-checked and unit-tested, not rendered in a live opencode.
|
|
247
|
+
|
|
116
248
|
---
|
|
117
249
|
|
|
118
250
|
## See which account is in use
|
|
@@ -157,7 +289,7 @@ A SolidJS TUI plugin renders a persistent bottom status bar (opencode's always-v
|
|
|
157
289
|
|------|------|
|
|
158
290
|
| [`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. |
|
|
159
291
|
| [`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}`. |
|
|
160
|
-
| [`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`. |
|
|
292
|
+
| [`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, the gist-sync request/status files, 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`. |
|
|
161
293
|
| [`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. |
|
|
162
294
|
|
|
163
295
|
Install the four files into a directory the **server does not scan** (anything other than `plugin/` / `plugins/`) and register the entry in `tui.json`:
|
|
@@ -184,7 +316,7 @@ Then register the entry in `~/.config/opencode/tui.json` (this is how opencode l
|
|
|
184
316
|
}
|
|
185
317
|
```
|
|
186
318
|
|
|
187
|
-
> **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`
|
|
319
|
+
> **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` originally; development dependencies now use `0.5.17` for V2 compatibility. 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.)
|
|
188
320
|
|
|
189
321
|
---
|
|
190
322
|
|
|
@@ -207,7 +339,10 @@ All knobs are environment variables with sane defaults.
|
|
|
207
339
|
| `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. |
|
|
208
340
|
| `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. |
|
|
209
341
|
| `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. |
|
|
342
|
+
| `OPENCODE_AUTH_LB_ANTHROPIC_AUTO_TOKEN` | `true` | Mint each Claude OAuth login a long-lived inference token (what `claude setup-token` prints) and renew it in its last 30 days — see [Claude long-lived token](#claude-long-lived-token-claude-setup-token). `0` / `false` / `no` / `off` turns minting off; tokens already minted stay. |
|
|
210
343
|
| `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. |
|
|
344
|
+
| `OPENCODE_AUTH_LB_SYNC` | `true` | The [gist sync](#sync-static-credentials-between-machines-github-gist) schedule (a cycle every 15 minutes, one after this machine's own credentials change, serve the TUI's requests). `0` / `false` / `no` / `off` turns it off. |
|
|
345
|
+
| `GITHUB_TOKEN`, `GH_TOKEN` | — | A GitHub token with the `gist` scope (checked in this order, then `gh auth token`). It is what lets a member **upload** its own credentials (and read without the anonymous rate limit); a machine without one still downloads. Sent only to `api.github.com`. |
|
|
211
346
|
| `OPENCODE_AUTH_LB_DIR` | — | Override the pool-file directory (handy for tests). |
|
|
212
347
|
| `OPENCODE_AUTH_LB_DEBUG` | — | `1`/`true` logs each selection to stderr. |
|
|
213
348
|
| `ANTHROPIC_BASE_URL` | — | Route Anthropic requests through a custom base URL. |
|
|
@@ -274,8 +409,8 @@ The unit/integration suite runs with coverage **gated at 100%** (lines and funct
|
|
|
274
409
|
|
|
275
410
|
opencode loads plugins once at startup, so the loop is:
|
|
276
411
|
|
|
277
|
-
1. `bun run dev` — keeps
|
|
278
|
-
2.
|
|
412
|
+
1. `bun run dev` — keeps both server entry points rebuilt on every edit.
|
|
413
|
+
2. For V2, register the package directory in `opencode.json` using `plugins`. For V1, symlink `dist/v1.js` into `.opencode/plugins/` (see install above).
|
|
279
414
|
3. Edit code → the watcher rebuilds → **restart opencode** to reload the plugin.
|
|
280
415
|
4. Use it; watch the toasts, run `bun run status`, and set `OPENCODE_AUTH_LB_DEBUG=1` to log selections.
|
|
281
416
|
|
|
@@ -287,6 +422,9 @@ src/
|
|
|
287
422
|
fetch.ts # load-balanced fetch — the per-request choke point
|
|
288
423
|
refresh.ts # singleflight OAuth refresh, invalid_grant handling
|
|
289
424
|
accounts.ts # append / bootstrap accounts into the pool
|
|
425
|
+
pairing.ts # which row a login lands on (setup-token ↔ OAuth pairing)
|
|
426
|
+
login-health.ts # login tags, re-login / expiry warnings, why a login was lost
|
|
427
|
+
token-mint.ts # long-lived Claude tokens minted from OAuth logins, renewed before they lapse
|
|
290
428
|
session.ts # derive a stable session key (affinity)
|
|
291
429
|
types.ts # provider-agnostic data model (accounts, usage windows, pool file)
|
|
292
430
|
usage-merge.ts # fixed weekly-anchor preservation / roll-forward
|
|
@@ -295,11 +433,12 @@ src/
|
|
|
295
433
|
notify.ts # toast on account switch
|
|
296
434
|
usage-refresh.ts # cold-start usage seeding via the usage endpoint
|
|
297
435
|
prime.ts # point the in-use marker at the top-ranked account at startup
|
|
436
|
+
sync/ # gist sync: crypto, gist client, payload (v2, origins), own/imported split, merge plan, one read-apply-write cycle, state, schedule
|
|
298
437
|
pending/ # recovery classifier, reference store, per-turn lease, restart coordinator
|
|
299
438
|
scheduler/ # config, score-core (shared scorer), select
|
|
300
439
|
pool/ # data-dir resolution + atomic, serialized pool store
|
|
301
440
|
providers/ # ProviderAdapter contract + headers
|
|
302
|
-
anthropic/ # Claude OAuth + Claude Code request transforms + usage
|
|
441
|
+
anthropic/ # Claude OAuth + setup-token login + Claude Code request transforms + usage
|
|
303
442
|
openai/ # ChatGPT/Codex OAuth + Responses transforms + usage
|
|
304
443
|
kimi/ # Kimi Code device-code OAuth + API-key login + /usages (kimi.com + kimi.ai)
|
|
305
444
|
cli/status.ts # `bun run status`
|
|
@@ -338,6 +477,7 @@ The account pool and durable pending references are separate JSON files (opencod
|
|
|
338
477
|
- **Kimi Code** sign-ins must be approved within 15 minutes, or the login fails and has to be started again. A key imported from opencode's store at startup is keyed by the key itself — the import makes no network call to learn its account — so log that subscription in again before adding it a second way, or it can count twice.
|
|
339
478
|
- **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.
|
|
340
479
|
- **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.
|
|
480
|
+
- **Gist sync** shares only static credentials, so a machine with no OAuth login for an account serves it on the shared token alone: its usage comes from response headers and the idle quota check, not the usage endpoint (see the setup-token section). The TUI asks the plugin to sync through a small request file beside the pool and shows the plugin's answer from a status file, because the TUI cannot import the plugin; if no opencode server is running, a request waits until one starts and is dropped after 10 minutes. A machine without a GitHub token reads the gist through the unauthenticated API, so an IP behind a busy shared NAT can hit GitHub's 60/hour limit. Gist writes have no compare-and-swap: two machines writing at once can lose one update until the next cycle re-asserts it (see the sync section).
|
|
341
481
|
- **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.
|
|
342
482
|
- Live OAuth/login and real-account end-to-end behavior should be smoke-tested in your environment; the test suite mocks the network.
|
|
343
483
|
|
package/dist/accounts.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type PoolAccount, type TokenSet } from './types';
|
|
1
|
+
import { type LostLogins, type PoolAccount, type PoolFile, type TokenSet } from './types';
|
|
2
2
|
/** A credential from opencode's auth store (`oauth` token pair or `api` key). */
|
|
3
3
|
interface OpencodeAuth {
|
|
4
4
|
type: string;
|
|
@@ -9,12 +9,32 @@ interface OpencodeAuth {
|
|
|
9
9
|
}
|
|
10
10
|
/** The credential getter opencode passes to an auth loader. */
|
|
11
11
|
export type OpencodeAuthGetter = () => Promise<OpencodeAuth>;
|
|
12
|
+
/** A pool row for `tokens` (also the throwaway row a login is measured through). */
|
|
13
|
+
export declare function makeAccount(providerID: string, label: string, tokens: TokenSet): PoolAccount;
|
|
14
|
+
export declare function recordLostLogin(row: PoolAccount, login: keyof LostLogins, reason: string): void;
|
|
15
|
+
export declare function clearLostLogin(row: PoolAccount, login: keyof LostLogins): void;
|
|
16
|
+
/**
|
|
17
|
+
* Forget a setup-token the API rejected, leaving its row on the OAuth login —
|
|
18
|
+
* but only while the row still holds that token, so one pasted mid-request
|
|
19
|
+
* survives — and record why for the dashboards.
|
|
20
|
+
*/
|
|
21
|
+
export declare function dropInferenceToken(accountId: string, token: string, reason: string): Promise<void>;
|
|
12
22
|
/**
|
|
13
23
|
* Append a freshly-authorized account to the pool, deduped by refresh token
|
|
14
24
|
* or provider account id. Re-authorizing the same account refreshes its
|
|
15
|
-
* tokens instead of duplicating it.
|
|
25
|
+
* tokens instead of duplicating it. A setup-token and an OAuth login of one
|
|
26
|
+
* account share a row in either order: the TUI hint or the organization
|
|
27
|
+
* pairs them, each keeping its own role.
|
|
16
28
|
*/
|
|
17
29
|
export declare function addAccount(providerID: string, tokens: TokenSet, label?: string): Promise<PoolAccount>;
|
|
30
|
+
/**
|
|
31
|
+
* The shared body of every login landing: fold `tokens` onto the row that
|
|
32
|
+
* already holds them, else the hinted row (of the same provider: a hint can
|
|
33
|
+
* never carry a login onto another provider's row), else the organization's
|
|
34
|
+
* partner, else append a new row. Gist sync lands its credentials through here
|
|
35
|
+
* too, inside its own pool transaction.
|
|
36
|
+
*/
|
|
37
|
+
export declare function placeTokens(pool: PoolFile, providerID: string, tokens: TokenSet, label: string | undefined, hintedId: string | undefined): PoolAccount;
|
|
18
38
|
/**
|
|
19
39
|
* Seed the pool from opencode's existing single-slot credential (e.g. an OAuth
|
|
20
40
|
* login left by the single-account anthropic-auth plugin, or a Kimi Code API
|
package/dist/index.d.ts
CHANGED
|
@@ -1,12 +1,7 @@
|
|
|
1
|
-
import type { Plugin } from '@opencode
|
|
2
|
-
export
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
* dashboard (in-use account per provider, each account's weekly/5h usage,
|
|
9
|
-
* cooldowns, and the ranked next candidates) across ALL providers. Registered once
|
|
10
|
-
* (not per provider) so the tool name doesn't collide.
|
|
11
|
-
*/
|
|
12
|
-
export declare const AuthLoadBalancerStatusPlugin: Plugin;
|
|
1
|
+
import type { Plugin } from '@opencode/plugin';
|
|
2
|
+
export { AnthropicLoadBalancerPlugin, AuthLoadBalancerStatusPlugin, KimiCodeGlobalLoadBalancerPlugin, KimiCodeLoadBalancerPlugin, OpenAILoadBalancerPlugin, } from './legacy';
|
|
3
|
+
declare const _default: {
|
|
4
|
+
id: string;
|
|
5
|
+
setup(ctx: Plugin.Context): Promise<Plugin.Cleanup>;
|
|
6
|
+
};
|
|
7
|
+
export default _default;
|