opencode-gemini-rotator 1.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,43 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.1.0](https://github.com/jianlingzhong/opencode-gemini-rotator/compare/v1.0.0...v1.1.0) (2026-05-24)
9
+
10
+ ### Features
11
+
12
+ - file-based IPC for TUI sidebar cross-process state ([5cdaa61](https://github.com/jianlingzhong/opencode-gemini-rotator/commit/5cdaa61e523088491a7cefd2ed3687ebbf7ca2ef))
13
+ - hide TUI sidebar when no Gemini activity is detected ([346873e](https://github.com/jianlingzhong/opencode-gemini-rotator/commit/346873e97537c26867b5140f9dcaecffc46550aa))
14
+ - initial implementation of opencode-gemini-rotator plugin ([f7000f7](https://github.com/jianlingzhong/opencode-gemini-rotator/commit/f7000f77ebcd7b6a4809a51cc0110e79c65a6c2e))
15
+ - real-time TUI sidebar status using SolidJS and OpenTUI slots ([177f6af](https://github.com/jianlingzhong/opencode-gemini-rotator/commit/177f6af5cec64851e3cf005a178c07c53a5908a6))
16
+
17
+ ### Bug Fixes
18
+
19
+ - drop stryker JSDoc type import; defer codeql/scorecard to dispatch-only ([02ead5c](https://github.com/jianlingzhong/opencode-gemini-rotator/commit/02ead5c59a1b66947f47160385b8a44db58e88f3))
20
+ - wrap conditional SolidJS Show in a persistent box element ([16ff883](https://github.com/jianlingzhong/opencode-gemini-rotator/commit/16ff8836f993c9006f0ceaed7b22ae55c091b903))
21
+
22
+ ## [1.0.0] - 2026-05-22
23
+
24
+ Initial release.
25
+
26
+ ### Features
27
+
28
+ - Transparent `globalThis.fetch` interceptor scoped to
29
+ `generativelanguage.googleapis.com`.
30
+ - Multi-key pool from inline config, comma-separated string, or
31
+ `GEMINI_API_KEYS` environment variable.
32
+ - Smart cooldown derived from `Retry-After` header or `reset after Xs`
33
+ error message; healthy keys are always preferred.
34
+ - Permanent invalidation for `API_KEY_INVALID` responses (per session).
35
+ - OAuth-aware header routing: `ya29.*` / `Bearer`-prefixed values go
36
+ in `Authorization`; raw API keys go in `x-goog-api-key`.
37
+ - Real-time TUI sidebar showing active key index, masked value, and
38
+ pool size.
39
+ - Opt-in debug logging via `OPENCODE_GEMINI_DEBUG=1` or the `logFile`
40
+ plugin option.
41
+ - `zod`-validated plugin options at the trust boundary.
42
+ - Consistent key masking (`prefix…suffix`) across logs, toasts, and
43
+ the sidebar.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jianling Zhong
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,309 @@
1
+ # opencode-gemini-rotator
2
+
3
+ [![CI](https://github.com/jianlingzhong/opencode-gemini-rotator/actions/workflows/ci.yml/badge.svg)](https://github.com/jianlingzhong/opencode-gemini-rotator/actions/workflows/ci.yml)
4
+ [![CodeQL](https://github.com/jianlingzhong/opencode-gemini-rotator/actions/workflows/codeql.yml/badge.svg)](https://github.com/jianlingzhong/opencode-gemini-rotator/actions/workflows/codeql.yml)
5
+ [![npm version](https://img.shields.io/npm/v/opencode-gemini-rotator.svg)](https://www.npmjs.com/package/opencode-gemini-rotator)
6
+ [![npm downloads](https://img.shields.io/npm/dm/opencode-gemini-rotator.svg)](https://www.npmjs.com/package/opencode-gemini-rotator)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
+ [![Node](https://img.shields.io/node/v/opencode-gemini-rotator.svg)](https://nodejs.org)
9
+
10
+ > **Stop hitting Gemini rate limits.** An [OpenCode](https://opencode.ai)
11
+ > plugin that transparently rotates a pool of Google Gemini API keys when
12
+ > requests get rate-limited (HTTP 429) or quota-exhausted (HTTP 403/503
13
+ > `RESOURCE_EXHAUSTED`). Drop in, configure your keys, forget about quotas.
14
+
15
+ ## Table of contents
16
+
17
+ - [Why](#why)
18
+ - [Features](#features)
19
+ - [Installation](#installation)
20
+ - [Configuration](#configuration)
21
+ - [How it works](#how-it-works)
22
+ - [Debugging](#debugging)
23
+ - [Development](#development)
24
+ - [Troubleshooting](#troubleshooting)
25
+ - [FAQ](#faq)
26
+ - [Security](#security)
27
+ - [Contributing](#contributing)
28
+ - [License](#license)
29
+
30
+ ## Why
31
+
32
+ [OpenCode](https://opencode.ai) uses your Gemini API key for every request.
33
+ When you hit your per-key per-minute quota, OpenCode stalls. This plugin
34
+ maintains a small pool of keys and rotates to the next healthy one
35
+ automatically — your session keeps moving without you doing anything.
36
+
37
+ ## Features
38
+
39
+ - **Pool of keys** — pass keys as an array, comma-separated string, or via
40
+ the `GEMINI_API_KEYS` environment variable.
41
+ - **Smart cooldowns** — exhausted keys are parked for a cooldown derived
42
+ from the `Retry-After` header or the error message
43
+ (e.g. `reset after 30s`); healthy keys are always preferred.
44
+ - **Permanent invalidation** — keys returning `API_KEY_INVALID` are removed
45
+ from the rotation for the rest of the session.
46
+ - **Transparent interception** — monkey-patches `globalThis.fetch`, so the
47
+ `@opencode-ai/sdk` and any other code Just Works without modification.
48
+ - **OAuth-aware** — `ya29.*` and `Bearer`-prefixed values are sent in
49
+ the `Authorization` header; raw API keys go in `x-goog-api-key`.
50
+ - **TUI sidebar** — shows the active key index, masked value, and pool size
51
+ in the OpenCode right-side panel, refreshed in real time.
52
+ - **Scoped impact** — only requests to `generativelanguage.googleapis.com`
53
+ are touched; everything else passes straight through to the original
54
+ `fetch`.
55
+ - **No secrets in logs** — keys are masked (`AIza…1234`) everywhere they
56
+ surface.
57
+
58
+ ## Installation
59
+
60
+ Requires **Node 18+** (or Bun) and OpenCode `1.4.3` or newer.
61
+
62
+ ### Option A — npm (recommended)
63
+
64
+ Just add the package name to your OpenCode config; OpenCode auto-installs
65
+ npm plugins on startup using its bundled Bun. See the upstream
66
+ [plugin docs](https://opencode.ai/docs/plugins/#from-npm).
67
+
68
+ ```json
69
+ {
70
+ "$schema": "https://opencode.ai/config.json",
71
+ "plugin": ["opencode-gemini-rotator"]
72
+ }
73
+ ```
74
+
75
+ Then export your keys (or use the inline form below):
76
+
77
+ ```bash
78
+ export GEMINI_API_KEYS="AIza...key1,AIza...key2,AIza...key3"
79
+ opencode
80
+ ```
81
+
82
+ ### Option B — Local plugin (clone & build)
83
+
84
+ ```bash
85
+ git clone https://github.com/jianlingzhong/opencode-gemini-rotator.git
86
+ cd opencode-gemini-rotator
87
+ bun install
88
+ bun run build
89
+ ```
90
+
91
+ Then point OpenCode at the absolute path:
92
+
93
+ ```json
94
+ {
95
+ "$schema": "https://opencode.ai/config.json",
96
+ "plugin": ["/absolute/path/to/opencode-gemini-rotator"]
97
+ }
98
+ ```
99
+
100
+ ## Configuration
101
+
102
+ OpenCode loads plugins via its config file
103
+ (`~/.config/opencode/opencode.json` or project-local
104
+ `.opencode/opencode.json`).
105
+
106
+ ### Inline keys (per-plugin options)
107
+
108
+ When you want to keep keys in the config file (instead of an env var),
109
+ use the tuple form `[name, options]`:
110
+
111
+ ```json
112
+ {
113
+ "$schema": "https://opencode.ai/config.json",
114
+ "plugin": [
115
+ [
116
+ "opencode-gemini-rotator",
117
+ {
118
+ "keys": ["AIza...your-first-key", "AIza...your-second-key"]
119
+ }
120
+ ]
121
+ ]
122
+ }
123
+ ```
124
+
125
+ Replace `"opencode-gemini-rotator"` with an absolute path if you're
126
+ running from a local clone.
127
+
128
+ ### Plugin options
129
+
130
+ | Option | Type | Default | Description |
131
+ | --------- | -------------------- | ------------------------------------- | --------------------------------------------------- |
132
+ | `keys` | `string[] \| string` | `process.env.GEMINI_API_KEYS` | Pool of API keys. |
133
+ | `logFile` | `string` (path) | _none_ (logging off unless `DEBUG=1`) | When set, debug telemetry is appended to this file. |
134
+
135
+ ## How it works
136
+
137
+ ### Architecture
138
+
139
+ ```mermaid
140
+ flowchart LR
141
+ A[OpenCode / SDK] -->|fetch| B[globalThis.fetch hook]
142
+ B -->|other host| C[Original fetch]
143
+ B -->|Gemini host| D[GeminiRotator]
144
+ D -->|pick healthy key| E[Original fetch]
145
+ E --> F{Response}
146
+ F -->|2xx| G[Return to caller]
147
+ F -->|429 / 403 / 400 quota| H[Mark cooldown, rotate]
148
+ F -->|400 invalid key| I[Mark invalid, rotate]
149
+ H --> D
150
+ I --> D
151
+ ```
152
+
153
+ ### Request lifecycle
154
+
155
+ ```mermaid
156
+ sequenceDiagram
157
+ participant App as OpenCode
158
+ participant Hook as globalThis.fetch
159
+ participant Rot as GeminiRotator
160
+ participant API as Gemini API
161
+
162
+ App->>Hook: fetch(geminiUrl, init)
163
+ Hook->>Rot: dispatch (host matches)
164
+ loop while shouldRotate
165
+ Rot->>Rot: pick healthiest key
166
+ Rot->>API: fetch(url, headers w/ key)
167
+ API-->>Rot: response
168
+ alt 2xx
169
+ Rot-->>App: response
170
+ else 429 / quota
171
+ Rot->>Rot: park key for cooldown
172
+ else API_KEY_INVALID
173
+ Rot->>Rot: mark key invalid (session)
174
+ end
175
+ end
176
+ ```
177
+
178
+ ### Step-by-step
179
+
180
+ 1. **Init.** Each configured key is registered as healthy
181
+ (`isValid: true, availableAt: 0`).
182
+ 2. **Intercept.** The plugin hooks `globalThis.fetch`. Requests to hosts
183
+ other than `generativelanguage.googleapis.com` pass through unchanged.
184
+ 3. **Key selection.** Any key already present on the inbound request
185
+ (header or `?key=` query param) is added to the candidate pool so
186
+ OpenCode's native credentials remain in play.
187
+ 4. **Header normalization.** Keys starting with `ya29.` or `Bearer` are
188
+ placed in the `Authorization` header; everything else goes in
189
+ `x-goog-api-key`. The `?key=` query param is stripped from the URL.
190
+ 5. **Failure & rotation.**
191
+ - `429` → cooldown 60 s, rotate.
192
+ - `403`/`503` with `RESOURCE_EXHAUSTED` or quota text → cooldown
193
+ derived from `Retry-After` header or error message
194
+ (e.g. `reset after 30s`), rotate.
195
+ - `400` with `API_KEY_INVALID` → mark the key invalid for the session,
196
+ rotate.
197
+ - Anything else → response is returned to the caller untouched.
198
+ 6. **All-on-cooldown.** If every key is parked, the rotator sleeps until
199
+ the earliest `availableAt`, then retries.
200
+ 7. **Toast notification.** Each rotation pops a transient warning in the
201
+ OpenCode TUI.
202
+
203
+ ## Debugging
204
+
205
+ File logging is **opt-in**. Enable it by either:
206
+
207
+ ```bash
208
+ export OPENCODE_GEMINI_DEBUG=1
209
+ ```
210
+
211
+ …or by passing `logFile` in the plugin options:
212
+
213
+ ```json
214
+ ["/path/to/opencode-gemini-rotator", { "keys": ["AIza..."], "logFile": "/tmp/gemini-rotator.log" }]
215
+ ```
216
+
217
+ Then tail the log:
218
+
219
+ ```bash
220
+ tail -f /tmp/gemini-rotator-debug.log
221
+ ```
222
+
223
+ The TUI sidebar widget writes a small status JSON to
224
+ `$TMPDIR/gemini-rotator-status.json` so it can poll cross-process state;
225
+ this file is harmless and contains only the current key index, masked
226
+ value, and pool size.
227
+
228
+ ## Development
229
+
230
+ ```bash
231
+ bun install
232
+ bun run test # unit + property-based tests
233
+ bun run test:coverage # v8 coverage report
234
+ bun run typecheck # tsc --noEmit
235
+ bun run format # prettier --write .
236
+ bun run format:check # prettier --check .
237
+ bun run build # produce ./dist
238
+ ```
239
+
240
+ CI runs typecheck, format check, tests, and build on every push and PR
241
+ across Ubuntu and macOS.
242
+
243
+ ## Troubleshooting
244
+
245
+ **The TUI sidebar doesn't appear.**
246
+ The sidebar only shows once the rotator has been initialized with at
247
+ least one key. Make sure your `opencode.json` either lists keys inline
248
+ or that `GEMINI_API_KEYS` is exported in the shell that launches
249
+ OpenCode. The sidebar reads from `$TMPDIR/gemini-rotator-status.json`;
250
+ delete that file and restart OpenCode if you suspect stale state.
251
+
252
+ **Rotation toast never shows.**
253
+ Toasts only fire when a key is rotated. If your first key has fresh
254
+ quota, you'll never see one. Force a rotation by temporarily putting an
255
+ obviously bogus key first: `["AIzaBOGUSKEY", "AIza...your-real-key"]`.
256
+
257
+ **"All provided Gemini keys are invalid" thrown immediately.**
258
+ At least one key in your pool returned `API_KEY_INVALID` and there are
259
+ no others available. Run with `OPENCODE_GEMINI_DEBUG=1` and check
260
+ `/tmp/gemini-rotator-debug.log` for the masked key and the full error
261
+ message.
262
+
263
+ **`opencode` doesn't pick up the plugin.**
264
+ Confirm OpenCode 1.4.3+ (`opencode --version`). For local installs, the
265
+ path must be absolute. For npm installs, run `bun cache rm` and restart
266
+ to force a reinstall into `~/.cache/opencode/node_modules/`.
267
+
268
+ **CI for my fork fails on `format:check`.**
269
+ Run `bun run format` locally and commit the result. Prettier config
270
+ lives in `.prettierrc`.
271
+
272
+ ## FAQ
273
+
274
+ **Does this proxy my prompts somewhere?**
275
+ No. Requests still go directly to `generativelanguage.googleapis.com`.
276
+ The plugin only swaps the auth header and retries on failure.
277
+
278
+ **Will it work with the OAuth flow / `ya29.` tokens?**
279
+ Yes. OAuth bearer tokens are detected and sent in the `Authorization`
280
+ header. They count as one entry in the pool.
281
+
282
+ **What happens if all keys are exhausted?**
283
+ The plugin sleeps until the earliest key's cooldown expires, then retries
284
+ — unless the caller aborts the request (`AbortSignal`), in which case the
285
+ promise rejects with `Aborted`.
286
+
287
+ **Does it touch non-Gemini requests?**
288
+ No. Anything not addressed to `generativelanguage.googleapis.com` is
289
+ passed straight through to the original `fetch`.
290
+
291
+ **Does it cache or persist anything across sessions?**
292
+ No. All state (cooldowns, invalid-key flags) is in-memory and resets when
293
+ OpenCode restarts.
294
+
295
+ ## Security
296
+
297
+ Please **do not** commit real API keys to any branch. If you discover a
298
+ vulnerability, see [SECURITY.md](./SECURITY.md) for the private
299
+ disclosure process.
300
+
301
+ ## Contributing
302
+
303
+ Bug reports, doc fixes, and PRs are welcome. See
304
+ [CONTRIBUTING.md](./CONTRIBUTING.md) for the dev loop, and
305
+ [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) for expected behavior.
306
+
307
+ ## License
308
+
309
+ [MIT](./LICENSE) © Jianling Zhong
@@ -0,0 +1,2 @@
1
+ import { server } from "./server.js";
2
+ export { server };
package/dist/index.js ADDED
@@ -0,0 +1,325 @@
1
+ // src/server.ts
2
+ import path from "path";
3
+ import os from "os";
4
+ import fs from "fs";
5
+ import { z } from "zod";
6
+ var statusFile = path.join(os.tmpdir(), "gemini-rotator-status.json");
7
+ var RotatorOptionsSchema = z.object({
8
+ keys: z.union([z.array(z.string()), z.string()]).optional(),
9
+ logFile: z.string().optional()
10
+ }).strict();
11
+ var MAX_TRACKED_KEYS = 256;
12
+ var STATUS_WRITE_THROTTLE_MS = 250;
13
+ var lastWriteAt = 0;
14
+ var pendingInfo = null;
15
+ var writeTimer = null;
16
+ function flushStatus() {
17
+ if (!pendingInfo)
18
+ return;
19
+ const info = pendingInfo;
20
+ pendingInfo = null;
21
+ lastWriteAt = Date.now();
22
+ fs.promises.writeFile(statusFile, JSON.stringify(info)).catch(() => {});
23
+ }
24
+ function notifyKeyUpdate(info) {
25
+ pendingInfo = info;
26
+ const now = Date.now();
27
+ const since = now - lastWriteAt;
28
+ if (since >= STATUS_WRITE_THROTTLE_MS) {
29
+ flushStatus();
30
+ } else if (!writeTimer) {
31
+ writeTimer = setTimeout(() => {
32
+ writeTimer = null;
33
+ flushStatus();
34
+ }, STATUS_WRITE_THROTTLE_MS - since);
35
+ writeTimer.unref?.();
36
+ }
37
+ }
38
+ function maskKey(key) {
39
+ if (key.startsWith("ya29.") || key.startsWith("Bearer "))
40
+ return "OAuth Token";
41
+ if (key.length <= 12)
42
+ return key;
43
+ return `${key.slice(0, 4)}…${key.slice(-4)}`;
44
+ }
45
+
46
+ class GeminiRotator {
47
+ originalFetch;
48
+ keyStates = new Map;
49
+ logFile;
50
+ fallbackKeys = [];
51
+ client;
52
+ constructor(client, options = {}) {
53
+ this.client = client;
54
+ this.originalFetch = globalThis.fetch;
55
+ if (options.logFile) {
56
+ this.logFile = options.logFile;
57
+ } else if (process.env.OPENCODE_GEMINI_DEBUG === "1") {
58
+ this.logFile = path.join(os.tmpdir(), "gemini-rotator-debug.log");
59
+ }
60
+ if (Array.isArray(options.keys)) {
61
+ this.fallbackKeys = options.keys;
62
+ } else if (typeof options.keys === "string") {
63
+ this.fallbackKeys = options.keys.split(",").map((k) => k.trim());
64
+ } else if (process.env.GEMINI_API_KEYS) {
65
+ this.fallbackKeys = process.env.GEMINI_API_KEYS.split(",").map((k) => k.trim());
66
+ }
67
+ this.fallbackKeys = this.fallbackKeys.filter((k) => k.length > 0);
68
+ if (this.fallbackKeys.length > 0) {
69
+ notifyKeyUpdate({
70
+ index: 1,
71
+ maskedKey: maskKey(this.fallbackKeys[0]),
72
+ total: this.fallbackKeys.length
73
+ });
74
+ } else {
75
+ try {
76
+ if (fs.existsSync(statusFile))
77
+ fs.unlinkSync(statusFile);
78
+ } catch {}
79
+ notifyKeyUpdate({ index: 0, maskedKey: "None", total: 0 });
80
+ }
81
+ }
82
+ async fileLog(msg) {
83
+ if (!this.logFile)
84
+ return;
85
+ const timestampedMsg = `[${new Date().toISOString()}] ${msg}
86
+ `;
87
+ try {
88
+ await fs.promises.appendFile(this.logFile, timestampedMsg);
89
+ } catch {}
90
+ }
91
+ async showToast(message, variant = "info", duration) {
92
+ try {
93
+ await this.client.tui?.showToast({
94
+ body: { message, variant, duration }
95
+ });
96
+ } catch {}
97
+ }
98
+ sleep(ms, signal) {
99
+ return new Promise((resolve, reject) => {
100
+ if (signal?.aborted)
101
+ return reject(new Error("Aborted"));
102
+ const timer = setTimeout(resolve, ms);
103
+ signal?.addEventListener("abort", () => {
104
+ clearTimeout(timer);
105
+ reject(new Error("Aborted"));
106
+ });
107
+ });
108
+ }
109
+ async fetch(reqInfo, init) {
110
+ let urlObj;
111
+ if (typeof reqInfo === "string") {
112
+ urlObj = new URL(reqInfo);
113
+ } else if (reqInfo instanceof URL) {
114
+ urlObj = new URL(reqInfo.toString());
115
+ } else if (reqInfo instanceof Request) {
116
+ urlObj = new URL(reqInfo.url);
117
+ } else {
118
+ return this.originalFetch(reqInfo, init);
119
+ }
120
+ if (urlObj.hostname !== "generativelanguage.googleapis.com") {
121
+ return this.originalFetch(reqInfo, init);
122
+ }
123
+ const requestHeaders = new Headers(init?.headers);
124
+ const isRequestObj = reqInfo instanceof Request;
125
+ if (isRequestObj) {
126
+ reqInfo.headers.forEach((value, key) => {
127
+ if (!requestHeaders.has(key)) {
128
+ requestHeaders.set(key, value);
129
+ }
130
+ });
131
+ }
132
+ const providedKey = requestHeaders.get("x-goog-api-key") || urlObj.searchParams.get("key") || requestHeaders.get("authorization") || "";
133
+ let keysToUse = this.fallbackKeys;
134
+ if (providedKey) {
135
+ if (providedKey.includes(",")) {
136
+ keysToUse = providedKey.split(",").map((k) => k.trim()).filter((k) => k.length > 0);
137
+ } else {
138
+ if (this.fallbackKeys.length > 0) {
139
+ keysToUse = [...this.fallbackKeys];
140
+ if (!keysToUse.includes(providedKey)) {
141
+ keysToUse.push(providedKey);
142
+ }
143
+ } else {
144
+ keysToUse = [providedKey];
145
+ }
146
+ }
147
+ }
148
+ if (keysToUse.length === 0) {
149
+ return this.originalFetch(reqInfo, init);
150
+ }
151
+ urlObj.searchParams.delete("key");
152
+ const newUrlStr = urlObj.toString();
153
+ await this.fileLog(`--- Intercepting Gemini Request: ${urlObj.pathname} ---`);
154
+ keysToUse.forEach((k) => {
155
+ if (!this.keyStates.has(k)) {
156
+ if (this.keyStates.size >= MAX_TRACKED_KEYS) {
157
+ const firstKey = this.keyStates.keys().next().value;
158
+ if (firstKey !== undefined)
159
+ this.keyStates.delete(firstKey);
160
+ }
161
+ this.keyStates.set(k, { isValid: true, availableAt: 0 });
162
+ }
163
+ });
164
+ while (true) {
165
+ if (init?.signal?.aborted) {
166
+ await this.fileLog(`Request aborted by user.`);
167
+ throw new Error("Aborted");
168
+ }
169
+ const validKeys = keysToUse.filter((k) => {
170
+ const state = this.keyStates.get(k);
171
+ return state && state.isValid !== false;
172
+ });
173
+ if (validKeys.length === 0) {
174
+ await this.fileLog(`All keys marked as invalid!`);
175
+ this.showToast(`All provided Gemini keys are invalid!`, "error", 1e4);
176
+ throw new Error("All provided Gemini keys are invalid");
177
+ }
178
+ const now = Date.now();
179
+ validKeys.sort((a, b) => {
180
+ const stateA = this.keyStates.get(a);
181
+ const stateB = this.keyStates.get(b);
182
+ const isAvailableA = stateA.availableAt <= now;
183
+ const isAvailableB = stateB.availableAt <= now;
184
+ if (isAvailableA && isAvailableB) {
185
+ return keysToUse.indexOf(a) - keysToUse.indexOf(b);
186
+ } else if (isAvailableA) {
187
+ return -1;
188
+ } else if (isAvailableB) {
189
+ return 1;
190
+ } else {
191
+ return stateA.availableAt - stateB.availableAt;
192
+ }
193
+ });
194
+ const activeKey = validKeys[0];
195
+ const activeState = this.keyStates.get(activeKey);
196
+ const activeKeyMasked = maskKey(activeKey);
197
+ notifyKeyUpdate({
198
+ index: keysToUse.indexOf(activeKey) + 1,
199
+ maskedKey: activeKeyMasked,
200
+ total: keysToUse.length
201
+ });
202
+ if (activeState.availableAt > now) {
203
+ const sleepMs = activeState.availableAt - now;
204
+ await this.fileLog(`All keys exhausted. Sleeping ${sleepMs}ms until ${activeKeyMasked} available.`);
205
+ this.showToast(`All keys on cooldown. Waiting ${Math.ceil(sleepMs / 1000)}s…`, "warning", sleepMs);
206
+ await this.sleep(sleepMs, init?.signal ?? undefined);
207
+ }
208
+ const fetchHeaders = new Headers(requestHeaders);
209
+ if (activeKey.startsWith("Bearer ") || activeKey.startsWith("ya29.")) {
210
+ fetchHeaders.set("Authorization", activeKey.startsWith("Bearer ") ? activeKey : `Bearer ${activeKey}`);
211
+ fetchHeaders.delete("x-goog-api-key");
212
+ } else {
213
+ fetchHeaders.set("x-goog-api-key", activeKey);
214
+ fetchHeaders.delete("Authorization");
215
+ }
216
+ let fetchInput;
217
+ let fetchInit;
218
+ if (isRequestObj) {
219
+ const clonedReq = reqInfo.clone();
220
+ fetchInput = new Request(newUrlStr, {
221
+ method: clonedReq.method,
222
+ headers: fetchHeaders,
223
+ body: clonedReq.body,
224
+ mode: clonedReq.mode,
225
+ credentials: clonedReq.credentials,
226
+ cache: clonedReq.cache,
227
+ redirect: clonedReq.redirect,
228
+ referrer: clonedReq.referrer,
229
+ referrerPolicy: clonedReq.referrerPolicy,
230
+ integrity: clonedReq.integrity,
231
+ keepalive: clonedReq.keepalive,
232
+ signal: clonedReq.signal
233
+ });
234
+ fetchInit = init || {};
235
+ } else {
236
+ fetchInput = newUrlStr;
237
+ fetchInit = { ...init, headers: fetchHeaders };
238
+ }
239
+ let response;
240
+ try {
241
+ await this.fileLog(`Trying key (${activeKeyMasked})`);
242
+ response = await this.originalFetch(fetchInput, fetchInit);
243
+ await this.fileLog(`Response Status: ${response.status}`);
244
+ } catch (error) {
245
+ await this.fileLog(`Fetch threw an error: ${error}`);
246
+ throw error;
247
+ }
248
+ let shouldRotate = false;
249
+ let isInvalid = false;
250
+ let delayMs = 1e4;
251
+ if (response.status === 429) {
252
+ shouldRotate = true;
253
+ await this.fileLog(`Rate limited (429).`);
254
+ delayMs = 60000;
255
+ } else if (!response.ok && (response.status === 403 || response.status === 400 || response.status === 503)) {
256
+ const cloned = response.clone();
257
+ try {
258
+ const errorData = await cloned.json();
259
+ const msg = errorData?.error?.message?.toLowerCase() || "";
260
+ const firstDetail = errorData?.error?.details?.[0];
261
+ const reason = firstDetail?.reason?.toLowerCase() || "";
262
+ const errorStatus = errorData?.error?.status?.toLowerCase() || "";
263
+ await this.fileLog(`Error: msg="${msg}", reason="${reason}", status="${errorStatus}"`);
264
+ if (msg.includes("api key not valid") || reason.includes("api_key_invalid")) {
265
+ isInvalid = true;
266
+ shouldRotate = true;
267
+ } else if (msg.includes("quota") || msg.includes("rate limit") || reason.includes("rate_limit") || reason.includes("quota_exceeded") || errorStatus === "resource_exhausted" || errorStatus === "unavailable") {
268
+ shouldRotate = true;
269
+ const retryAfter = response.headers.get("retry-after");
270
+ if (retryAfter) {
271
+ const parsed = parseInt(retryAfter, 10);
272
+ if (!isNaN(parsed))
273
+ delayMs = parsed * 1000;
274
+ } else {
275
+ const afterMatch = msg.match(/reset after\s+([0-9.]+)(s|m|h)/i);
276
+ if (afterMatch) {
277
+ const val = parseFloat(afterMatch[1]);
278
+ const unit = afterMatch[2].toLowerCase();
279
+ if (unit === "s")
280
+ delayMs = val * 1000;
281
+ if (unit === "m")
282
+ delayMs = val * 60 * 1000;
283
+ if (unit === "h")
284
+ delayMs = val * 3600 * 1000;
285
+ }
286
+ }
287
+ }
288
+ } catch {}
289
+ }
290
+ if (isInvalid) {
291
+ activeState.isValid = false;
292
+ this.showToast(`Key (${activeKeyMasked}) is invalid.`, "error", 5000);
293
+ continue;
294
+ }
295
+ if (shouldRotate) {
296
+ activeState.availableAt = Date.now() + delayMs;
297
+ this.showToast(`Rotating from ${activeKeyMasked} (Cooldown: ${Math.ceil(delayMs / 1000)}s)`, "warning", 3000);
298
+ continue;
299
+ }
300
+ return response;
301
+ }
302
+ }
303
+ patch() {
304
+ globalThis.fetch = this.fetch.bind(this);
305
+ }
306
+ unpatch() {
307
+ globalThis.fetch = this.originalFetch;
308
+ }
309
+ }
310
+ var rotator = null;
311
+ var server = async ({ client }, options) => {
312
+ if (rotator)
313
+ rotator.unpatch();
314
+ const parsed = RotatorOptionsSchema.safeParse(options ?? {});
315
+ const opts = parsed.success ? parsed.data : {};
316
+ if (!parsed.success) {
317
+ console.warn("[opencode-gemini-rotator] Invalid plugin options; falling back to defaults:", parsed.error.issues);
318
+ }
319
+ rotator = new GeminiRotator(client, opts);
320
+ rotator.patch();
321
+ return {};
322
+ };
323
+ export {
324
+ server
325
+ };
@@ -0,0 +1,54 @@
1
+ import { type Plugin } from "@opencode-ai/plugin";
2
+ import { z } from "zod";
3
+ /**
4
+ * Zod schema for the plugin options object as it appears in
5
+ * `opencode.json`. Keeping validation at the boundary protects us from
6
+ * prototype pollution and stops obvious config typos like `key:` (no s).
7
+ */
8
+ export declare const RotatorOptionsSchema: z.ZodObject<{
9
+ keys: z.ZodOptional<z.ZodUnion<readonly [z.ZodArray<z.ZodString>, z.ZodString]>>;
10
+ logFile: z.ZodOptional<z.ZodString>;
11
+ }, z.core.$strict>;
12
+ export type RotatorOptions = z.infer<typeof RotatorOptionsSchema>;
13
+ interface ToastClient {
14
+ tui?: {
15
+ showToast: (args: {
16
+ body: {
17
+ message: string;
18
+ variant?: "info" | "warning" | "success" | "error";
19
+ duration?: number;
20
+ };
21
+ }) => Promise<unknown>;
22
+ };
23
+ }
24
+ /**
25
+ * Render a key safely for display in logs, toasts, and the TUI sidebar.
26
+ * - OAuth bearer tokens (`ya29.*`) are labeled rather than truncated.
27
+ * - Standard API keys show a 4-char prefix and 4-char suffix so the user
28
+ * can distinguish keys in their pool without exposing the secret middle.
29
+ * - Very short identifiers (test fakes) are shown verbatim.
30
+ */
31
+ export declare function maskKey(key: string): string;
32
+ export declare class GeminiRotator {
33
+ private originalFetch;
34
+ private keyStates;
35
+ private logFile;
36
+ private fallbackKeys;
37
+ private client;
38
+ constructor(client: ToastClient, options?: RotatorOptions);
39
+ private fileLog;
40
+ private showToast;
41
+ private sleep;
42
+ fetch(reqInfo: RequestInfo | URL, init?: RequestInit): Promise<Response>;
43
+ patch(): void;
44
+ unpatch(): void;
45
+ }
46
+ /**
47
+ * Reset the singleton (test-only helper). Restores the original
48
+ * `globalThis.fetch` so test files don't leak state between suites.
49
+ * @internal
50
+ */
51
+ export declare function _resetForTesting(): void;
52
+ export declare const id = "gemini-key-rotator";
53
+ export declare const server: Plugin;
54
+ export {};
package/dist/server.js ADDED
@@ -0,0 +1,337 @@
1
+ // src/server.ts
2
+ import path from "path";
3
+ import os from "os";
4
+ import fs from "fs";
5
+ import { z } from "zod";
6
+ var statusFile = path.join(os.tmpdir(), "gemini-rotator-status.json");
7
+ var RotatorOptionsSchema = z.object({
8
+ keys: z.union([z.array(z.string()), z.string()]).optional(),
9
+ logFile: z.string().optional()
10
+ }).strict();
11
+ var MAX_TRACKED_KEYS = 256;
12
+ var STATUS_WRITE_THROTTLE_MS = 250;
13
+ var lastWriteAt = 0;
14
+ var pendingInfo = null;
15
+ var writeTimer = null;
16
+ function flushStatus() {
17
+ if (!pendingInfo)
18
+ return;
19
+ const info = pendingInfo;
20
+ pendingInfo = null;
21
+ lastWriteAt = Date.now();
22
+ fs.promises.writeFile(statusFile, JSON.stringify(info)).catch(() => {});
23
+ }
24
+ function notifyKeyUpdate(info) {
25
+ pendingInfo = info;
26
+ const now = Date.now();
27
+ const since = now - lastWriteAt;
28
+ if (since >= STATUS_WRITE_THROTTLE_MS) {
29
+ flushStatus();
30
+ } else if (!writeTimer) {
31
+ writeTimer = setTimeout(() => {
32
+ writeTimer = null;
33
+ flushStatus();
34
+ }, STATUS_WRITE_THROTTLE_MS - since);
35
+ writeTimer.unref?.();
36
+ }
37
+ }
38
+ function maskKey(key) {
39
+ if (key.startsWith("ya29.") || key.startsWith("Bearer "))
40
+ return "OAuth Token";
41
+ if (key.length <= 12)
42
+ return key;
43
+ return `${key.slice(0, 4)}…${key.slice(-4)}`;
44
+ }
45
+
46
+ class GeminiRotator {
47
+ originalFetch;
48
+ keyStates = new Map;
49
+ logFile;
50
+ fallbackKeys = [];
51
+ client;
52
+ constructor(client, options = {}) {
53
+ this.client = client;
54
+ this.originalFetch = globalThis.fetch;
55
+ if (options.logFile) {
56
+ this.logFile = options.logFile;
57
+ } else if (process.env.OPENCODE_GEMINI_DEBUG === "1") {
58
+ this.logFile = path.join(os.tmpdir(), "gemini-rotator-debug.log");
59
+ }
60
+ if (Array.isArray(options.keys)) {
61
+ this.fallbackKeys = options.keys;
62
+ } else if (typeof options.keys === "string") {
63
+ this.fallbackKeys = options.keys.split(",").map((k) => k.trim());
64
+ } else if (process.env.GEMINI_API_KEYS) {
65
+ this.fallbackKeys = process.env.GEMINI_API_KEYS.split(",").map((k) => k.trim());
66
+ }
67
+ this.fallbackKeys = this.fallbackKeys.filter((k) => k.length > 0);
68
+ if (this.fallbackKeys.length > 0) {
69
+ notifyKeyUpdate({
70
+ index: 1,
71
+ maskedKey: maskKey(this.fallbackKeys[0]),
72
+ total: this.fallbackKeys.length
73
+ });
74
+ } else {
75
+ try {
76
+ if (fs.existsSync(statusFile))
77
+ fs.unlinkSync(statusFile);
78
+ } catch {}
79
+ notifyKeyUpdate({ index: 0, maskedKey: "None", total: 0 });
80
+ }
81
+ }
82
+ async fileLog(msg) {
83
+ if (!this.logFile)
84
+ return;
85
+ const timestampedMsg = `[${new Date().toISOString()}] ${msg}
86
+ `;
87
+ try {
88
+ await fs.promises.appendFile(this.logFile, timestampedMsg);
89
+ } catch {}
90
+ }
91
+ async showToast(message, variant = "info", duration) {
92
+ try {
93
+ await this.client.tui?.showToast({
94
+ body: { message, variant, duration }
95
+ });
96
+ } catch {}
97
+ }
98
+ sleep(ms, signal) {
99
+ return new Promise((resolve, reject) => {
100
+ if (signal?.aborted)
101
+ return reject(new Error("Aborted"));
102
+ const timer = setTimeout(resolve, ms);
103
+ signal?.addEventListener("abort", () => {
104
+ clearTimeout(timer);
105
+ reject(new Error("Aborted"));
106
+ });
107
+ });
108
+ }
109
+ async fetch(reqInfo, init) {
110
+ let urlObj;
111
+ if (typeof reqInfo === "string") {
112
+ urlObj = new URL(reqInfo);
113
+ } else if (reqInfo instanceof URL) {
114
+ urlObj = new URL(reqInfo.toString());
115
+ } else if (reqInfo instanceof Request) {
116
+ urlObj = new URL(reqInfo.url);
117
+ } else {
118
+ return this.originalFetch(reqInfo, init);
119
+ }
120
+ if (urlObj.hostname !== "generativelanguage.googleapis.com") {
121
+ return this.originalFetch(reqInfo, init);
122
+ }
123
+ const requestHeaders = new Headers(init?.headers);
124
+ const isRequestObj = reqInfo instanceof Request;
125
+ if (isRequestObj) {
126
+ reqInfo.headers.forEach((value, key) => {
127
+ if (!requestHeaders.has(key)) {
128
+ requestHeaders.set(key, value);
129
+ }
130
+ });
131
+ }
132
+ const providedKey = requestHeaders.get("x-goog-api-key") || urlObj.searchParams.get("key") || requestHeaders.get("authorization") || "";
133
+ let keysToUse = this.fallbackKeys;
134
+ if (providedKey) {
135
+ if (providedKey.includes(",")) {
136
+ keysToUse = providedKey.split(",").map((k) => k.trim()).filter((k) => k.length > 0);
137
+ } else {
138
+ if (this.fallbackKeys.length > 0) {
139
+ keysToUse = [...this.fallbackKeys];
140
+ if (!keysToUse.includes(providedKey)) {
141
+ keysToUse.push(providedKey);
142
+ }
143
+ } else {
144
+ keysToUse = [providedKey];
145
+ }
146
+ }
147
+ }
148
+ if (keysToUse.length === 0) {
149
+ return this.originalFetch(reqInfo, init);
150
+ }
151
+ urlObj.searchParams.delete("key");
152
+ const newUrlStr = urlObj.toString();
153
+ await this.fileLog(`--- Intercepting Gemini Request: ${urlObj.pathname} ---`);
154
+ keysToUse.forEach((k) => {
155
+ if (!this.keyStates.has(k)) {
156
+ if (this.keyStates.size >= MAX_TRACKED_KEYS) {
157
+ const firstKey = this.keyStates.keys().next().value;
158
+ if (firstKey !== undefined)
159
+ this.keyStates.delete(firstKey);
160
+ }
161
+ this.keyStates.set(k, { isValid: true, availableAt: 0 });
162
+ }
163
+ });
164
+ while (true) {
165
+ if (init?.signal?.aborted) {
166
+ await this.fileLog(`Request aborted by user.`);
167
+ throw new Error("Aborted");
168
+ }
169
+ const validKeys = keysToUse.filter((k) => {
170
+ const state = this.keyStates.get(k);
171
+ return state && state.isValid !== false;
172
+ });
173
+ if (validKeys.length === 0) {
174
+ await this.fileLog(`All keys marked as invalid!`);
175
+ this.showToast(`All provided Gemini keys are invalid!`, "error", 1e4);
176
+ throw new Error("All provided Gemini keys are invalid");
177
+ }
178
+ const now = Date.now();
179
+ validKeys.sort((a, b) => {
180
+ const stateA = this.keyStates.get(a);
181
+ const stateB = this.keyStates.get(b);
182
+ const isAvailableA = stateA.availableAt <= now;
183
+ const isAvailableB = stateB.availableAt <= now;
184
+ if (isAvailableA && isAvailableB) {
185
+ return keysToUse.indexOf(a) - keysToUse.indexOf(b);
186
+ } else if (isAvailableA) {
187
+ return -1;
188
+ } else if (isAvailableB) {
189
+ return 1;
190
+ } else {
191
+ return stateA.availableAt - stateB.availableAt;
192
+ }
193
+ });
194
+ const activeKey = validKeys[0];
195
+ const activeState = this.keyStates.get(activeKey);
196
+ const activeKeyMasked = maskKey(activeKey);
197
+ notifyKeyUpdate({
198
+ index: keysToUse.indexOf(activeKey) + 1,
199
+ maskedKey: activeKeyMasked,
200
+ total: keysToUse.length
201
+ });
202
+ if (activeState.availableAt > now) {
203
+ const sleepMs = activeState.availableAt - now;
204
+ await this.fileLog(`All keys exhausted. Sleeping ${sleepMs}ms until ${activeKeyMasked} available.`);
205
+ this.showToast(`All keys on cooldown. Waiting ${Math.ceil(sleepMs / 1000)}s…`, "warning", sleepMs);
206
+ await this.sleep(sleepMs, init?.signal ?? undefined);
207
+ }
208
+ const fetchHeaders = new Headers(requestHeaders);
209
+ if (activeKey.startsWith("Bearer ") || activeKey.startsWith("ya29.")) {
210
+ fetchHeaders.set("Authorization", activeKey.startsWith("Bearer ") ? activeKey : `Bearer ${activeKey}`);
211
+ fetchHeaders.delete("x-goog-api-key");
212
+ } else {
213
+ fetchHeaders.set("x-goog-api-key", activeKey);
214
+ fetchHeaders.delete("Authorization");
215
+ }
216
+ let fetchInput;
217
+ let fetchInit;
218
+ if (isRequestObj) {
219
+ const clonedReq = reqInfo.clone();
220
+ fetchInput = new Request(newUrlStr, {
221
+ method: clonedReq.method,
222
+ headers: fetchHeaders,
223
+ body: clonedReq.body,
224
+ mode: clonedReq.mode,
225
+ credentials: clonedReq.credentials,
226
+ cache: clonedReq.cache,
227
+ redirect: clonedReq.redirect,
228
+ referrer: clonedReq.referrer,
229
+ referrerPolicy: clonedReq.referrerPolicy,
230
+ integrity: clonedReq.integrity,
231
+ keepalive: clonedReq.keepalive,
232
+ signal: clonedReq.signal
233
+ });
234
+ fetchInit = init || {};
235
+ } else {
236
+ fetchInput = newUrlStr;
237
+ fetchInit = { ...init, headers: fetchHeaders };
238
+ }
239
+ let response;
240
+ try {
241
+ await this.fileLog(`Trying key (${activeKeyMasked})`);
242
+ response = await this.originalFetch(fetchInput, fetchInit);
243
+ await this.fileLog(`Response Status: ${response.status}`);
244
+ } catch (error) {
245
+ await this.fileLog(`Fetch threw an error: ${error}`);
246
+ throw error;
247
+ }
248
+ let shouldRotate = false;
249
+ let isInvalid = false;
250
+ let delayMs = 1e4;
251
+ if (response.status === 429) {
252
+ shouldRotate = true;
253
+ await this.fileLog(`Rate limited (429).`);
254
+ delayMs = 60000;
255
+ } else if (!response.ok && (response.status === 403 || response.status === 400 || response.status === 503)) {
256
+ const cloned = response.clone();
257
+ try {
258
+ const errorData = await cloned.json();
259
+ const msg = errorData?.error?.message?.toLowerCase() || "";
260
+ const firstDetail = errorData?.error?.details?.[0];
261
+ const reason = firstDetail?.reason?.toLowerCase() || "";
262
+ const errorStatus = errorData?.error?.status?.toLowerCase() || "";
263
+ await this.fileLog(`Error: msg="${msg}", reason="${reason}", status="${errorStatus}"`);
264
+ if (msg.includes("api key not valid") || reason.includes("api_key_invalid")) {
265
+ isInvalid = true;
266
+ shouldRotate = true;
267
+ } else if (msg.includes("quota") || msg.includes("rate limit") || reason.includes("rate_limit") || reason.includes("quota_exceeded") || errorStatus === "resource_exhausted" || errorStatus === "unavailable") {
268
+ shouldRotate = true;
269
+ const retryAfter = response.headers.get("retry-after");
270
+ if (retryAfter) {
271
+ const parsed = parseInt(retryAfter, 10);
272
+ if (!isNaN(parsed))
273
+ delayMs = parsed * 1000;
274
+ } else {
275
+ const afterMatch = msg.match(/reset after\s+([0-9.]+)(s|m|h)/i);
276
+ if (afterMatch) {
277
+ const val = parseFloat(afterMatch[1]);
278
+ const unit = afterMatch[2].toLowerCase();
279
+ if (unit === "s")
280
+ delayMs = val * 1000;
281
+ if (unit === "m")
282
+ delayMs = val * 60 * 1000;
283
+ if (unit === "h")
284
+ delayMs = val * 3600 * 1000;
285
+ }
286
+ }
287
+ }
288
+ } catch {}
289
+ }
290
+ if (isInvalid) {
291
+ activeState.isValid = false;
292
+ this.showToast(`Key (${activeKeyMasked}) is invalid.`, "error", 5000);
293
+ continue;
294
+ }
295
+ if (shouldRotate) {
296
+ activeState.availableAt = Date.now() + delayMs;
297
+ this.showToast(`Rotating from ${activeKeyMasked} (Cooldown: ${Math.ceil(delayMs / 1000)}s)`, "warning", 3000);
298
+ continue;
299
+ }
300
+ return response;
301
+ }
302
+ }
303
+ patch() {
304
+ globalThis.fetch = this.fetch.bind(this);
305
+ }
306
+ unpatch() {
307
+ globalThis.fetch = this.originalFetch;
308
+ }
309
+ }
310
+ var rotator = null;
311
+ function _resetForTesting() {
312
+ if (rotator) {
313
+ rotator.unpatch();
314
+ rotator = null;
315
+ }
316
+ }
317
+ var id = "gemini-key-rotator";
318
+ var server = async ({ client }, options) => {
319
+ if (rotator)
320
+ rotator.unpatch();
321
+ const parsed = RotatorOptionsSchema.safeParse(options ?? {});
322
+ const opts = parsed.success ? parsed.data : {};
323
+ if (!parsed.success) {
324
+ console.warn("[opencode-gemini-rotator] Invalid plugin options; falling back to defaults:", parsed.error.issues);
325
+ }
326
+ rotator = new GeminiRotator(client, opts);
327
+ rotator.patch();
328
+ return {};
329
+ };
330
+ export {
331
+ server,
332
+ maskKey,
333
+ id,
334
+ _resetForTesting,
335
+ RotatorOptionsSchema,
336
+ GeminiRotator
337
+ };
@@ -0,0 +1,6 @@
1
+ export interface KeyInfo {
2
+ index: number;
3
+ maskedKey: string;
4
+ total: number;
5
+ }
6
+ export declare const initialKeyInfo: KeyInfo;
package/dist/tui.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { type TuiPlugin } from "@opencode-ai/plugin/tui";
2
+ export declare const tui: TuiPlugin;
3
+ export declare const id = "gemini-rotator-tui";
4
+ declare const _default: {
5
+ id: string;
6
+ tui: TuiPlugin;
7
+ };
8
+ export default _default;
package/dist/tui.js ADDED
@@ -0,0 +1,162 @@
1
+ // src/tui.tsx
2
+ import { createComponent as _$createComponent } from "@opentui/solid";
3
+ import { effect as _$effect } from "@opentui/solid";
4
+ import { insert as _$insert } from "@opentui/solid";
5
+ import { createTextNode as _$createTextNode } from "@opentui/solid";
6
+ import { insertNode as _$insertNode } from "@opentui/solid";
7
+ import { setProp as _$setProp } from "@opentui/solid";
8
+ import { createElement as _$createElement } from "@opentui/solid";
9
+ import { createSignal, onMount, onCleanup, Show } from "solid-js";
10
+ import fs from "fs";
11
+ import path from "path";
12
+ import os from "os";
13
+
14
+ // src/shared.ts
15
+ var initialKeyInfo = { index: 0, maskedKey: "None", total: 0 };
16
+
17
+ // src/tui.tsx
18
+ var statusFile = path.join(os.tmpdir(), "gemini-rotator-status.json");
19
+ var DEBUG = process.env.OPENCODE_GEMINI_DEBUG === "1";
20
+ var debugLogFile = path.join(os.tmpdir(), "gemini-rotator-tui.log");
21
+ function debugLog(msg) {
22
+ if (!DEBUG)
23
+ return;
24
+ try {
25
+ fs.appendFileSync(debugLogFile, `[${new Date().toISOString()}] ${msg}
26
+ `);
27
+ } catch {}
28
+ }
29
+ function SidebarView(props) {
30
+ const [info, setInfo] = createSignal(initialKeyInfo);
31
+ const theme = () => props.api.theme.current;
32
+ const readStatus = () => {
33
+ try {
34
+ if (fs.existsSync(statusFile)) {
35
+ const content = fs.readFileSync(statusFile, "utf-8");
36
+ const data = JSON.parse(content);
37
+ setInfo(data);
38
+ }
39
+ } catch (e) {
40
+ debugLog(`error reading status: ${e}`);
41
+ }
42
+ };
43
+ onMount(() => {
44
+ debugLog(`config keys: ${Object.keys(props.api.state.config).join(", ")}`);
45
+ readStatus();
46
+ let watcher;
47
+ try {
48
+ watcher = fs.watch(statusFile, {
49
+ persistent: false
50
+ }, () => readStatus());
51
+ watcher.on("error", (err) => debugLog(`fs.watch error: ${err}`));
52
+ } catch (e) {
53
+ debugLog(`fs.watch unavailable, falling back to polling: ${e}`);
54
+ }
55
+ const interval = setInterval(readStatus, 5000);
56
+ onCleanup(() => {
57
+ clearInterval(interval);
58
+ watcher?.close();
59
+ });
60
+ });
61
+ const isGeminiActive = () => info().total > 0;
62
+ return (() => {
63
+ var _el$ = _$createElement("box");
64
+ _$insert(_el$, _$createComponent(Show, {
65
+ get when() {
66
+ return isGeminiActive();
67
+ },
68
+ get children() {
69
+ var _el$2 = _$createElement("box"), _el$3 = _$createElement("box"), _el$4 = _$createElement("text"), _el$5 = _$createElement("b");
70
+ _$insertNode(_el$2, _el$3);
71
+ _$setProp(_el$2, "paddingX", 1);
72
+ _$setProp(_el$2, "marginBottom", 1);
73
+ _$insertNode(_el$3, _el$4);
74
+ _$setProp(_el$3, "flexDirection", "row");
75
+ _$setProp(_el$3, "gap", 1);
76
+ _$insertNode(_el$4, _el$5);
77
+ _$insertNode(_el$5, _$createTextNode(`GEMINI ROTATOR`));
78
+ _$insert(_el$2, _$createComponent(Show, {
79
+ get when() {
80
+ return info().maskedKey !== "None";
81
+ },
82
+ get fallback() {
83
+ return (() => {
84
+ var _el$15 = _$createElement("text");
85
+ _$insertNode(_el$15, _$createTextNode(`Waiting for request...`));
86
+ _$effect((_$p) => _$setProp(_el$15, "fg", theme().textMuted, _$p));
87
+ return _el$15;
88
+ })();
89
+ },
90
+ get children() {
91
+ return [(() => {
92
+ var _el$7 = _$createElement("box"), _el$8 = _$createElement("text"), _el$0 = _$createElement("text"), _el$1 = _$createTextNode(`#`), _el$10 = _$createElement("text"), _el$11 = _$createTextNode(`(`), _el$12 = _$createTextNode(`)`);
93
+ _$insertNode(_el$7, _el$8);
94
+ _$insertNode(_el$7, _el$0);
95
+ _$insertNode(_el$7, _el$10);
96
+ _$setProp(_el$7, "flexDirection", "row");
97
+ _$setProp(_el$7, "gap", 1);
98
+ _$insertNode(_el$8, _$createTextNode(`Active Key:`));
99
+ _$insertNode(_el$0, _el$1);
100
+ _$insert(_el$0, () => info().index, null);
101
+ _$insertNode(_el$10, _el$11);
102
+ _$insertNode(_el$10, _el$12);
103
+ _$insert(_el$10, () => info().maskedKey, _el$12);
104
+ _$effect((_p$) => {
105
+ var _v$ = theme().text, _v$2 = theme().success, _v$3 = theme().textMuted;
106
+ _v$ !== _p$.e && (_p$.e = _$setProp(_el$8, "fg", _v$, _p$.e));
107
+ _v$2 !== _p$.t && (_p$.t = _$setProp(_el$0, "fg", _v$2, _p$.t));
108
+ _v$3 !== _p$.a && (_p$.a = _$setProp(_el$10, "fg", _v$3, _p$.a));
109
+ return _p$;
110
+ }, {
111
+ e: undefined,
112
+ t: undefined,
113
+ a: undefined
114
+ });
115
+ return _el$7;
116
+ })(), (() => {
117
+ var _el$13 = _$createElement("text"), _el$14 = _$createTextNode(`Pool size: `);
118
+ _$insertNode(_el$13, _el$14);
119
+ _$insert(_el$13, () => info().total, null);
120
+ _$effect((_$p) => _$setProp(_el$13, "fg", theme().textMuted, _$p));
121
+ return _el$13;
122
+ })()];
123
+ }
124
+ }), null);
125
+ _$effect((_$p) => _$setProp(_el$4, "fg", theme().primary, _$p));
126
+ return _el$2;
127
+ }
128
+ }));
129
+ return _el$;
130
+ })();
131
+ }
132
+ var tui = async (api) => {
133
+ try {
134
+ api.slots.register({
135
+ order: 100,
136
+ slots: {
137
+ home_prompt_right() {
138
+ return _$createComponent(SidebarView, {
139
+ api
140
+ });
141
+ },
142
+ sidebar_content() {
143
+ return _$createComponent(SidebarView, {
144
+ api
145
+ });
146
+ }
147
+ }
148
+ });
149
+ } catch (e) {
150
+ debugLog(`failed to register slots: ${e}`);
151
+ }
152
+ };
153
+ var id = "gemini-rotator-tui";
154
+ var tui_default = {
155
+ id,
156
+ tui
157
+ };
158
+ export {
159
+ tui,
160
+ id,
161
+ tui_default as default
162
+ };
package/package.json ADDED
@@ -0,0 +1,108 @@
1
+ {
2
+ "name": "opencode-gemini-rotator",
3
+ "version": "1.1.0",
4
+ "overrides": {
5
+ "qs": "^6.15.2"
6
+ },
7
+ "description": "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.",
8
+ "keywords": [
9
+ "opencode",
10
+ "opencode-plugin",
11
+ "opencode-ai",
12
+ "gemini",
13
+ "gemini-api",
14
+ "gemini-pro",
15
+ "google-gemini",
16
+ "google-ai",
17
+ "generative-ai",
18
+ "api-key",
19
+ "api-key-rotation",
20
+ "key-rotation",
21
+ "rate-limit",
22
+ "rate-limiting",
23
+ "quota",
24
+ "rotator",
25
+ "fallback",
26
+ "retry",
27
+ "fetch",
28
+ "interceptor",
29
+ "llm",
30
+ "ai-tools",
31
+ "developer-tools",
32
+ "tui"
33
+ ],
34
+ "homepage": "https://github.com/jianlingzhong/opencode-gemini-rotator#readme",
35
+ "bugs": {
36
+ "url": "https://github.com/jianlingzhong/opencode-gemini-rotator/issues"
37
+ },
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/jianlingzhong/opencode-gemini-rotator.git"
41
+ },
42
+ "license": "MIT",
43
+ "author": "Jianling Zhong <jianlingzh@gmail.com>",
44
+ "type": "module",
45
+ "main": "dist/index.js",
46
+ "tui": "dist/tui.js",
47
+ "types": "dist/index.d.ts",
48
+ "exports": {
49
+ ".": {
50
+ "types": "./dist/index.d.ts",
51
+ "default": "./dist/index.js"
52
+ },
53
+ "./tui": {
54
+ "types": "./dist/tui.d.ts",
55
+ "default": "./dist/tui.js"
56
+ },
57
+ "./package.json": "./package.json"
58
+ },
59
+ "files": [
60
+ "dist",
61
+ "LICENSE",
62
+ "README.md",
63
+ "CHANGELOG.md"
64
+ ],
65
+ "engines": {
66
+ "node": ">=18"
67
+ },
68
+ "scripts": {
69
+ "build": "rm -rf dist && tsc --emitDeclarationOnly && bun build.ts",
70
+ "test": "vitest run",
71
+ "test:watch": "vitest",
72
+ "test:coverage": "vitest run --coverage",
73
+ "test:mutation": "stryker run",
74
+ "typecheck": "tsc --noEmit",
75
+ "lint": "eslint .",
76
+ "lint:fix": "eslint . --fix",
77
+ "format": "prettier --write .",
78
+ "format:check": "prettier --check .",
79
+ "deadcode": "knip",
80
+ "size": "node scripts/check-size.mjs",
81
+ "audit:full": "bun audit && osv-scanner --lockfile=bun.lock",
82
+ "prepublishOnly": "bun run lint && bun run typecheck && bun run test && bun run build && bun run size && bun audit"
83
+ },
84
+ "dependencies": {
85
+ "@opencode-ai/plugin": "^1.15.9",
86
+ "zod": "^4.4.3"
87
+ },
88
+ "devDependencies": {
89
+ "@eslint/js": "^10.0.1",
90
+ "@opentui/core": "^0.2.15",
91
+ "@opentui/solid": "^0.2.15",
92
+ "@stryker-mutator/core": "^9.6.1",
93
+ "@stryker-mutator/vitest-runner": "^9.6.1",
94
+ "@types/node": "^22",
95
+ "@vitest/coverage-v8": "^4.1.7",
96
+ "eslint": "^10.4.0",
97
+ "eslint-plugin-n": "^18.0.1",
98
+ "eslint-plugin-promise": "^7.3.0",
99
+ "eslint-plugin-security": "^4.0.0",
100
+ "fast-check": "^4.8.0",
101
+ "knip": "^6.14.2",
102
+ "prettier": "^3.8.3",
103
+ "solid-js": "^1.9.13",
104
+ "typescript": "^5",
105
+ "typescript-eslint": "^8.59.4",
106
+ "vitest": "^4.1.7"
107
+ }
108
+ }