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 +43 -0
- package/LICENSE +21 -0
- package/README.md +309 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +325 -0
- package/dist/server.d.ts +54 -0
- package/dist/server.js +337 -0
- package/dist/shared.d.ts +6 -0
- package/dist/tui.d.ts +8 -0
- package/dist/tui.js +162 -0
- package/package.json +108 -0
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
|
+
[](https://github.com/jianlingzhong/opencode-gemini-rotator/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/jianlingzhong/opencode-gemini-rotator/actions/workflows/codeql.yml)
|
|
5
|
+
[](https://www.npmjs.com/package/opencode-gemini-rotator)
|
|
6
|
+
[](https://www.npmjs.com/package/opencode-gemini-rotator)
|
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
[](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
|
package/dist/index.d.ts
ADDED
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
|
+
};
|
package/dist/server.d.ts
ADDED
|
@@ -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
|
+
};
|
package/dist/shared.d.ts
ADDED
package/dist/tui.d.ts
ADDED
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
|
+
}
|