opencode-gemini-rotator
v1.1.1
Published
OpenCode plugin that rotates multiple Google Gemini API keys to bypass per-key rate limits (HTTP 429) and quota exhaustion (HTTP 403/503 RESOURCE_EXHAUSTED) — drop-in, transparent fetch interceptor with cooldown and TUI sidebar.
Downloads
401
Maintainers
Readme
opencode-gemini-rotator
Stop hitting Gemini rate limits. An OpenCode plugin that transparently rotates a pool of Google Gemini API keys when requests get rate-limited (HTTP 429) or quota-exhausted (HTTP 403/503
RESOURCE_EXHAUSTED). Drop in, configure your keys, forget about quotas.
Table of contents
- Why
- Features
- Installation
- Configuration
- How it works
- Debugging
- Development
- Troubleshooting
- FAQ
- Security
- Contributing
- License
Why
OpenCode uses your Gemini API key for every request. When you hit your per-key per-minute quota, OpenCode stalls. This plugin maintains a small pool of keys and rotates to the next healthy one automatically — your session keeps moving without you doing anything.
Features
- Pool of keys — pass keys as an array, comma-separated string, or via
the
GEMINI_API_KEYSenvironment variable. - Smart cooldowns — exhausted keys are parked for a cooldown derived
from the
Retry-Afterheader or the error message (e.g.reset after 30s); healthy keys are always preferred. - Permanent invalidation — keys returning
API_KEY_INVALIDare removed from the rotation for the rest of the session. - Transparent interception — monkey-patches
globalThis.fetch, so the@opencode-ai/sdkand any other code Just Works without modification. - OAuth-aware —
ya29.*andBearer-prefixed values are sent in theAuthorizationheader; raw API keys go inx-goog-api-key. - TUI sidebar — shows the active key index, masked value, and pool size in the OpenCode right-side panel, refreshed in real time.
- Scoped impact — only requests to
generativelanguage.googleapis.comare touched; everything else passes straight through to the originalfetch. - No secrets in logs — keys are masked (
AIza…1234) everywhere they surface.
Installation
Requires Node 18+ (or Bun) and OpenCode 1.4.3 or newer.
Option A — npm (recommended)
Just add the package name to your OpenCode config; OpenCode auto-installs npm plugins on startup using its bundled Bun. See the upstream plugin docs.
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-gemini-rotator"]
}Then export your keys (or use the inline form below):
export GEMINI_API_KEYS="AIza...key1,AIza...key2,AIza...key3"
opencodeOption B — Local plugin (clone & build)
git clone https://github.com/jianlingzhong/opencode-gemini-rotator.git
cd opencode-gemini-rotator
bun install
bun run buildThen point OpenCode at the absolute path:
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["/absolute/path/to/opencode-gemini-rotator"]
}Configuration
OpenCode loads plugins via its config file
(~/.config/opencode/opencode.json or project-local
.opencode/opencode.json).
Inline keys (per-plugin options)
When you want to keep keys in the config file (instead of an env var),
use the tuple form [name, options]:
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
[
"opencode-gemini-rotator",
{
"keys": ["AIza...your-first-key", "AIza...your-second-key"]
}
]
]
}Replace "opencode-gemini-rotator" with an absolute path if you're
running from a local clone.
Plugin options
| Option | Type | Default | Description |
| --------- | -------------------- | ------------------------------------- | --------------------------------------------------- |
| keys | string[] \| string | process.env.GEMINI_API_KEYS | Pool of API keys. |
| logFile | string (path) | none (logging off unless DEBUG=1) | When set, debug telemetry is appended to this file. |
How it works
Architecture
flowchart LR
A[OpenCode / SDK] -->|fetch| B[globalThis.fetch hook]
B -->|other host| C[Original fetch]
B -->|Gemini host| D[GeminiRotator]
D -->|pick healthy key| E[Original fetch]
E --> F{Response}
F -->|2xx| G[Return to caller]
F -->|429 / 403 / 400 quota| H[Mark cooldown, rotate]
F -->|400 invalid key| I[Mark invalid, rotate]
H --> D
I --> DRequest lifecycle
sequenceDiagram
participant App as OpenCode
participant Hook as globalThis.fetch
participant Rot as GeminiRotator
participant API as Gemini API
App->>Hook: fetch(geminiUrl, init)
Hook->>Rot: dispatch (host matches)
loop while shouldRotate
Rot->>Rot: pick healthiest key
Rot->>API: fetch(url, headers w/ key)
API-->>Rot: response
alt 2xx
Rot-->>App: response
else 429 / quota
Rot->>Rot: park key for cooldown
else API_KEY_INVALID
Rot->>Rot: mark key invalid (session)
end
endStep-by-step
- Init. Each configured key is registered as healthy
(
isValid: true, availableAt: 0). - Intercept. The plugin hooks
globalThis.fetch. Requests to hosts other thangenerativelanguage.googleapis.compass through unchanged. - Key selection. Any key already present on the inbound request
(header or
?key=query param) is added to the candidate pool so OpenCode's native credentials remain in play. - Header normalization. Keys starting with
ya29.orBearerare placed in theAuthorizationheader; everything else goes inx-goog-api-key. The?key=query param is stripped from the URL. - Failure & rotation.
429→ cooldown 60 s, rotate.403/503withRESOURCE_EXHAUSTEDor quota text → cooldown derived fromRetry-Afterheader or error message (e.g.reset after 30s), rotate.400withAPI_KEY_INVALID→ mark the key invalid for the session, rotate.- Anything else → response is returned to the caller untouched.
- All-on-cooldown. If every key is parked, the rotator sleeps until
the earliest
availableAt, then retries. - Toast notification. Each rotation pops a transient warning in the OpenCode TUI.
Debugging
File logging is opt-in. Enable it by either:
export OPENCODE_GEMINI_DEBUG=1…or by passing logFile in the plugin options:
["/path/to/opencode-gemini-rotator", { "keys": ["AIza..."], "logFile": "/tmp/gemini-rotator.log" }]Then tail the log:
tail -f /tmp/gemini-rotator-debug.logThe TUI sidebar widget writes a small status JSON to
$TMPDIR/gemini-rotator-status.json so it can poll cross-process state;
this file is harmless and contains only the current key index, masked
value, and pool size.
Development
bun install
bun run test # unit + property-based tests
bun run test:coverage # v8 coverage report
bun run typecheck # tsc --noEmit
bun run format # prettier --write .
bun run format:check # prettier --check .
bun run build # produce ./distCI runs typecheck, format check, tests, and build on every push and PR across Ubuntu and macOS.
Troubleshooting
The TUI sidebar doesn't appear.
The sidebar only shows once the rotator has been initialized with at
least one key. Make sure your opencode.json either lists keys inline
or that GEMINI_API_KEYS is exported in the shell that launches
OpenCode. The sidebar reads from $TMPDIR/gemini-rotator-status.json;
delete that file and restart OpenCode if you suspect stale state.
Rotation toast never shows.
Toasts only fire when a key is rotated. If your first key has fresh
quota, you'll never see one. Force a rotation by temporarily putting an
obviously bogus key first: ["AIzaBOGUSKEY", "AIza...your-real-key"].
"All provided Gemini keys are invalid" thrown immediately.
At least one key in your pool returned API_KEY_INVALID and there are
no others available. Run with OPENCODE_GEMINI_DEBUG=1 and check
/tmp/gemini-rotator-debug.log for the masked key and the full error
message.
opencode doesn't pick up the plugin.
Confirm OpenCode 1.4.3+ (opencode --version). For local installs, the
path must be absolute. For npm installs, run bun cache rm and restart
to force a reinstall into ~/.cache/opencode/node_modules/.
CI for my fork fails on format:check.
Run bun run format locally and commit the result. Prettier config
lives in .prettierrc.
FAQ
Does this proxy my prompts somewhere?
No. Requests still go directly to generativelanguage.googleapis.com.
The plugin only swaps the auth header and retries on failure.
Will it work with the OAuth flow / ya29. tokens?
Yes. OAuth bearer tokens are detected and sent in the Authorization
header. They count as one entry in the pool.
What happens if all keys are exhausted?
The plugin sleeps until the earliest key's cooldown expires, then retries
— unless the caller aborts the request (AbortSignal), in which case the
promise rejects with Aborted.
Does it touch non-Gemini requests?
No. Anything not addressed to generativelanguage.googleapis.com is
passed straight through to the original fetch.
Does it cache or persist anything across sessions? No. All state (cooldowns, invalid-key flags) is in-memory and resets when OpenCode restarts.
Security
Please do not commit real API keys to any branch. If you discover a vulnerability, see SECURITY.md for the private disclosure process.
Contributing
Bug reports, doc fixes, and PRs are welcome. See CONTRIBUTING.md for the dev loop, and CODE_OF_CONDUCT.md for expected behavior.
License
MIT © Jianling Zhong
