opencode-v2-gemini-rotator 0.0.0-stage → 2.0.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/LICENSE +22 -0
- package/README.en.md +163 -0
- package/README.md +132 -2
- package/README.ru.md +163 -0
- package/index.ts +354 -0
- package/llms-full.txt +63 -0
- package/llms.txt +20 -0
- package/package.json +31 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sabirjanov Abdurahmon
|
|
4
|
+
Copyright (c) 2026 Jianling Zhong
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/README.en.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Gemini Key Rotator for OpenCode V2
|
|
2
|
+
|
|
3
|
+
English | [Русский](./README.md)
|
|
4
|
+
|
|
5
|
+
**Maintained by: Sabirjanov Abdurahmon**
|
|
6
|
+
|
|
7
|
+
A server-side OpenCode V2 plugin that rotates a pool of Gemini API keys. On HTTP 429, HTTP 503, recognized quota errors, or a recognized invalid key, it updates the key state and uses OpenCode's retry mechanism to select another available key. The implementation adapts the V1 rotator's behavior to the V2 API.
|
|
8
|
+
|
|
9
|
+
**Tested:** The maintainer confirms real-world testing on OpenCode 2.2.0 with Google API keys from six accounts, including successful key switching after actual HTTP 429 and 503 errors. Compatibility with other V2 versions is not guaranteed.
|
|
10
|
+
|
|
11
|
+
The automated E2E tests in this repository are separate: they use a **local Gemini API mock and fake keys**, not live accounts.
|
|
12
|
+
|
|
13
|
+
The implementation is [`index.ts`](./index.ts). It exports `Plugin.define({ id, effect })` and explicitly registers `http.request`, `http.response`, and `retry` through `ctx.session.hook`. It does not patch `globalThis.fetch` or wrap a V1 plugin.
|
|
14
|
+
|
|
15
|
+
## 1. Installation
|
|
16
|
+
|
|
17
|
+
The following commands are for Linux, macOS, and other POSIX shells. Extract the archive, then run:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
cd opencode-gemini-rotator-v2
|
|
21
|
+
CONFIG_ROOT="${XDG_CONFIG_HOME:-$HOME/.config}/opencode"
|
|
22
|
+
mkdir -p "$CONFIG_ROOT/plugins/gemini-rotator"
|
|
23
|
+
cp index.ts LICENSE "$CONFIG_ROOT/plugins/gemini-rotator/"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The default location is `~/.config/opencode/plugins/gemini-rotator/index.ts`. OpenCode V2 automatically discovers plugins in this directory. No separate `npm install` or build step is required for normal use: OpenCode loads the single source file. Distribute the accompanying `LICENSE` file with the code to retain the MIT notices.
|
|
27
|
+
|
|
28
|
+
For a project-local installation, put `index.ts` and `LICENSE` in `.opencode/plugins/gemini-rotator/`. Do not load multiple copies of this plugin. If an older V1 rotator is installed, remove its entry from `plugin`/`plugins` and remove the old automatically discovered files without changing unrelated plugins.
|
|
29
|
+
|
|
30
|
+
Check your OpenCode version:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
opencode --version
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 2. Keys and Google provider activation
|
|
37
|
+
|
|
38
|
+
The rotator needs a key pool, and the **Google provider must also be activated** in OpenCode. Setting `GEMINI_API_KEYS` alone may not activate the provider.
|
|
39
|
+
|
|
40
|
+
For a standalone run from the current shell:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# Replace these placeholders with your own keys; avoid exposing them in shell history.
|
|
44
|
+
export GEMINI_API_KEYS='YOUR_GOOGLE_KEY_1,YOUR_GOOGLE_KEY_2,YOUR_GOOGLE_KEY_3,YOUR_GOOGLE_KEY_4,YOUR_GOOGLE_KEY_5,YOUR_GOOGLE_KEY_6'
|
|
45
|
+
# The provider credential is usually the first key in the pool.
|
|
46
|
+
export GOOGLE_GENERATIVE_AI_API_KEY='YOUR_GOOGLE_KEY_1'
|
|
47
|
+
|
|
48
|
+
# --standalone starts a private server with this shell's environment.
|
|
49
|
+
opencode run --standalone --model google/gemini-3.7-flash "Say hello"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The six keys above are an example, not a limit. Configure **1 to 256 keys** separated by commas. Empty entries and duplicates are removed. Google applies rate limits per project rather than per API key, so several keys belonging to the same project generally do not increase its quota.
|
|
53
|
+
|
|
54
|
+
Choose a model available to your account using `opencode models` or `/models`. The example uses `google/gemini-3.7-flash`. **As of October 27, 2026**, Google marks Gemini 3.7 Flash as deprecated and automatically routes its API requests to Gemini 3.8 Flash [1](https://ai.google.dev/gemini-api/docs/models/gemini-3.7-flash). If 3.7 is missing from your OpenCode catalog, select an available model such as `google/gemini-3.8-flash`.
|
|
55
|
+
|
|
56
|
+
Instead of setting `GOOGLE_GENERATIVE_AI_API_KEY`, you can connect Google through `/connect` and select a model with `/models`. Use the **Gemini API / Google** provider: Vertex and custom gateways are outside this plugin's scope.
|
|
57
|
+
|
|
58
|
+
### Background service
|
|
59
|
+
|
|
60
|
+
Exporting a variable in a shell does not update an already running server. For the managed service, set the variables and restart it:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# Set the two shell variables shown above first.
|
|
64
|
+
opencode service set env GEMINI_API_KEYS "$GEMINI_API_KEYS"
|
|
65
|
+
opencode service set env GOOGLE_GENERATIVE_AI_API_KEY "$GOOGLE_GENERATIVE_AI_API_KEY"
|
|
66
|
+
opencode service start
|
|
67
|
+
|
|
68
|
+
opencode run --model google/gemini-3.7-flash "Say hello"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
If Google is already connected through `/connect`, setting the provider credential through the service may not be necessary. `service set env` stores a value in service configuration and stops the running service; run `service start` after making all changes. Use `opencode service restart` after changing plugin files. `--standalone` and `opencode serve` use their own process environments and do not automatically inherit managed-service variables.
|
|
72
|
+
|
|
73
|
+
**Secrets:** Environment values and commands can appear in shell history, service configuration, or process listings. Do not share `opencode service get env` output or commit real keys. Use a secure storage method appropriate to your system. Configuration and keys are read when the plugin loads; restart the relevant server after changing its environment.
|
|
74
|
+
|
|
75
|
+
## Alternative: configure a plugin directory with options
|
|
76
|
+
|
|
77
|
+
Use this approach **instead of**, not in addition to, automatic discovery. Place the source outside `plugins/`:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
CONFIG_ROOT="${XDG_CONFIG_HOME:-$HOME/.config}/opencode"
|
|
81
|
+
mkdir -p "$CONFIG_ROOT/custom/gemini-rotator"
|
|
82
|
+
cp index.ts LICENSE "$CONFIG_ROOT/custom/gemini-rotator/"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Add an entry to the `plugins` array in your existing `$CONFIG_ROOT/opencode.json` without overwriting other settings:
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"plugins": [
|
|
90
|
+
{
|
|
91
|
+
"package": "./custom/gemini-rotator",
|
|
92
|
+
"options": {
|
|
93
|
+
"keys": [
|
|
94
|
+
"YOUR_GOOGLE_KEY_1", "YOUR_GOOGLE_KEY_2", "YOUR_GOOGLE_KEY_3",
|
|
95
|
+
"YOUR_GOOGLE_KEY_4", "YOUR_GOOGLE_KEY_5", "YOUR_GOOGLE_KEY_6"
|
|
96
|
+
],
|
|
97
|
+
"logFile": "/tmp/gemini-rotator-custom.log"
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`package` points to a **directory**, not directly to `index.ts`. Relative paths are resolved from the configuration file. `keys` can also be a comma-separated string. An explicitly supplied `options.keys`, even an empty array, takes precedence over `GEMINI_API_KEYS`. `logFile` is optional. Anyone who can read the JSON file can read keys stored there: restrict file permissions and keep secrets out of version control. Google provider activation is still required.
|
|
105
|
+
|
|
106
|
+
## How it works
|
|
107
|
+
|
|
108
|
+
1. For requests to the exact origin `https://generativelanguage.googleapis.com`, the first available configured key is selected. A single incoming `x-goog-api-key`, `?key=`, or Bearer credential is appended if not already present. As in V1, a comma-separated incoming credential specifies its own request pool. Empty entries and duplicates are removed; there is a limit of 256 candidates.
|
|
109
|
+
2. API keys are sent in `x-goog-api-key`; `ya29.*` and `Bearer ...` credentials use `Authorization`. The `?key=` query parameter is removed. Configured credentials are not inserted for other origins.
|
|
110
|
+
3. HTTP **429**, any HTTP **503**, and recognized quota errors in **400/403** place a key on cooldown. The plugin considers `retry-after-ms`, `Retry-After`, Google `RetryInfo.retryDelay`, and suitable hints in the error message. The default is 60 seconds for 429 and 10 seconds for other eligible errors, bounded to 250 ms–24 hours.
|
|
111
|
+
4. An `API_KEY_INVALID` reason or `API key not valid` message in a 400/403 response invalidates the key until the plugin instance reloads. Unrelated 400/403 errors and other status codes are not classified as rotation triggers.
|
|
112
|
+
5. If every valid key is cooling down, the earliest available time is selected. The wait before a new request uses interruptible `Effect.sleep`. If all keys are invalid, a retry is not forced, and a new request fails. Key state is kept in memory and is cleared when the plugin instance reloads.
|
|
113
|
+
6. The plugin does not send an extra HTTP request itself: it adjusts OpenCode's native retry decision and substitutes a key on the next attempt.
|
|
114
|
+
|
|
115
|
+
## Important limitations
|
|
116
|
+
|
|
117
|
+
- **Retries are finite.** In OpenCode 2.2.0, the main flow allows up to 10 retries after the first attempt; some errors, such as timeouts, have a lower limit. A request can still fail after the retry budget is exhausted or after output has started. Long waits can also exceed a surrounding timeout. This differs from V1's internal infinite retry loop.
|
|
118
|
+
- V2's `retry` event does not carry the original HTTP request ID. The plugin correlates failures on a best-effort basis using session, agent, model, status, and an expiry time. Concurrent requests in the same session can be correlated incorrectly. `title` and `generate` use a separate retry path: their 429/503 responses affect the key pool, but forced retries for 400/403 are not available in every path.
|
|
119
|
+
- Only the exact HTTPS Gemini API origin is supported. Custom `baseURL` values, Vertex AI, and third-party gateways are intentionally excluded to avoid sending configured keys to another host. A standard HTTPS CONNECT proxy that preserves the original URL is supported.
|
|
120
|
+
- Google [rate limits are per project](https://ai.google.dev/gemini-api/docs/rate-limits), not per API key. Check the limits and terms of the projects you use.
|
|
121
|
+
- The V1 TUI sidebar, toast notifications, and status JSON are **not included**. Use the optional debug log to observe rotations.
|
|
122
|
+
- Wait interruption is covered by an Effect unit test; end-to-end cancellation from the real OpenCode UI has not been tested separately.
|
|
123
|
+
|
|
124
|
+
## Debugging
|
|
125
|
+
|
|
126
|
+
For a standalone run:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
export OPENCODE_GEMINI_DEBUG=1
|
|
130
|
+
opencode run --standalone --model google/gemini-3.7-flash "Say hello"
|
|
131
|
+
tail -f "${TMPDIR:-/tmp}/gemini-rotator-debug.log"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The log is written to the server process's `os.tmpdir()`, which may be different on your system. For the managed service, set `OPENCODE_GEMINI_DEBUG` using `opencode service set env`, then run `opencode service start`. Alternatively, specify an absolute `options.logFile` path. The log includes a key's position in the pool and HTTP status, but not key values, URLs, request or response bodies, or exception text. A new file is created with mode `0600`; existing permissions are not changed. Logging failures do not abort requests.
|
|
135
|
+
|
|
136
|
+
If rotation does not appear, check that OpenCode V2 is in use, the plugin is not loaded twice, Google is activated, the **server** has the environment variables, and the endpoint has not been redirected to another host. A healthy first key will not rotate until a qualifying error occurs.
|
|
137
|
+
|
|
138
|
+
## Testing and development
|
|
139
|
+
|
|
140
|
+
No test files or `npm install` are required for ordinary use. For development, install Node.js (22.19+ recommended for dependencies) and npm:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
npm ci
|
|
144
|
+
npm test
|
|
145
|
+
npm run typecheck
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The optional black-box test starts a real OpenCode binary, creates a temporary certificate using `openssl`, and runs a local TLS CONNECT proxy. It simulates Gemini responses without real API keys. It requires OpenSSL with `-addext` support and OpenCode 2.2.0 in PATH:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
npm run test:e2e
|
|
152
|
+
# Or specify the CLI path:
|
|
153
|
+
npm run test:e2e -- /absolute/path/to/opencode
|
|
154
|
+
# More diagnostics:
|
|
155
|
+
E2E_DEBUG=1 npm run test:e2e
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
E2E scenarios cover 429/503, quota-related 403, invalid-key 400, all keys on cooldown, all keys invalid, and automatic discovery of `index.ts`. Unit tests additionally cover a six-key `GEMINI_API_KEYS` pool and the 256-key limit.
|
|
159
|
+
|
|
160
|
+
## Documentation and license
|
|
161
|
+
|
|
162
|
+
- OpenCode documentation: [V1-to-V2 migration](https://opencode.ai/v2/docs/build/plugins/migrate-v1/), [server hooks](https://opencode.ai/v2/docs/build/plugins/server/), [plugin loading](https://opencode.ai/v2/docs/plugins/), and [service environment](https://opencode.ai/v2/docs/network/).
|
|
163
|
+
- License: MIT. Keep [`LICENSE`](./LICENSE), including its existing copyright and permission notices, with redistributed copies of the code.
|
package/README.md
CHANGED
|
@@ -1,3 +1,133 @@
|
|
|
1
|
-
#
|
|
1
|
+
# OpenCode Gemini Rotator V2
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[English] | [Русский](README.ru.md)
|
|
4
|
+
|
|
5
|
+
**Maintained by: Sabirjanov Abdurahmon**
|
|
6
|
+
|
|
7
|
+
`opencode-v2-gemini-rotator` is a native OpenCode V2 server plugin that provides automatic Google Gemini API key rotation and smart rate-limit fallback. When encountering HTTP 429 (Rate Limit / Quota Exceeded), HTTP 503 (Service Unavailable), quota errors, or invalid keys, it temporarily excludes the active key, selects the next available key from your pool, and triggers OpenCode's native retry pipeline.
|
|
8
|
+
|
|
9
|
+
Tested and validated on **OpenCode 2.2.0** with a multi-key pool of Google Gemini API keys.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## What It Handles
|
|
14
|
+
|
|
15
|
+
The plugin intercepts responses from `https://generativelanguage.googleapis.com` and automatically classifies error responses to determine rotation policy:
|
|
16
|
+
|
|
17
|
+
| Status Code / Error | Category / Detection | Plugin Action | Cooldown Period |
|
|
18
|
+
| :--- | :--- | :--- | :--- |
|
|
19
|
+
| **HTTP 429** | Rate Limit / Resource Exhausted | Marks key on cooldown; rotates to next key in pool | Extracted from `Retry-After`, `retry-after-ms`, or Google `RetryInfo` (Default: 60s) |
|
|
20
|
+
| **HTTP 503** | Service Unavailable / Overloaded | Marks key on cooldown; rotates to next key in pool | Extracted from headers/body (Default: 10s) |
|
|
21
|
+
| **HTTP 400 / 403** | Quota Exceeded / Resource Exhausted | Marks key on cooldown; rotates to next key in pool | 10s default or parsed from response delay hints |
|
|
22
|
+
| **HTTP 400 / 403** | `API_KEY_INVALID` / "API key not valid" | Marks key as **permanently invalid** for current process session | Until plugin or server process reload |
|
|
23
|
+
| **Other 4xx / 5xx** | Generic Client/Server Errors | Passed through to OpenCode standard error handling | No key rotation forced |
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Who Is This For / Who Is This NOT For
|
|
28
|
+
|
|
29
|
+
### ✅ Who Is This For
|
|
30
|
+
- **Developers using OpenCode V2** with Google Gemini models (`google/gemini-2.5-flash`, `google/gemini-2.5-pro`, `google/gemini-3.7-flash`, etc.).
|
|
31
|
+
- Users running into **429 Rate Limit / Quota Exceeded** errors on free-tier or pay-as-you-go Gemini API accounts.
|
|
32
|
+
- AI coding agent workflows requiring uninterrupted long-running generation tasks using multiple Google API keys (pool size 1 to 256 keys).
|
|
33
|
+
- Developers who want a clean, single-file plugin without `npm install`, build steps, or monkey-patching `globalThis.fetch`.
|
|
34
|
+
|
|
35
|
+
### ❌ Who Is This NOT For
|
|
36
|
+
- **OpenCode V1 Users:** This plugin uses OpenCode V2 Server Plugin APIs (`Plugin.define`, `ctx.session.hook`, Effect runtime) and is incompatible with OpenCode V1.
|
|
37
|
+
- **Google Vertex AI / Custom Proxies:** For security and key safety, this plugin exclusively targets the exact origin `https://generativelanguage.googleapis.com`. Custom `baseURL` and Vertex AI endpoints are ignored.
|
|
38
|
+
- **Single Google Cloud Project multi-key pools:** Google applies rate limits at the GCP Project level. Using multiple keys generated from the exact same GCP project will not increase your project quota. Use keys across different GCP projects.
|
|
39
|
+
- **Users expecting TUI status bars / toast UI popups:** OpenCode V2 plugin API handles backend request hooks. Debug logs are accessible via environment variable (`OPENCODE_GEMINI_DEBUG=1`).
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Quick Start & Installation
|
|
44
|
+
|
|
45
|
+
### Option A: Global Single-File Installation (Recommended)
|
|
46
|
+
|
|
47
|
+
In POSIX shell (Linux / macOS):
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
CONFIG_ROOT="${XDG_CONFIG_HOME:-$HOME/.config}/opencode"
|
|
51
|
+
mkdir -p "$CONFIG_ROOT/plugins/gemini-rotator"
|
|
52
|
+
cp index.ts LICENSE "$CONFIG_ROOT/plugins/gemini-rotator/"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
OpenCode V2 automatically discovers plugins inside `~/.config/opencode/plugins/gemini-rotator/index.ts`. No `npm install` or compilation step is required.
|
|
56
|
+
|
|
57
|
+
### Option B: Project-Level Installation
|
|
58
|
+
|
|
59
|
+
Place `index.ts` and `LICENSE` in `.opencode/plugins/gemini-rotator/` within your project root directory.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Key Configuration
|
|
64
|
+
|
|
65
|
+
Set your pool of Gemini API keys via the `GEMINI_API_KEYS` environment variable (comma-separated, supporting 1 to 256 keys):
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
export GEMINI_API_KEYS='YOUR_GOOGLE_KEY_1,YOUR_GOOGLE_KEY_2,YOUR_GOOGLE_KEY_3'
|
|
69
|
+
export GOOGLE_GENERATIVE_AI_API_KEY='YOUR_GOOGLE_KEY_1' # Primary provider key
|
|
70
|
+
|
|
71
|
+
# Run standalone CLI
|
|
72
|
+
opencode run --standalone --model google/gemini-2.5-flash "Write a hello world function"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Managed Background Service
|
|
76
|
+
|
|
77
|
+
If using `opencode service`:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
opencode service set env GEMINI_API_KEYS "$GEMINI_API_KEYS"
|
|
81
|
+
opencode service set env GOOGLE_GENERATIVE_AI_API_KEY "$GOOGLE_GENERATIVE_AI_API_KEY"
|
|
82
|
+
opencode service start
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### JSON Options Configuration (`opencode.json`)
|
|
86
|
+
|
|
87
|
+
Alternative setup placing source in custom location:
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"plugins": [
|
|
92
|
+
{
|
|
93
|
+
"package": "./custom/gemini-rotator",
|
|
94
|
+
"options": {
|
|
95
|
+
"keys": [
|
|
96
|
+
"YOUR_GOOGLE_KEY_1",
|
|
97
|
+
"YOUR_GOOGLE_KEY_2",
|
|
98
|
+
"YOUR_GOOGLE_KEY_3"
|
|
99
|
+
],
|
|
100
|
+
"logFile": "/tmp/gemini-rotator-debug.log"
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
]
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Debugging
|
|
110
|
+
|
|
111
|
+
To observe rotation events in real-time, enable debug logging:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
export OPENCODE_GEMINI_DEBUG=1
|
|
115
|
+
opencode run --standalone --model google/gemini-2.5-flash "Say hello"
|
|
116
|
+
tail -f "${TMPDIR:-/tmp}/gemini-rotator-debug.log"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The log records active key indices, HTTP error status codes, and cooldown durations without logging key secrets, request URLs, or payloads.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Documentation & Machine Readable Specs
|
|
124
|
+
|
|
125
|
+
- **Full HTML Documentation:** [OpenCode Gemini Rotator V2 Docs](https://sngx762.github.io/opencode-v2-gemini-rotator/)
|
|
126
|
+
- **Machine Readability (LLMs):** [llms.txt](./llms.txt) | [llms-full.txt](./llms-full.txt)
|
|
127
|
+
- **Russian Guide:** [README.ru.md](./README.ru.md)
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT License. See [LICENSE](./LICENSE) for details.
|
package/README.ru.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Gemini Key Rotator для OpenCode V2
|
|
2
|
+
|
|
3
|
+
[English](./README.en.md) | Русский
|
|
4
|
+
|
|
5
|
+
**Maintained by: Sabirjanov Abdurahmon**
|
|
6
|
+
|
|
7
|
+
Локальный server-плагин для OpenCode V2 с адаптированной логикой ротации ключей V1. При 429, 503, ошибках квоты и распознанном недействительном ключе он помечает ключ, выбирает следующий и использует механизм повторов OpenCode.
|
|
8
|
+
|
|
9
|
+
**Tested:** Проверено в реальной работе на OpenCode 2.2.0 с реальными API-ключами Google (пул из 6 аккаунтов). Плагин успешно переживает реальные ошибки 429 (лимиты) и 503 (перегрузка серверов), автоматически переключая ключи. Эту проверку подтвердил сопровождающий проекта. На других версиях V2 совместимость не гарантируется.
|
|
10
|
+
|
|
11
|
+
Автоматические E2E-тесты из этого репозитория отдельно используют **локальный имитатор Gemini API и тестовые ключи**, а не реальные аккаунты.
|
|
12
|
+
|
|
13
|
+
Код — в [`index.ts`](./index.ts). Это не V1-обёртка и не патч `globalThis.fetch`: экспортируется `Plugin.define({ id, effect })`, а хуки `http.request`, `http.response` и `retry` явно регистрируются через `ctx.session.hook`. Effect-вариант — штатная альтернатива `setup` в V2.
|
|
14
|
+
|
|
15
|
+
## 1. Установка
|
|
16
|
+
|
|
17
|
+
Команды ниже — для Linux/macOS или другой POSIX-оболочки. Распакуйте архив и из каталога проекта выполните:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
cd opencode-gemini-rotator-v2
|
|
21
|
+
CONFIG_ROOT="${XDG_CONFIG_HOME:-$HOME/.config}/opencode"
|
|
22
|
+
mkdir -p "$CONFIG_ROOT/plugins/gemini-rotator"
|
|
23
|
+
cp index.ts LICENSE "$CONFIG_ROOT/plugins/gemini-rotator/"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
В обычной конфигурации код окажется в `~/.config/opencode/plugins/gemini-rotator/index.ts`. OpenCode V2 автоматически обнаруживает глобальные плагины в этом каталоге. Для запуска отдельно выполнять `npm install` или сборку **не нужно**: OpenCode загружает один файл кода. Файл `LICENSE` нужно распространять вместе с кодом, чтобы сохранить уведомления MIT.
|
|
27
|
+
|
|
28
|
+
Для отдельного проекта альтернативный путь — `.opencode/plugins/gemini-rotator/` внутри проекта; разместите там `index.ts` и `LICENSE`. Не включайте одновременно несколько копий этого плагина. Если установлена предыдущая V1-версия ротатора, удалите её запись из `plugin`/`plugins` в соответствующих конфигурациях и уберите старые автоматически обнаруживаемые файлы. Другие плагины и настройки удалять не нужно.
|
|
29
|
+
|
|
30
|
+
Проверьте версию:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
opencode --version
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 2. Ключи и подключение Google
|
|
37
|
+
|
|
38
|
+
Плагину нужен список ключей, а **сам провайдер Google нужно отдельно активировать** в OpenCode. Одной переменной `GEMINI_API_KEYS` для активации провайдера может быть недостаточно.
|
|
39
|
+
|
|
40
|
+
Вариант для разового запуска в текущем терминале:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# Замените значения своими ключами; не оставляйте их в общедоступной истории shell.
|
|
44
|
+
export GEMINI_API_KEYS='YOUR_GOOGLE_KEY_1,YOUR_GOOGLE_KEY_2,YOUR_GOOGLE_KEY_3,YOUR_GOOGLE_KEY_4,YOUR_GOOGLE_KEY_5,YOUR_GOOGLE_KEY_6'
|
|
45
|
+
# Учетные данные провайдера; обычно это первый ключ из списка.
|
|
46
|
+
export GOOGLE_GENERATIVE_AI_API_KEY='YOUR_GOOGLE_KEY_1'
|
|
47
|
+
|
|
48
|
+
# --standalone запускает отдельный сервер с окружением этого терминала.
|
|
49
|
+
opencode run --standalone --model google/gemini-3.7-flash "Скажи привет"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Шесть ключей выше — только пример: можно указать **от 1 до 256 ключей**, разделённых запятыми. Пустые элементы и дубликаты удаляются. Убедитесь, что ключи относятся к нужным проектам Google: квоты применяются на уровне проекта, а не отдельного ключа.
|
|
53
|
+
|
|
54
|
+
Выберите доступную вам Google-модель через `opencode models` или `/models` в интерактивном интерфейсе. В примере указан `google/gemini-3.7-flash`. **По состоянию на 27 октября 2026 года** Google помечает Gemini 3.7 Flash устаревшей и автоматически направляет запросы к Gemini 3.8 Flash [1](https://ai.google.dev/gemini-api/docs/models/gemini-3.7-flash). Если 3.7 отсутствует в каталоге OpenCode, выберите доступную модель, например `google/gemini-3.8-flash`.
|
|
55
|
+
|
|
56
|
+
Вместо `GOOGLE_GENERATIVE_AI_API_KEY` можно подключить Google через `/connect` в интерфейсе OpenCode, после чего выбрать модель через `/models`. Проверяйте именно **Gemini API/Google**; маршруты Vertex и произвольные шлюзы этим плагином не обслуживаются.
|
|
57
|
+
|
|
58
|
+
### Если используется фоновый сервис
|
|
59
|
+
|
|
60
|
+
Экспорт в текущем shell не меняет окружение уже запущенного сервера. Для управляемого сервиса задайте переменные и запустите сервис заново:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# Сначала задайте обе переменные в текущей оболочке, как показано выше.
|
|
64
|
+
opencode service set env GEMINI_API_KEYS "$GEMINI_API_KEYS"
|
|
65
|
+
opencode service set env GOOGLE_GENERATIVE_AI_API_KEY "$GOOGLE_GENERATIVE_AI_API_KEY"
|
|
66
|
+
opencode service start
|
|
67
|
+
|
|
68
|
+
opencode run --model google/gemini-3.7-flash "Скажи привет"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Если Google уже подключён через `/connect`, вторая строка `service set env` может не понадобиться. Команда `service set env` сохраняет значение в конфигурации сервиса и останавливает работающий сервис; после всех изменений выполните `service start`. При изменении файлов плагина можно выполнить `opencode service restart`. У `--standalone` и `opencode serve` собственное окружение процесса; они не получают эти значения автоматически из управляемого сервиса.
|
|
72
|
+
|
|
73
|
+
**Секреты:** значения переменных и команды с ними могут попасть в историю оболочки, конфигурацию сервиса и список процессов. Не отправляйте вывод `opencode service get env` другим людям и не коммитьте ключи в Git. Выбирайте подходящий способ хранения секретов для своей системы. Настройки и ключи читаются при загрузке плагина; после изменения окружения перезапустите соответствующий сервер.
|
|
74
|
+
|
|
75
|
+
## Альтернатива: явно подключить каталог с options
|
|
76
|
+
|
|
77
|
+
Используйте **вместо** автозагрузки выше, а не вместе с ней. Например, разместите исходник вне `plugins/`:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
CONFIG_ROOT="${XDG_CONFIG_HOME:-$HOME/.config}/opencode"
|
|
81
|
+
mkdir -p "$CONFIG_ROOT/custom/gemini-rotator"
|
|
82
|
+
cp index.ts LICENSE "$CONFIG_ROOT/custom/gemini-rotator/"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Добавьте элемент в массив `plugins` существующего `$CONFIG_ROOT/opencode.json` (не затирайте остальные настройки):
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"plugins": [
|
|
90
|
+
{
|
|
91
|
+
"package": "./custom/gemini-rotator",
|
|
92
|
+
"options": {
|
|
93
|
+
"keys": [
|
|
94
|
+
"YOUR_GOOGLE_KEY_1", "YOUR_GOOGLE_KEY_2", "YOUR_GOOGLE_KEY_3",
|
|
95
|
+
"YOUR_GOOGLE_KEY_4", "YOUR_GOOGLE_KEY_5", "YOUR_GOOGLE_KEY_6"
|
|
96
|
+
],
|
|
97
|
+
"logFile": "/tmp/gemini-rotator-custom.log"
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Путь `package` указывает на **каталог**, а не на `index.ts`; относительный путь разрешается относительно файла конфигурации. `keys` также может быть одной строкой с ключами через запятую. Явно заданный `options.keys`, даже пустой массив, имеет приоритет над `GEMINI_API_KEYS`. `logFile` необязателен. Хранение ключей в JSON означает, что к ним получит доступ каждый, кто может прочитать файл; ограничьте права и не включайте файл в репозиторий. Активация провайдера Google по-прежнему нужна.
|
|
105
|
+
|
|
106
|
+
## Поведение
|
|
107
|
+
|
|
108
|
+
1. При запросе к точному origin `https://generativelanguage.googleapis.com` выбирается первый доступный ключ из настроенного списка. Одиночный ключ из исходного `x-goog-api-key`, `?key=` или Bearer-заголовка добавляется в конец списка, если его там ещё нет. Список через запятую в самом исходном credential, как и в V1, задаёт отдельный пул для этого запроса. Пустые элементы и дубликаты убираются. Лимит — 256 кандидатов.
|
|
109
|
+
2. API-ключ отправляется в `x-goog-api-key`, а `ya29.*` и `Bearer ...` — в `Authorization`. `?key=` из URL удаляется. На другие origin плагин ключи не подставляет.
|
|
110
|
+
3. При **429** и любом **503**, а также при распознанной ошибке квоты в **400/403**, ключ временно исключается. Учитываются `retry-after-ms`, `Retry-After`, Google `RetryInfo.retryDelay` или подходящая подсказка в сообщении. Без подсказки: 60 секунд для 429 и 10 секунд для остальных; задержка ограничена диапазоном 250 мс — 24 часа.
|
|
111
|
+
4. При `API_KEY_INVALID` или сообщении `API key not valid` в 400/403 ключ считается недействительным до выгрузки/перезагрузки экземпляра плагина. Прочие ошибки 400/403 и остальные статусы плагин не классифицирует как повод для ротации.
|
|
112
|
+
5. Если все действительные ключи на паузе, выбирается ближайший срок освобождения. Задержка перед новым запросом использует прерываемый `Effect.sleep`; если все ключи недействительны, повтор не форсируется, а новый запрос завершается ошибкой. Статусы и cooldown хранятся только в памяти экземпляра плагина; его перезагрузка сбрасывает их.
|
|
113
|
+
6. Плагин не делает собственный дополнительный HTTP-запрос: он задаёт политику штатного повтора V2 и подставляет следующий ключ в следующей попытке.
|
|
114
|
+
|
|
115
|
+
## Важные ограничения
|
|
116
|
+
|
|
117
|
+
- **Повторы не бесконечны.** В OpenCode 2.2.0 для основного потока есть встроенный предел: до 10 повторов после первой попытки; для некоторых ошибок, например timeout, предел меньше. После расходования бюджета или после начала выдачи текста запрос всё равно может завершиться ошибкой. Если пауза превышает лимиты окружения или запроса, возможно прерывание по timeout. Это отличается от внутреннего бесконечного цикла V1.
|
|
118
|
+
- В V2 событие `retry` не передаёт ID исходного HTTP-запроса. Связь с ошибкой восстанавливается приблизительно по сессии, агенту, модели и HTTP-статусу с ограниченным временем жизни записи. При конкурентных запросах в одной сессии соответствие не гарантировано. `title` и `generate` используют собственный путь повторов: их 429/503 влияют на выбор ключа при следующих попытках, но принудительный повтор для 400/403 есть не во всех путях.
|
|
119
|
+
- Распознаётся только точный HTTPS origin Google Gemini API. Кастомный `baseURL`, Vertex AI и сторонний шлюз намеренно исключены, чтобы не отправлять туда ключи. Обычный сетевой HTTPS CONNECT-прокси, сохраняющий исходный URL, допустим.
|
|
120
|
+
- Google применяет [rate limits на уровне проекта](https://ai.google.dev/gemini-api/docs/rate-limits), а не каждого ключа. Несколько ключей одного проекта обычно не увеличат его квоту; проверяйте условия и лимиты своих проектов.
|
|
121
|
+
- V1 TUI-сайдбар, toast-уведомления и статусный JSON **не перенесены**. Ротацию можно наблюдать через необязательный отладочный лог.
|
|
122
|
+
- Прерывание ожидания проверено unit-тестом Effect; отдельный end-to-end тест отмены из реального интерфейса OpenCode не проводился.
|
|
123
|
+
|
|
124
|
+
## Отладка
|
|
125
|
+
|
|
126
|
+
Для standalone запустите процесс с `OPENCODE_GEMINI_DEBUG=1`:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
export OPENCODE_GEMINI_DEBUG=1
|
|
130
|
+
opencode run --standalone --model google/gemini-3.7-flash "Скажи привет"
|
|
131
|
+
tail -f "${TMPDIR:-/tmp}/gemini-rotator-debug.log"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Файл пишется в `os.tmpdir()` процесса сервера; в зависимости от системы это может быть другой каталог. Для фонового сервиса установите `OPENCODE_GEMINI_DEBUG` через `opencode service set env`, затем `opencode service start`. Альтернативно задайте `options.logFile` с абсолютным путём. Лог содержит номер ключа в пуле и статус, но не значение ключа, URL, тело запроса, тело ответа или текст ошибки. Новый файл создаётся с режимом `0600`; права существующего файла не меняются. Запись лога необязательна: её ошибка не прерывает запрос.
|
|
135
|
+
|
|
136
|
+
При поиске неисправности проверьте, что используется OpenCode V2, плагин не загружен дважды, Google-провайдер активен, переменные доступны **серверу**, а endpoint не переопределён на другой host. Если первый ключ работает, переключения в логе не будет до первой подходящей ошибки.
|
|
137
|
+
|
|
138
|
+
## Проверки и разработка
|
|
139
|
+
|
|
140
|
+
Для обычного использования файлы тестов и `npm install` не нужны. Для разработки понадобятся Node.js (рекомендуется 22.19+ для зависимостей), npm и исходники архива:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
npm ci
|
|
144
|
+
npm test # unit-тесты
|
|
145
|
+
npm run typecheck # TypeScript
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Дополнительный black-box тест запускает настоящий OpenCode, создаёт временный сертификат с `openssl` и локальный TLS CONNECT proxy. Он не использует реальные API-ключи и моделирует ответы Gemini. Нужны `openssl` с поддержкой `-addext` и OpenCode 2.2.0 в PATH:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
npm run test:e2e
|
|
152
|
+
# или указать путь к CLI:
|
|
153
|
+
npm run test:e2e -- /absolute/path/to/opencode
|
|
154
|
+
# подробности диагностики:
|
|
155
|
+
E2E_DEBUG=1 npm run test:e2e
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
E2E-проверки охватывают ротацию при 429/503, quota-403 и invalid-key-400, ожидание при cooldown всех ключей, остановку при всех недействительных ключах, а также автозагрузку `index.ts`. Unit-тесты дополнительно проверяют пул из шести ключей через `GEMINI_API_KEYS` и ограничение в 256 ключей.
|
|
159
|
+
|
|
160
|
+
## Документация и лицензия
|
|
161
|
+
|
|
162
|
+
- Документация OpenCode: [миграция плагинов V1 → V2](https://opencode.ai/v2/docs/build/plugins/migrate-v1/), [серверные хуки](https://opencode.ai/v2/docs/build/plugins/server/), [загрузка плагинов](https://opencode.ai/v2/docs/plugins/), [окружение сервиса](https://opencode.ai/v2/docs/network/).
|
|
163
|
+
- Лицензия: MIT. При распространении сохраняйте [`LICENSE`](./LICENSE) вместе с кодом.
|
package/index.ts
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Gemini Key Rotator for OpenCode V2.
|
|
3
|
+
* Maintainer: Sabirjanov Abdurahmon.
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
* Copyright and license notices: see the accompanying LICENSE file.
|
|
6
|
+
* Targeted and tested against OpenCode 2.2.0 / @opencode/plugin 2.2.0.
|
|
7
|
+
*/
|
|
8
|
+
import { Plugin } from "@opencode/plugin/effect"
|
|
9
|
+
import type { SessionHttpResponse, SessionRetry } from "@opencode/plugin/effect/session"
|
|
10
|
+
import { Duration, Effect } from "effect"
|
|
11
|
+
import { appendFile } from "node:fs/promises"
|
|
12
|
+
import { tmpdir } from "node:os"
|
|
13
|
+
import { join } from "node:path"
|
|
14
|
+
|
|
15
|
+
const GEMINI_ORIGIN = "https://generativelanguage.googleapis.com"
|
|
16
|
+
const MAX_CONFIGURED_KEYS = 256
|
|
17
|
+
const MAX_TRACKED_KEYS = 512
|
|
18
|
+
const MAX_ERROR_BYTES = 64 * 1024
|
|
19
|
+
const ERROR_READ_TIMEOUT_MS = 1_500
|
|
20
|
+
const FAILURE_TTL_MS = 2 * 60_000
|
|
21
|
+
const MAX_FAILURES = 512
|
|
22
|
+
const MIN_COOLDOWN_MS = 250
|
|
23
|
+
const MAX_COOLDOWN_MS = 24 * 60 * 60_000
|
|
24
|
+
|
|
25
|
+
type KeyState = { invalid: boolean; availableAt: number }
|
|
26
|
+
type RequestMeta = { candidates: string[] }
|
|
27
|
+
type Failure = { at: number; candidates: string[] }
|
|
28
|
+
type Classification = { kind: "invalid" } | { kind: "cooldown"; ms: number }
|
|
29
|
+
|
|
30
|
+
/** Normalize OAuth values so `ya29.x` and `Bearer ya29.x` share one state. */
|
|
31
|
+
function normalizeKey(value: string): string | undefined {
|
|
32
|
+
let key = value.trim()
|
|
33
|
+
if (!key) return undefined
|
|
34
|
+
if (/^bearer\s+/i.test(key)) key = `Bearer ${key.replace(/^bearer\s+/i, "")}`
|
|
35
|
+
else if (key.startsWith("ya29.")) key = `Bearer ${key}`
|
|
36
|
+
const secret = key.startsWith("Bearer ") ? key.slice(7) : key
|
|
37
|
+
// Reject CR/LF, whitespace, non-ASCII and unreasonably large header values.
|
|
38
|
+
if (!secret || secret.length > 8_192 || !/^[\x21-\x7e]+$/.test(secret)) return undefined
|
|
39
|
+
return key
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function uniqueKeys(values: string[]): string[] {
|
|
43
|
+
const keys: string[] = []
|
|
44
|
+
for (const value of values) {
|
|
45
|
+
const trimmed = value.trim()
|
|
46
|
+
if (!trimmed) continue
|
|
47
|
+
const key = normalizeKey(trimmed)
|
|
48
|
+
if (!key) throw new Error("[gemini-rotator] Invalid key format in plugin configuration")
|
|
49
|
+
if (!keys.includes(key)) keys.push(key)
|
|
50
|
+
}
|
|
51
|
+
if (keys.length > MAX_CONFIGURED_KEYS) {
|
|
52
|
+
throw new Error(`[gemini-rotator] At most ${MAX_CONFIGURED_KEYS} keys are supported`)
|
|
53
|
+
}
|
|
54
|
+
return keys
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function configuredKeys(options: Readonly<Record<string, unknown>>): string[] {
|
|
58
|
+
const raw = options.keys === undefined ? process.env.GEMINI_API_KEYS : options.keys
|
|
59
|
+
if (raw === undefined) return []
|
|
60
|
+
if (typeof raw === "string") return uniqueKeys(raw.split(","))
|
|
61
|
+
if (Array.isArray(raw) && raw.every((entry) => typeof entry === "string")) {
|
|
62
|
+
return uniqueKeys(raw as string[])
|
|
63
|
+
}
|
|
64
|
+
throw new Error("[gemini-rotator] options.keys must be a string or an array of strings")
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function logger(options: Readonly<Record<string, unknown>>): (message: string) => void {
|
|
68
|
+
if (options.logFile !== undefined && typeof options.logFile !== "string") {
|
|
69
|
+
throw new Error("[gemini-rotator] options.logFile must be a path string")
|
|
70
|
+
}
|
|
71
|
+
const path = (options.logFile as string | undefined) ||
|
|
72
|
+
(process.env.OPENCODE_GEMINI_DEBUG === "1" ? join(tmpdir(), "gemini-rotator-debug.log") : undefined)
|
|
73
|
+
if (!path) return () => {}
|
|
74
|
+
return (message) => {
|
|
75
|
+
// Never write response bodies, URLs, headers, keys, or exception text.
|
|
76
|
+
void appendFile(path, `[${new Date().toISOString()}] ${message}\n`, { mode: 0o600 }).catch(() => {})
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function geminiURL(request: Request): URL | undefined {
|
|
81
|
+
try {
|
|
82
|
+
const url = new URL(request.url)
|
|
83
|
+
// Do not send Google credentials to proxies, custom gateways, Vertex, or other hosts.
|
|
84
|
+
return url.origin === GEMINI_ORIGIN ? url : undefined
|
|
85
|
+
} catch {
|
|
86
|
+
return undefined
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function providedCredentials(request: Request, url: URL): string[] {
|
|
91
|
+
const auth = request.headers.get("authorization")
|
|
92
|
+
const supplied = request.headers.get("x-goog-api-key") || url.searchParams.get("key") ||
|
|
93
|
+
(auth && /^bearer\s+/i.test(auth) ? auth : "")
|
|
94
|
+
if (!supplied) return []
|
|
95
|
+
// The V1 plugin accepted a comma-separated list in an incoming credential.
|
|
96
|
+
return supplied.split(",").map(normalizeKey).filter((key): key is string => key !== undefined)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
class KeyPool {
|
|
100
|
+
private readonly states = new Map<string, KeyState>()
|
|
101
|
+
private readonly protectedKeys: Set<string>
|
|
102
|
+
|
|
103
|
+
constructor(readonly configured: string[]) {
|
|
104
|
+
this.protectedKeys = new Set(configured)
|
|
105
|
+
for (const key of configured) this.state(key)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
state(key: string): KeyState {
|
|
109
|
+
const existing = this.states.get(key)
|
|
110
|
+
if (existing) {
|
|
111
|
+
// LRU order for bounded tracking of dynamically observed credentials.
|
|
112
|
+
this.states.delete(key)
|
|
113
|
+
this.states.set(key, existing)
|
|
114
|
+
return existing
|
|
115
|
+
}
|
|
116
|
+
if (this.states.size >= MAX_TRACKED_KEYS) {
|
|
117
|
+
const evict = [...this.states.keys()].find((item) => !this.protectedKeys.has(item))
|
|
118
|
+
if (evict !== undefined) this.states.delete(evict)
|
|
119
|
+
}
|
|
120
|
+
const created: KeyState = { invalid: false, availableAt: 0 }
|
|
121
|
+
this.states.set(key, created)
|
|
122
|
+
return created
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
knows(key: string): boolean {
|
|
126
|
+
return this.states.has(key)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
candidates(request: Request, url: URL): string[] {
|
|
130
|
+
const supplied = providedCredentials(request, url)
|
|
131
|
+
// Match V1: an incoming comma-separated credential supplies its own pool.
|
|
132
|
+
const raw = request.headers.get("x-goog-api-key") || url.searchParams.get("key") ||
|
|
133
|
+
request.headers.get("authorization") || ""
|
|
134
|
+
const candidates = raw.includes(",") ? supplied : [...this.configured, ...supplied]
|
|
135
|
+
return [...new Set(candidates)].slice(0, MAX_CONFIGURED_KEYS)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
choice(candidates: string[], now = Date.now()): { key: string; wait: number } | undefined {
|
|
139
|
+
let earliest: { key: string; wait: number } | undefined
|
|
140
|
+
for (const key of candidates) {
|
|
141
|
+
const state = this.state(key)
|
|
142
|
+
if (state.invalid) continue
|
|
143
|
+
const wait = Math.max(0, state.availableAt - now)
|
|
144
|
+
if (wait === 0) return { key, wait: 0 }
|
|
145
|
+
if (!earliest || wait < earliest.wait) earliest = { key, wait }
|
|
146
|
+
}
|
|
147
|
+
return earliest
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function sendWithKey(event: { request: Request }, url: URL, key: string): void {
|
|
152
|
+
// Request URL is immutable. Rebuild only when removing a query-string key;
|
|
153
|
+
// Request copies method, body, headers, and signal to the new request.
|
|
154
|
+
if (url.searchParams.has("key")) {
|
|
155
|
+
url.searchParams.delete("key")
|
|
156
|
+
event.request = new Request(url.toString(), event.request)
|
|
157
|
+
}
|
|
158
|
+
if (key.startsWith("Bearer ")) {
|
|
159
|
+
event.request.headers.set("authorization", key)
|
|
160
|
+
event.request.headers.delete("x-goog-api-key")
|
|
161
|
+
} else {
|
|
162
|
+
event.request.headers.set("x-goog-api-key", key)
|
|
163
|
+
event.request.headers.delete("authorization")
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
168
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
169
|
+
? value as Record<string, unknown>
|
|
170
|
+
: undefined
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Read a bounded clone, leaving the response OpenCode will consume untouched. */
|
|
174
|
+
async function readErrorJSON(response: Response, signal: AbortSignal): Promise<unknown> {
|
|
175
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
|
|
176
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
177
|
+
const cancel = () => { if (reader) void reader.cancel().catch(() => {}) }
|
|
178
|
+
try {
|
|
179
|
+
const clone = response.clone()
|
|
180
|
+
if (!clone.body) return undefined
|
|
181
|
+
reader = clone.body.getReader()
|
|
182
|
+
signal.addEventListener("abort", cancel, { once: true })
|
|
183
|
+
if (signal.aborted) return undefined
|
|
184
|
+
timer = setTimeout(cancel, ERROR_READ_TIMEOUT_MS)
|
|
185
|
+
const decoder = new TextDecoder()
|
|
186
|
+
let text = ""
|
|
187
|
+
let bytes = 0
|
|
188
|
+
for (;;) {
|
|
189
|
+
const part = await reader.read()
|
|
190
|
+
if (part.done) break
|
|
191
|
+
bytes += part.value.byteLength
|
|
192
|
+
if (bytes > MAX_ERROR_BYTES) return undefined
|
|
193
|
+
text += decoder.decode(part.value, { stream: true })
|
|
194
|
+
}
|
|
195
|
+
text += decoder.decode()
|
|
196
|
+
return JSON.parse(text)
|
|
197
|
+
} catch {
|
|
198
|
+
return undefined
|
|
199
|
+
} finally {
|
|
200
|
+
if (timer) clearTimeout(timer)
|
|
201
|
+
signal.removeEventListener("abort", cancel)
|
|
202
|
+
cancel() // Do not await cancellation of one branch of a tee'd stream.
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function parseDuration(value: string): number | undefined {
|
|
207
|
+
const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h)$/i)
|
|
208
|
+
if (!match) return undefined
|
|
209
|
+
const scale: Record<string, number> = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000 }
|
|
210
|
+
const ms = Number(match[1]) * scale[match[2].toLowerCase()]
|
|
211
|
+
return Number.isFinite(ms) ? ms : undefined
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function retryAfter(headers: Headers, error: Record<string, unknown> | undefined): number | undefined {
|
|
215
|
+
const millis = headers.get("retry-after-ms")
|
|
216
|
+
if (millis && /^\d+(?:\.\d+)?$/.test(millis.trim())) return Number(millis)
|
|
217
|
+
const header = headers.get("retry-after")?.trim()
|
|
218
|
+
if (header) {
|
|
219
|
+
if (/^\d+(?:\.\d+)?$/.test(header)) return Number(header) * 1_000
|
|
220
|
+
const date = Date.parse(header)
|
|
221
|
+
if (Number.isFinite(date)) return Math.max(0, date - Date.now())
|
|
222
|
+
}
|
|
223
|
+
const details = Array.isArray(error?.details) ? error.details : []
|
|
224
|
+
for (const detail of details) {
|
|
225
|
+
const entry = record(detail)
|
|
226
|
+
if (typeof entry?.retryDelay === "string") {
|
|
227
|
+
const ms = parseDuration(entry.retryDelay)
|
|
228
|
+
if (ms !== undefined) return ms
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const message = typeof error?.message === "string" ? error.message : ""
|
|
232
|
+
const match = message.match(/(?:reset\s+after|retry\s+(?:in|after))\s+(\d+(?:\.\d+)?\s*(?:ms|s|m|h))\b/i)
|
|
233
|
+
return match ? parseDuration(match[1]) : undefined
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function classify(response: Response, data: unknown): Classification | undefined {
|
|
237
|
+
const status = response.status
|
|
238
|
+
if (![400, 403, 429, 503].includes(status)) return undefined
|
|
239
|
+
const error = record(record(data)?.error)
|
|
240
|
+
const message = typeof error?.message === "string" ? error.message.toLowerCase() : ""
|
|
241
|
+
const errorStatus = typeof error?.status === "string" ? error.status.toLowerCase() : ""
|
|
242
|
+
const details = Array.isArray(error?.details) ? error.details : []
|
|
243
|
+
const reasons = details.map((detail) => record(detail)?.reason)
|
|
244
|
+
.filter((reason): reason is string => typeof reason === "string")
|
|
245
|
+
.map((reason) => reason.toLowerCase())
|
|
246
|
+
|
|
247
|
+
if ((status === 400 || status === 403) &&
|
|
248
|
+
(message.includes("api key not valid") || reasons.some((reason) => reason.includes("api_key_invalid")))) {
|
|
249
|
+
return { kind: "invalid" }
|
|
250
|
+
}
|
|
251
|
+
const quota = message.includes("quota") || message.includes("rate limit") ||
|
|
252
|
+
reasons.some((reason) => reason.includes("rate_limit") || reason.includes("quota_exceeded")) ||
|
|
253
|
+
errorStatus === "resource_exhausted" || errorStatus === "unavailable"
|
|
254
|
+
// Unlike V1, every 503 rotates, including a non-JSON UNAVAILABLE response.
|
|
255
|
+
if (status !== 429 && status !== 503 && !quota) return undefined
|
|
256
|
+
const suggested = retryAfter(response.headers, error)
|
|
257
|
+
const fallback = status === 429 ? 60_000 : 10_000
|
|
258
|
+
const ms = Number.isFinite(suggested) ? suggested! : fallback
|
|
259
|
+
return { kind: "cooldown", ms: Math.min(MAX_COOLDOWN_MS, Math.max(MIN_COOLDOWN_MS, Math.ceil(ms))) }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function scopeKey(event: Pick<SessionHttpResponse, "sessionID" | "agent" | "model">, status: number): string {
|
|
263
|
+
return JSON.stringify([event.sessionID, event.agent, event.model.providerID, event.model.id, status])
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export default Plugin.define({
|
|
267
|
+
id: "gemini-key-rotator",
|
|
268
|
+
effect: (ctx) => Effect.gen(function* () {
|
|
269
|
+
const options: Readonly<Record<string, unknown>> = ctx.options ?? {}
|
|
270
|
+
const pool = new KeyPool(configuredKeys(options))
|
|
271
|
+
const log = logger(options)
|
|
272
|
+
const requests = new WeakMap<Request, RequestMeta>()
|
|
273
|
+
const failures = new Map<string, Failure[]>()
|
|
274
|
+
log(`loaded: ${pool.configured.length} configured key(s)`)
|
|
275
|
+
|
|
276
|
+
yield* ctx.session.hook("http.request", (event) => Effect.gen(function* () {
|
|
277
|
+
const url = geminiURL(event.request)
|
|
278
|
+
if (!url) return
|
|
279
|
+
const candidates = pool.candidates(event.request, url)
|
|
280
|
+
if (candidates.length === 0) return // Let OpenCode handle missing credentials normally.
|
|
281
|
+
|
|
282
|
+
// Effect.sleep is interruptible: stopping the session cancels this wait.
|
|
283
|
+
let selected: { key: string; wait: number } | undefined
|
|
284
|
+
for (;;) {
|
|
285
|
+
selected = pool.choice(candidates)
|
|
286
|
+
if (!selected) {
|
|
287
|
+
return yield* Effect.die(new Error("[gemini-rotator] All available Gemini keys are invalid"))
|
|
288
|
+
}
|
|
289
|
+
if (selected.wait <= 0) break
|
|
290
|
+
yield* Effect.sleep(Duration.millis(selected.wait))
|
|
291
|
+
// Re-evaluate after waking: another request may have extended the cooldown.
|
|
292
|
+
}
|
|
293
|
+
sendWithKey(event, url, selected.key)
|
|
294
|
+
requests.set(event.request, { candidates })
|
|
295
|
+
log(`request: key #${candidates.indexOf(selected.key) + 1}/${candidates.length}`)
|
|
296
|
+
}))
|
|
297
|
+
|
|
298
|
+
yield* ctx.session.hook("http.response", (event) => Effect.gen(function* () {
|
|
299
|
+
const url = geminiURL(event.request)
|
|
300
|
+
if (!url || ![400, 403, 429, 503].includes(event.response.status)) return
|
|
301
|
+
const sent = providedCredentials(event.request, url)[0]
|
|
302
|
+
if (!sent) return
|
|
303
|
+
const metadata = requests.get(event.request)
|
|
304
|
+
// Another plugin can replace Request after our request hook; only act on a key we know.
|
|
305
|
+
if (!metadata && !pool.knows(sent)) return
|
|
306
|
+
const candidates = metadata?.candidates ?? [...new Set([...pool.configured, sent])]
|
|
307
|
+
if (!candidates.includes(sent)) return
|
|
308
|
+
|
|
309
|
+
const data = yield* Effect.promise((signal) => readErrorJSON(event.response, signal))
|
|
310
|
+
const result = classify(event.response, data)
|
|
311
|
+
if (!result) return
|
|
312
|
+
const state = pool.state(sent)
|
|
313
|
+
if (result.kind === "invalid") state.invalid = true
|
|
314
|
+
else state.availableAt = Math.max(state.availableAt, Date.now() + result.ms)
|
|
315
|
+
log(`response: HTTP ${event.response.status}; key #${candidates.indexOf(sent) + 1}/${candidates.length}; ` +
|
|
316
|
+
(result.kind === "invalid" ? "invalid" : `cooldown ${result.ms}ms`))
|
|
317
|
+
|
|
318
|
+
// The retry hook has no request ID or kind. Title/generate calls use their
|
|
319
|
+
// own retry path, so they must not overwrite a primary/compaction failure.
|
|
320
|
+
if (event.kind !== "primary" && event.kind !== "compaction") return
|
|
321
|
+
const now = Date.now()
|
|
322
|
+
for (const [key, values] of failures) {
|
|
323
|
+
const fresh = values.filter((failure) => now - failure.at < FAILURE_TTL_MS)
|
|
324
|
+
if (fresh.length === 0) failures.delete(key)
|
|
325
|
+
else failures.set(key, fresh)
|
|
326
|
+
}
|
|
327
|
+
const key = scopeKey(event, event.response.status)
|
|
328
|
+
const list = failures.get(key) ?? []
|
|
329
|
+
list.push({ at: now, candidates })
|
|
330
|
+
failures.delete(key)
|
|
331
|
+
failures.set(key, list.slice(-4))
|
|
332
|
+
while (failures.size > MAX_FAILURES) {
|
|
333
|
+
const first = failures.keys().next().value
|
|
334
|
+
if (first === undefined) break
|
|
335
|
+
failures.delete(first)
|
|
336
|
+
}
|
|
337
|
+
}))
|
|
338
|
+
|
|
339
|
+
yield* ctx.session.hook("retry", (event: SessionRetry) => Effect.sync(() => {
|
|
340
|
+
const status = event.error.status
|
|
341
|
+
if (status === undefined) return
|
|
342
|
+
const key = scopeKey(event, status)
|
|
343
|
+
const list = failures.get(key)
|
|
344
|
+
if (!list?.length) return // Do not alter unrelated provider failures.
|
|
345
|
+
const failure = list.pop()!
|
|
346
|
+
if (list.length === 0) failures.delete(key)
|
|
347
|
+
if (Date.now() - failure.at >= FAILURE_TTL_MS) return
|
|
348
|
+
const next = pool.choice(failure.candidates)
|
|
349
|
+
// All invalid: expose the original provider error rather than retry forever.
|
|
350
|
+
event.decision = next ? { retry: true, delay: next.wait } : { retry: false }
|
|
351
|
+
log(next ? `retry: wait ${Math.ceil(next.wait)}ms` : "retry: all keys invalid")
|
|
352
|
+
}))
|
|
353
|
+
}),
|
|
354
|
+
})
|
package/llms-full.txt
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Complete Documentation: OpenCode Gemini Rotator V2
|
|
2
|
+
|
|
3
|
+
## Project Metadata
|
|
4
|
+
- Project Name: OpenCode Gemini Rotator V2 (`opencode-v2-gemini-rotator`)
|
|
5
|
+
- Repository: https://github.com/sngx762/opencode-v2-gemini-rotator
|
|
6
|
+
- Maintained by: Sabirjanov Abdurahmon
|
|
7
|
+
- License: MIT License
|
|
8
|
+
- Tested Platform: OpenCode 2.2.0
|
|
9
|
+
|
|
10
|
+
## Problem Statement
|
|
11
|
+
When developers run high-throughput AI coding agent tasks using Google Gemini models (`google/gemini-2.5-flash`, `google/gemini-2.5-pro`, `google/gemini-3.7-flash`) in OpenCode V2, they frequently hit HTTP 429 Too Many Requests, HTTP 503 Service Unavailable, or daily quota limits. Single API keys trigger immediate session halts.
|
|
12
|
+
|
|
13
|
+
## Core Capabilities
|
|
14
|
+
1. Automatic Key Rotation: Intercepts requests targeting https://generativelanguage.googleapis.com. Upon encountering 429 or 503 errors, puts the current key on temporary cooldown and automatically injects the next available key from the pool.
|
|
15
|
+
2. Smart Cooldown Parsing: Parses Retry-After, retry-after-ms, or Google RetryInfo payloads. Defaults to 60s cooldown for 429 rate limits and 10s for 503 service overload.
|
|
16
|
+
3. Invalid Key Handling: If an API key returns API_KEY_INVALID or "API key not valid", it is permanently marked invalid for the process session.
|
|
17
|
+
4. Single-File Installation: Zero build steps or npm install required for end users. Discovered automatically at ~/.config/opencode/plugins/gemini-rotator/index.ts.
|
|
18
|
+
5. Key Security: Exclusively targets official Google Gemini endpoints. Vertex AI and third-party custom proxy URLs are deliberately excluded to eliminate credential leakage risks.
|
|
19
|
+
|
|
20
|
+
## Error Handling Table
|
|
21
|
+
| HTTP Status | Category | Cooldown | Action |
|
|
22
|
+
| --- | --- | --- | --- |
|
|
23
|
+
| 429 | Rate Limit / Quota | Extracted from headers/body (Default 60s) | Rotates to next valid key |
|
|
24
|
+
| 503 | Overloaded | Extracted from headers/body (Default 10s) | Rotates to next valid key |
|
|
25
|
+
| 400 / 403 | Quota Limit | 10s default | Rotates to next valid key |
|
|
26
|
+
| 400 / 403 | API_KEY_INVALID | Permanent for process session | Marks key invalid, rotates |
|
|
27
|
+
|
|
28
|
+
## Installation & Configuration
|
|
29
|
+
|
|
30
|
+
### Global Single-File Installation
|
|
31
|
+
```bash
|
|
32
|
+
CONFIG_ROOT="${XDG_CONFIG_HOME:-$HOME/.config}/opencode"
|
|
33
|
+
mkdir -p "$CONFIG_ROOT/plugins/gemini-rotator"
|
|
34
|
+
cp index.ts LICENSE "$CONFIG_ROOT/plugins/gemini-rotator/"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Environment Variables
|
|
38
|
+
```bash
|
|
39
|
+
export GEMINI_API_KEYS='YOUR_KEY_1,YOUR_KEY_2,YOUR_KEY_3'
|
|
40
|
+
export GOOGLE_GENERATIVE_AI_API_KEY='YOUR_KEY_1'
|
|
41
|
+
opencode run --standalone --model google/gemini-2.5-flash "Say hello"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Managed Service Environment
|
|
45
|
+
```bash
|
|
46
|
+
opencode service set env GEMINI_API_KEYS "$GEMINI_API_KEYS"
|
|
47
|
+
opencode service set env GOOGLE_GENERATIVE_AI_API_KEY "$GOOGLE_GENERATIVE_AI_API_KEY"
|
|
48
|
+
opencode service start
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Debugging
|
|
52
|
+
Enable debug output to inspect key indices and cooldowns without revealing API secrets:
|
|
53
|
+
```bash
|
|
54
|
+
export OPENCODE_GEMINI_DEBUG=1
|
|
55
|
+
opencode run --standalone --model google/gemini-2.5-flash "Say hello"
|
|
56
|
+
tail -f "${TMPDIR:-/tmp}/gemini-rotator-debug.log"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Non-Goals & Limitations
|
|
60
|
+
- Not compatible with OpenCode V1 (requires OpenCode V2 plugin model).
|
|
61
|
+
- Does not bypass GCP project-level rate limits if all keys belong to the same Google Cloud project. Keys should be created across distinct GCP projects.
|
|
62
|
+
- Does not render TUI status bars or popups in OpenCode terminal UI.
|
|
63
|
+
- Maximum retry budget is constrained by OpenCode V2's native retry engine (up to 10 retries).
|
package/llms.txt
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# OpenCode Gemini Rotator V2
|
|
2
|
+
|
|
3
|
+
> Native OpenCode V2 server plugin for automatic Google Gemini API key rotation on 429 Rate Limits, 503 errors, and quota exhaustion.
|
|
4
|
+
|
|
5
|
+
OpenCode Gemini Rotator V2 (`opencode-v2-gemini-rotator`) handles HTTP 429 (Rate Limit / Resource Exhausted), HTTP 503 (Service Overloaded), quota limits, and invalid keys during AI coding sessions in OpenCode 2.2.0+. It provides seamless multi-key fallback across a pool of up to 256 Google Gemini API keys without monkey-patching fetch or adding external proxy processes.
|
|
6
|
+
|
|
7
|
+
## Main Documentation
|
|
8
|
+
|
|
9
|
+
- [Project Homepage](https://sngx762.github.io/opencode-v2-gemini-rotator/index.html): Overview, problem description, and single-file setup guide.
|
|
10
|
+
- [How Key Rotation Works](https://sngx762.github.io/opencode-v2-gemini-rotator/solution.html): Architecture, Effect.ts session hooks, and error classification details.
|
|
11
|
+
- [Who Is It For](https://sngx762.github.io/opencode-v2-gemini-rotator/who-is-it-for.html): Target developer audience and ideal AI coding workflows.
|
|
12
|
+
- [Who Is It NOT For](https://sngx762.github.io/opencode-v2-gemini-rotator/who-is-it-not-for.html): Transparent list of limitations, non-goals, and unsupported platforms.
|
|
13
|
+
- [Frequently Asked Questions](https://sngx762.github.io/opencode-v2-gemini-rotator/faq.html): FAQ covering 429 rate limit fixes, key pool limits, and security details.
|
|
14
|
+
- [Alternatives Comparison](https://sngx762.github.io/opencode-v2-gemini-rotator/alternatives.html): Feature matrix comparing Rotator V2 against LiteLLM, OpenRouter, V1 Rotator, and native retries.
|
|
15
|
+
- [V1 vs V2 Comparison](https://sngx762.github.io/opencode-v2-gemini-rotator/vs/opencode-gemini-rotator-v1.html): Technical differences and migration steps from legacy V1 plugin.
|
|
16
|
+
- [Real-World Use Cases](https://sngx762.github.io/opencode-v2-gemini-rotator/use-cases.html): Multi-file refactoring and background session use cases.
|
|
17
|
+
|
|
18
|
+
## Full Plain Text Context
|
|
19
|
+
|
|
20
|
+
- [Full Plain Text Documentation](https://sngx762.github.io/opencode-v2-gemini-rotator/llms-full.txt): Complete markdown context for LLMs and AI crawlers.
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-v2-gemini-rotator",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Native OpenCode V2 plugin for automatic Google Gemini API key rotation on 429 Rate Limits and 503 errors.",
|
|
5
|
+
"author": "Sabirjanov Abdurahmon",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./index.ts"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"index.ts",
|
|
13
|
+
"README.md",
|
|
14
|
+
"README.ru.md",
|
|
15
|
+
"LICENSE",
|
|
16
|
+
"llms.txt",
|
|
17
|
+
"llms-full.txt"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"test": "tsx --test test/*.test.ts",
|
|
22
|
+
"test:e2e": "node test/e2e.mjs"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@opencode/plugin": "2.2.0",
|
|
26
|
+
"effect": "4.0.1"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^22.0.0",
|
|
30
|
+
"tsx": "^4.20.0",
|
|
31
|
+
"typescript": "^5.9.0"
|
|
32
|
+
}
|
|
33
|
+
}
|