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 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
- # Temporary Holding Version
1
+ # OpenCode Gemini Rotator V2
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }