opencode-go-router
v0.4.0
Published
opencode plugin that rotates between multiple opencode GO (Zen) API keys, with automatic failover on rate limit.
Maintainers
Readme
opencode-go-router
Local proxy that rotates multiple opencode GO API keys with automatic failover on rate limit.
If you have several opencode GO accounts and want opencode to keep working after one of them hits the rate limit, point it at this proxy. It rotates through your keys, marks the exhausted ones in a cooldown, and retries the current request with the next healthy key — transparently, before any tokens reach your terminal.
Why
The opencode GO plan issues static API keys (sk_...) that you can generate freely from your dashboard. Under real use, a single key eventually returns 429 rate_limit_exceeded, and your session stalls until it resets. Rotating manually is annoying; rotating from inside an opencode plugin doesn't work because the opencode-go provider uses a custom fetch path that never fires the chat.headers hook.
Solution: a tiny local HTTP proxy sits between opencode and the GO upstream. It owns the Authorization header and swaps the key on the fly.
opencode ──► http://localhost:8787 ──► https://opencode.ai/zen/go/v1
│
├── pool: [sk_1, sk_2, sk_3]
├── sticky-until-fail (keeps using one key until it breaks)
├── injects Authorization: Bearer <active_key>
└── on 429/403 or "rate_limit" in the first SSE frame:
cools down the key and retries with the next oneFeatures
- Automatic failover on
429,403, and rate-limit payloads inside the first SSE frame. - Sticky-until-fail rotation — maximizes usage of one key before switching.
- Persistent cooldowns in
~/.opencode-go-router/state.json; survives restarts. - Streams natively — after the initial handshake, requests are piped straight through with no buffering.
- Zero opencode patches — works via
opencode.jsonconfig only. - Respects
Retry-Afterheaders when the upstream provides them. - Debug header (
x-opencode-router-key) on every response so you can see which key served it.
Requirements
- Node.js ≥ 18
- opencode installed
- Two or more opencode GO API keys
Install
npm install -g opencode-go-routerOr without global install:
npx opencode-go-router serveQuick start (plugin mode — recommended)
Configure once, globally — then every project just works.
opencode-go-router keys set "sk_1,sk_2,sk_3" # persisted to ~/.opencode-go-router/keys.json
opencode-go-router init --global # adds plugin entry to ~/.config/opencode/opencode.json
opencode # in any projectThis writes:
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-go-router"]
}opencode installs the plugin automatically (via bun, equivalent to npx) and caches it in ~/.cache/opencode/node_modules/. No per-project export, no per-project config.
Per-project instead of global
Drop --global to write ./opencode.json in the current directory:
opencode-go-router initAlternative: proxy mode
If the plugin's chat.headers hook doesn't fire on your opencode build (custom fetch paths in some provider integrations bypass it), fall back to running the local HTTP proxy — see Proxy mode below.
CLI reference
opencode-go-router init [path] [--global]
Configure opencode.json to load the plugin.
--global writes ~/.config/opencode/opencode.json.
opencode-go-router keys
List keys currently in use (masked) and their source (env or file).
opencode-go-router keys set "k1,k2,k3"
Persist keys to ~/.opencode-go-router/keys.json.
opencode-go-router keys add <key>
Append one key to the persisted file.
opencode-go-router keys remove <index|suffix>
Remove by 1-based index or last-6 suffix.
opencode-go-router keys clear
Delete the persisted keys file.
opencode-go-router status
Show pool health and cooldown timers.
opencode-go-router force-cooldown
Mark the currently active key as exhausted (useful for testing).
opencode-go-router reset
Clear all cooldown state.
opencode-go-router serve [--port 8787] [--upstream URL]
(Optional) run the standalone rotating proxy.Key sources
Resolved in order — first match wins:
env OPENCODE_ZEN_KEYSenv OPENCODE_GO_KEYS~/.opencode-go-router/keys.json(managed bykeys set/add/remove/clear)
The env var takes precedence so you can override the persisted set for a single shell without deleting the file. While an env var is set, keys add/keys remove refuse to write (they'd be shadowed anyway).
Environment variables
| Variable | Default | Description |
|---|---|---|
| OPENCODE_ZEN_KEYS | (unset) | Comma-separated GO API keys — takes precedence over the persisted file |
| OPENCODE_ROUTER_UPSTREAM | https://opencode.ai/zen/go/v1 | Upstream base URL (proxy mode) |
| OPENCODE_ROUTER_PORT | 8787 | Default port for serve |
| OPENCODE_ROUTER_DEBUG | (off) | Set to 1 for per-request logs on stderr |
| OPENCODE_ROUTER_STATE_DIR | ~/.opencode-go-router | Where cooldown state and keys.json live |
Proxy mode (fallback)
If the plugin hook can't intercept requests on your setup, run the standalone proxy and point opencode's opencode-go provider at it.
opencode-go-router keys set "sk_1,sk_2,sk_3" # or export OPENCODE_ZEN_KEYS=...
opencode-go-router serve # in a dedicated terminalThen edit opencode.json (per project) to add:
{
"provider": { "opencode-go": { "options": { "baseURL": "http://localhost:8787" } } }
}Verifying it works
With the proxy running, expect logs like:
[go-router] listening on :8787, upstream=https://opencode.ai/zen/go/v1, keys=3
[go-router] → attempt 1/3 key=...abc123 POST https://opencode.ai/zen/go/v1/chat/completions
[go-router] ✓ streamed via key=...abc123Force a rotation to confirm failover works end-to-end:
opencode-go-router force-cooldownThen send any prompt in opencode — you should see the proxy pick a different key:
[go-router] → attempt 1/3 key=...def456 POST https://opencode.ai/zen/go/v1/chat/completions
[go-router] ✓ streamed via key=...def456Inspect the pool at any time:
opencode-go-router status{
"activeKey": "...def456",
"keys": [
{ "key": "...abc123", "healthy": false, "cooldownMsRemaining": 587234 },
{ "key": "...def456", "healthy": true, "cooldownMsRemaining": 0 },
{ "key": "...ghi789", "healthy": true, "cooldownMsRemaining": 0 }
]
}How rotation works
- On every request, the proxy calls
pickKey()which returns the current sticky key (or the next healthy one if it's cooling down). - The request is forwarded upstream with
Authorization: Bearer <key>. - The response is inspected:
429/403→ the key is put into cooldown (respectingRetry-Afterif present; default 5 minutes), and the request is retried with the next key. Up topool.sizeattempts.200with SSE → the first frame is sniffed (up to 800 ms) forrate_limit_exceeded/quota_exceededpayloads. If found, cooldown + retry. Otherwise, headers are flushed and the stream is piped through untouched.- All other responses → passed through verbatim.
- If every key is in cooldown, the proxy responds with
429andx-opencode-router: all-exhaustedso opencode surfaces a clear error rather than hanging.
Limitations
- No mid-stream failover. If the upstream starts streaming and only later emits an error, the current turn is lost. The next turn will use a fresh key.
- SSE error detection is heuristic. Payloads matching
rate.?limit|quota|too many|insufficient.?quotaare treated as exhaustion. If the GO backend introduces a new error shape, this regex may need updating (seesrc/proxy.js). - Single opencode config target. The
initcommand only configures theopencode-goprovider. Other providers are untouched. - Terms of Service. Rotating between your own accounts is generally fine, but you should confirm this is acceptable under the opencode GO ToS before running it in production. This project is a routing layer, not an evasion technique.
FAQ
Why not implement this as an opencode plugin?
The opencode-go provider uses a custom fetch path that bypasses the chat.headers plugin hook, so a plugin can't reliably swap the auth header. A local proxy sits outside opencode entirely and works regardless of internal changes.
Can I use this with providers other than opencode-go?
The proxy itself is provider-agnostic — it forwards to whatever --upstream you set. But init only writes config for opencode-go. For other providers, edit opencode.json manually.
What happens when all keys are exhausted?
The proxy returns 429 with header x-opencode-router: all-exhausted. Opencode will surface this as a normal rate-limit error. opencode-go-router status shows the remaining cooldown for each key.
Is this an MCP server? No. MCP is a protocol for tools/context, not for authenticating provider requests. Key rotation has to happen where the request is issued, which is inside opencode — so we intercept at the HTTP layer instead.
Development
git clone https://github.com/YOUR_USERNAME/opencode-go-router
cd opencode-go-router
npm install
npm link # expose CLI globally
cd /path/to/some/opencode/project
npm link opencode-go-router
opencode-go-router keys set "sk_1,sk_2,sk_3"
opencode-go-router init --global
OPENCODE_ROUTER_DEBUG=1 opencodeProject layout:
opencode-go-router/
├── src/
│ ├── proxy.js # HTTP proxy with rotation + retry logic
│ ├── pool.js # KeyPool: pickKey, markExhausted, status
│ └── index.js # legacy opencode plugin (kept for other providers)
├── bin/cli.js # command-line interface
├── package.json
└── README.mdLicense
MIT © Your Name
Contributing
Issues and PRs welcome. If you hit an edge case — a new upstream error shape, a different provider path, a bug in the SSE sniffer — please open an issue with the redacted debug log (OPENCODE_ROUTER_DEBUG=1).
