@vymalo/opencode-ratelimit 0.5.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 +21 -0
- package/README.md +107 -0
- package/dist/config.d.ts +13 -0
- package/dist/config.js +62 -0
- package/dist/config.js.map +1 -0
- package/dist/headers.d.ts +18 -0
- package/dist/headers.js +82 -0
- package/dist/headers.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/lib.d.ts +6 -0
- package/dist/lib.js +6 -0
- package/dist/lib.js.map +1 -0
- package/dist/logging.d.ts +15 -0
- package/dist/logging.js +69 -0
- package/dist/logging.js.map +1 -0
- package/dist/opencode.d.ts +11 -0
- package/dist/opencode.js +55 -0
- package/dist/opencode.js.map +1 -0
- package/dist/plugin.d.ts +64 -0
- package/dist/plugin.js +246 -0
- package/dist/plugin.js.map +1 -0
- package/dist/types.d.ts +38 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vymalo contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# @vymalo/opencode-ratelimit
|
|
2
|
+
|
|
3
|
+
OpenCode plugin that makes OpenAI-compatible providers **rate-limit aware**. It reads the IETF draft-03 rate-limit response headers emitted by [Envoy Gateway](https://gateway.envoyproxy.io/docs/tasks/traffic/global-rate-limit/)'s global rate limiting (`x-ratelimit-limit` / `-remaining` / `-reset`), proactively pauses new requests when the quota is exhausted, and backs off + retries on HTTP `429` — so your OpenCode client cooperates with the gateway instead of hammering it.
|
|
4
|
+
|
|
5
|
+
Auth-agnostic by design: it runs as an OpenCode `config` hook and only wraps the provider's `fetch`. It never reads or sets `Authorization`, so it composes with `@vymalo/opencode-oauth2`, static API keys, or no auth at all.
|
|
6
|
+
|
|
7
|
+
## Why use this
|
|
8
|
+
|
|
9
|
+
OpenCode has no built-in client-side rate-limit handling. If your inference sits behind a gateway that advertises a quota on every response, a burst of requests (streaming + title generation + parallel tool calls) blows through the limit and earns a wall of `429`s. This plugin observes the advertised quota and the reset window and waits the right amount of time — automatically, per provider.
|
|
10
|
+
|
|
11
|
+
## How it works
|
|
12
|
+
|
|
13
|
+
OpenCode's plugin API has **no post-response hook**, so the only way to observe response status and headers is to inject a custom `fetch` onto the provider's `options.fetch` during the `config` hook (OpenCode forwards it to the AI SDK provider). This plugin wraps that fetch:
|
|
14
|
+
|
|
15
|
+
1. **Pre-request gate** — if the last response said `remaining: 0`, hold new requests until `x-ratelimit-reset` elapses.
|
|
16
|
+
2. **Send** the request through the underlying fetch (or any fetch a prior plugin already installed).
|
|
17
|
+
3. **Read** the rate-limit headers from the response and update per-provider state.
|
|
18
|
+
4. **On `429`** — wait the reset window (`x-ratelimit-reset`, or `Retry-After` as a fallback) and retry, up to `maxRetries`.
|
|
19
|
+
|
|
20
|
+
The `Response` is returned untouched (no `.clone()`), so the streaming body reaches OpenCode intact.
|
|
21
|
+
|
|
22
|
+
> **Where the headers live.** Envoy attaches its rate-limit `BackendTrafficPolicy` to a specific route — in practice `/v1/chat/completions`, **not** `/v1/models`. So the plugin sees the quota on the inference calls that matter, and a `curl` of `/v1/models` may show no `x-ratelimit-*` at all. The `limit` header is also commonly *multi-policy*, e.g. `x-ratelimit-limit: 200, 200;w=60, 200000;w=60, 50000000;w=2592000` — the parser takes the first token as `limit`, while `remaining`/`reset` already track whichever bucket is closest to its cap (which is all the throttle logic needs).
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
npm install @vymalo/opencode-ratelimit
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
A provider opts in via `options.meta.rateLimit`. All fields are optional:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"plugin": ["@vymalo/opencode-ratelimit"],
|
|
37
|
+
"provider": {
|
|
38
|
+
"my-provider": {
|
|
39
|
+
"npm": "@ai-sdk/openai-compatible",
|
|
40
|
+
"options": {
|
|
41
|
+
"baseURL": "https://api.example.com/v1",
|
|
42
|
+
"meta": {
|
|
43
|
+
"rateLimit": {
|
|
44
|
+
"enabled": true,
|
|
45
|
+
"maxWaitMs": 0,
|
|
46
|
+
"maxRetries": 5,
|
|
47
|
+
"headerPrefix": "x-ratelimit"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
| Field | Default | Meaning |
|
|
57
|
+
| --- | --- | --- |
|
|
58
|
+
| `enabled` | `true` | Set `false` to opt a provider out while keeping the block. Omitting the whole `rateLimit` block also opts out. |
|
|
59
|
+
| `maxWaitMs` | `0` | Upper bound on any single wait, in ms. **`0` = unlimited** (wait the full reset window). |
|
|
60
|
+
| `maxRetries` | `5` | How many times a `429` is retried before the response is handed back as-is. |
|
|
61
|
+
| `headerPrefix` | `x-ratelimit` | Lowercased prefix of the draft-03 triple. Remap for gateways that use e.g. `ratelimit-*`. |
|
|
62
|
+
|
|
63
|
+
If you also use `@vymalo/opencode-oauth2`, list it **before** this plugin in `plugin` (config hooks run in registration order). It is only a soft recommendation — this plugin's fetch wrapping is auth-independent.
|
|
64
|
+
|
|
65
|
+
## Caveats
|
|
66
|
+
|
|
67
|
+
### Long waits can be cut short by OpenCode's own timeouts
|
|
68
|
+
|
|
69
|
+
OpenCode wraps the injected `fetch` with its own `headerTimeout` / `chunkTimeout` logic, and our pre-request gate and `429` backoff wait **inside** that fetch. With the default `maxWaitMs: 0`, a 55-second reset window means a 55-second wait — which OpenCode may abort if it exceeds the header timeout. When that happens the wait is cancelled cleanly (a `ratelimit_wait_aborted` event is logged and the abort is propagated as a normal cancellation).
|
|
70
|
+
|
|
71
|
+
If you expect long waits, raise the provider's `headerTimeout` (and `chunkTimeout`) above your largest reset window:
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
"options": { "headerTimeout": 90000, "chunkTimeout": 90000 }
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### State is in-memory only
|
|
78
|
+
|
|
79
|
+
Unlike `@vymalo/opencode-oauth2` and `-models-info`, this plugin keeps **no cache on disk** — rate-limit windows are measured in seconds, so per-process state is all that matters and surviving a restart would be pointless (and stale).
|
|
80
|
+
|
|
81
|
+
### Concurrency
|
|
82
|
+
|
|
83
|
+
During a known cooldown window all in-flight callers share a **single** timer (a 10-way burst produces one wait, not ten) and resume together; each still honors its own request `signal`. Outside a cooldown, requests flow concurrently and each response's headers correct the shared state — a burst that races past `remaining: 0` is mopped up by the `429` backoff path.
|
|
84
|
+
|
|
85
|
+
## Logged events
|
|
86
|
+
|
|
87
|
+
Structured events flow through OpenCode's log stream (service `opencode-ratelimit-plugin`) with a JSON-console fallback:
|
|
88
|
+
|
|
89
|
+
| Event | Level | When |
|
|
90
|
+
| --- | --- | --- |
|
|
91
|
+
| `ratelimit_plugin_initialized` | info | once per config load (`providerCount`) |
|
|
92
|
+
| `ratelimit_provider_enabled` | info | a provider opted in |
|
|
93
|
+
| `ratelimit_provider_skipped` | debug | a provider did not opt in |
|
|
94
|
+
| `ratelimit_quota` | debug | every response (`remaining`, `limit`, `resetSeconds`) |
|
|
95
|
+
| `ratelimit_throttle_wait` | info | pre-request gate engaged |
|
|
96
|
+
| `ratelimit_429_backoff` | warn | a `429` was retried |
|
|
97
|
+
| `ratelimit_wait_aborted` | warn | a wait was cancelled by the request signal |
|
|
98
|
+
| `ratelimit_giveup` | error | `maxRetries` exhausted, `429` returned as-is |
|
|
99
|
+
| `ratelimit_header_parse_failed` | debug | a response's headers could not be parsed |
|
|
100
|
+
|
|
101
|
+
## Library API
|
|
102
|
+
|
|
103
|
+
The `./lib` subpath exports the testable internals for embedders: `parseRateLimit`, `parseRateLimitOptions`, `makeRateLimitFetch`, `installRateLimiter`, `createProviderState`, and the `createOpencodeRatelimitPlugin` factory.
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { RateLimitOptions } from "./types.js";
|
|
2
|
+
export declare const DEFAULT_MAX_WAIT_MS = 0;
|
|
3
|
+
export declare const DEFAULT_MAX_RETRIES = 5;
|
|
4
|
+
export declare const DEFAULT_HEADER_PREFIX = "x-ratelimit";
|
|
5
|
+
/**
|
|
6
|
+
* Parse a provider's `options.meta.rateLimit` block into resolved
|
|
7
|
+
* {@link RateLimitOptions}. Returns `null` when the provider has NOT opted in
|
|
8
|
+
* (no `meta.rateLimit`) or has explicitly disabled it (`enabled: false`) — in
|
|
9
|
+
* both cases the provider's fetch is left untouched.
|
|
10
|
+
*
|
|
11
|
+
* Mirrors `@vymalo/opencode-models-info`'s `parseMetaOptions` opt-in idiom.
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseRateLimitOptions(providerOptions: Record<string, unknown> | undefined): RateLimitOptions | null;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export const DEFAULT_MAX_WAIT_MS = 0; // 0 = unlimited (wait the full reset window)
|
|
2
|
+
export const DEFAULT_MAX_RETRIES = 5;
|
|
3
|
+
export const DEFAULT_HEADER_PREFIX = "x-ratelimit";
|
|
4
|
+
const META_KEY = "meta";
|
|
5
|
+
const RATE_LIMIT_KEY = "rateLimit";
|
|
6
|
+
function asRecord(value) {
|
|
7
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
function asString(value) {
|
|
13
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
14
|
+
}
|
|
15
|
+
function asBoolean(value, fallback) {
|
|
16
|
+
return typeof value === "boolean" ? value : fallback;
|
|
17
|
+
}
|
|
18
|
+
function asPositiveInt(value, fallback) {
|
|
19
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
20
|
+
return Math.floor(value);
|
|
21
|
+
}
|
|
22
|
+
return fallback;
|
|
23
|
+
}
|
|
24
|
+
/** Like {@link asPositiveInt} but accepts `0` — used for `maxWaitMs` where 0 means unlimited. */
|
|
25
|
+
function asNonNegativeInt(value, fallback) {
|
|
26
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
|
|
27
|
+
return Math.floor(value);
|
|
28
|
+
}
|
|
29
|
+
return fallback;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Parse a provider's `options.meta.rateLimit` block into resolved
|
|
33
|
+
* {@link RateLimitOptions}. Returns `null` when the provider has NOT opted in
|
|
34
|
+
* (no `meta.rateLimit`) or has explicitly disabled it (`enabled: false`) — in
|
|
35
|
+
* both cases the provider's fetch is left untouched.
|
|
36
|
+
*
|
|
37
|
+
* Mirrors `@vymalo/opencode-models-info`'s `parseMetaOptions` opt-in idiom.
|
|
38
|
+
*/
|
|
39
|
+
export function parseRateLimitOptions(providerOptions) {
|
|
40
|
+
if (!providerOptions) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const meta = asRecord(providerOptions[META_KEY]);
|
|
44
|
+
if (!meta) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const rateLimit = asRecord(meta[RATE_LIMIT_KEY]);
|
|
48
|
+
if (!rateLimit) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const enabled = asBoolean(rateLimit.enabled, true);
|
|
52
|
+
if (!enabled) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
enabled: true,
|
|
57
|
+
maxWaitMs: asNonNegativeInt(rateLimit.maxWaitMs, DEFAULT_MAX_WAIT_MS),
|
|
58
|
+
maxRetries: asPositiveInt(rateLimit.maxRetries, DEFAULT_MAX_RETRIES),
|
|
59
|
+
headerPrefix: (asString(rateLimit.headerPrefix) ?? DEFAULT_HEADER_PREFIX).toLowerCase()
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAEA,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC,CAAC,6CAA6C;AACnF,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC;AACrC,MAAM,CAAC,MAAM,qBAAqB,GAAG,aAAa,CAAC;AAEnD,MAAM,QAAQ,GAAG,MAAM,CAAC;AACxB,MAAM,cAAc,GAAG,WAAW,CAAC;AAEnC,SAAS,QAAQ,CAAC,KAAc;IAC9B,IAAI,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QAChE,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,OAAO,KAAgC,CAAC;AAC1C,CAAC;AAED,SAAS,QAAQ,CAAC,KAAc;IAC9B,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;AACzF,CAAC;AAED,SAAS,SAAS,CAAC,KAAc,EAAE,QAAiB;IAClD,OAAO,OAAO,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC;AACvD,CAAC;AAED,SAAS,aAAa,CAAC,KAAc,EAAE,QAAgB;IACrD,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;QACrE,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IAC3B,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,iGAAiG;AACjG,SAAS,gBAAgB,CAAC,KAAc,EAAE,QAAgB;IACxD,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC,EAAE,CAAC;QACtE,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IAC3B,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,qBAAqB,CACnC,eAAoD;IAEpD,IAAI,CAAC,eAAe,EAAE,CAAC;QACrB,OAAO,IAAI,CAAC;IACd,CAAC;IAED,MAAM,IAAI,GAAG,QAAQ,CAAC,eAAe,CAAC,QAAQ,CAAC,CAAC,CAAC;IACjD,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,OAAO,IAAI,CAAC;IACd,CAAC;IAED,MAAM,SAAS,GAAG,QAAQ,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC,CAAC;IACjD,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,OAAO,IAAI,CAAC;IACd,CAAC;IAED,MAAM,OAAO,GAAG,SAAS,CAAC,SAAS,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;IACnD,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,OAAO,IAAI,CAAC;IACd,CAAC;IAED,OAAO;QACL,OAAO,EAAE,IAAI;QACb,SAAS,EAAE,gBAAgB,CAAC,SAAS,CAAC,SAAS,EAAE,mBAAmB,CAAC;QACrE,UAAU,EAAE,aAAa,CAAC,SAAS,CAAC,UAAU,EAAE,mBAAmB,CAAC;QACpE,YAAY,EAAE,CAAC,QAAQ,CAAC,SAAS,CAAC,YAAY,CAAC,IAAI,qBAAqB,CAAC,CAAC,WAAW,EAAE;KACxF,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { RateLimitSnapshot } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Parse the IETF draft-03 rate-limit triple (as emitted by Envoy Gateway's
|
|
4
|
+
* global rate limit) plus an optional `Retry-After` fallback into a
|
|
5
|
+
* {@link RateLimitSnapshot}.
|
|
6
|
+
*
|
|
7
|
+
* Envoy emits these on BOTH 200 and 429 responses, e.g.:
|
|
8
|
+
* x-ratelimit-limit: 3, 3;w=60
|
|
9
|
+
* x-ratelimit-remaining: 2
|
|
10
|
+
* x-ratelimit-reset: 48 (seconds until the bucket resets)
|
|
11
|
+
*
|
|
12
|
+
* `Retry-After` is NOT emitted by Envoy Gateway today, so it is only consulted
|
|
13
|
+
* as a fallback when present (all-digits → seconds; otherwise an HTTP-date).
|
|
14
|
+
*
|
|
15
|
+
* Header names are case-insensitive (the Fetch `Headers` object lowercases
|
|
16
|
+
* them); `prefix` should already be lowercased by the caller.
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseRateLimit(headers: Headers, prefix: string, nowMs: number): RateLimitSnapshot;
|
package/dist/headers.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse the IETF draft-03 rate-limit triple (as emitted by Envoy Gateway's
|
|
3
|
+
* global rate limit) plus an optional `Retry-After` fallback into a
|
|
4
|
+
* {@link RateLimitSnapshot}.
|
|
5
|
+
*
|
|
6
|
+
* Envoy emits these on BOTH 200 and 429 responses, e.g.:
|
|
7
|
+
* x-ratelimit-limit: 3, 3;w=60
|
|
8
|
+
* x-ratelimit-remaining: 2
|
|
9
|
+
* x-ratelimit-reset: 48 (seconds until the bucket resets)
|
|
10
|
+
*
|
|
11
|
+
* `Retry-After` is NOT emitted by Envoy Gateway today, so it is only consulted
|
|
12
|
+
* as a fallback when present (all-digits → seconds; otherwise an HTTP-date).
|
|
13
|
+
*
|
|
14
|
+
* Header names are case-insensitive (the Fetch `Headers` object lowercases
|
|
15
|
+
* them); `prefix` should already be lowercased by the caller.
|
|
16
|
+
*/
|
|
17
|
+
export function parseRateLimit(headers, prefix, nowMs) {
|
|
18
|
+
const snapshot = {};
|
|
19
|
+
const limit = parseLimit(headers.get(`${prefix}-limit`));
|
|
20
|
+
if (limit !== undefined) {
|
|
21
|
+
snapshot.limit = limit;
|
|
22
|
+
}
|
|
23
|
+
const remaining = parseInteger(headers.get(`${prefix}-remaining`));
|
|
24
|
+
if (remaining !== undefined) {
|
|
25
|
+
snapshot.remaining = remaining;
|
|
26
|
+
}
|
|
27
|
+
const resetSeconds = parseInteger(headers.get(`${prefix}-reset`));
|
|
28
|
+
if (resetSeconds !== undefined) {
|
|
29
|
+
snapshot.resetSeconds = resetSeconds;
|
|
30
|
+
snapshot.resetAtMs = nowMs + resetSeconds * 1000;
|
|
31
|
+
}
|
|
32
|
+
const retryAfterMs = parseRetryAfter(headers.get("retry-after"), nowMs);
|
|
33
|
+
if (retryAfterMs !== undefined) {
|
|
34
|
+
snapshot.retryAfterMs = retryAfterMs;
|
|
35
|
+
}
|
|
36
|
+
return snapshot;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* `x-ratelimit-limit` carries the effective limit followed by optional
|
|
40
|
+
* window descriptors, e.g. `"3, 3;w=60"`. We take the first comma-separated
|
|
41
|
+
* token and strip any `;w=...` quota-policy suffix → `3`. A plain `"3"` works too.
|
|
42
|
+
*/
|
|
43
|
+
function parseLimit(raw) {
|
|
44
|
+
if (raw === null) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
const firstToken = raw.split(",", 1)[0]?.split(";", 1)[0];
|
|
48
|
+
return parseInteger(firstToken);
|
|
49
|
+
}
|
|
50
|
+
function parseInteger(raw) {
|
|
51
|
+
if (raw === null || raw === undefined) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
const trimmed = raw.trim();
|
|
55
|
+
if (trimmed.length === 0 || !/^-?\d+$/.test(trimmed)) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
const value = Number.parseInt(trimmed, 10);
|
|
59
|
+
return Number.isFinite(value) ? value : undefined;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* `Retry-After` is either a non-negative integer (delay-seconds) or an
|
|
63
|
+
* HTTP-date. Returns the wait in ms, never negative.
|
|
64
|
+
*/
|
|
65
|
+
function parseRetryAfter(raw, nowMs) {
|
|
66
|
+
if (raw === null) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
const trimmed = raw.trim();
|
|
70
|
+
if (trimmed.length === 0) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
if (/^\d+$/.test(trimmed)) {
|
|
74
|
+
return Number.parseInt(trimmed, 10) * 1000;
|
|
75
|
+
}
|
|
76
|
+
const dateMs = Date.parse(trimmed);
|
|
77
|
+
if (Number.isNaN(dateMs)) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
return Math.max(0, dateMs - nowMs);
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=headers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"headers.js","sourceRoot":"","sources":["../src/headers.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./opencode.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// OpenCode plugin entry. The host iterates every named export of this module
|
|
2
|
+
// and rejects any export that isn't a Plugin function (or { server: Plugin }).
|
|
3
|
+
// Library API is exposed via the "./lib" subpath in package.json.
|
|
4
|
+
export { default } from "./opencode.js";
|
|
5
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,+EAA+E;AAC/E,kEAAkE;AAClE,OAAO,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC"}
|
package/dist/lib.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createOpencodeRatelimitPlugin, OpencodeRatelimitPlugin, type OpenCodePluginFactoryOptions } from "./opencode.js";
|
|
2
|
+
export { DEFAULT_HEADER_PREFIX, DEFAULT_MAX_RETRIES, DEFAULT_MAX_WAIT_MS, parseRateLimitOptions } from "./config.js";
|
|
3
|
+
export { parseRateLimit } from "./headers.js";
|
|
4
|
+
export { createJsonConsoleLogger, DEFAULT_LOG_LEVEL, fromOpenCodeLogLevel, type LogFields, type Logger, type LogLevel } from "./logging.js";
|
|
5
|
+
export { createProviderState, DEFAULT_BACKOFF_MS, type InstallConfigInput, installRateLimiter, makeRateLimitFetch, type ProviderConfigLike, type ProviderRateState, type RateLimitDeps } from "./plugin.js";
|
|
6
|
+
export type { RateLimitOptions, RateLimitSnapshot } from "./types.js";
|
package/dist/lib.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createOpencodeRatelimitPlugin, OpencodeRatelimitPlugin } from "./opencode.js";
|
|
2
|
+
export { DEFAULT_HEADER_PREFIX, DEFAULT_MAX_RETRIES, DEFAULT_MAX_WAIT_MS, parseRateLimitOptions } from "./config.js";
|
|
3
|
+
export { parseRateLimit } from "./headers.js";
|
|
4
|
+
export { createJsonConsoleLogger, DEFAULT_LOG_LEVEL, fromOpenCodeLogLevel } from "./logging.js";
|
|
5
|
+
export { createProviderState, DEFAULT_BACKOFF_MS, installRateLimiter, makeRateLimitFetch } from "./plugin.js";
|
|
6
|
+
//# sourceMappingURL=lib.js.map
|
package/dist/lib.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lib.js","sourceRoot":"","sources":["../src/lib.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,6BAA6B,EAC7B,uBAAuB,EAExB,MAAM,eAAe,CAAC;AAEvB,OAAO,EACL,qBAAqB,EACrB,mBAAmB,EACnB,mBAAmB,EACnB,qBAAqB,EACtB,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAE9C,OAAO,EACL,uBAAuB,EACvB,iBAAiB,EACjB,oBAAoB,EAIrB,MAAM,cAAc,CAAC;AAEtB,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAElB,kBAAkB,EAClB,kBAAkB,EAInB,MAAM,aAAa,CAAC"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { LogLevel } from "./types.js";
|
|
2
|
+
export type { LogLevel } from "./types.js";
|
|
3
|
+
export declare const LOG_LEVEL_PRIORITY: Record<LogLevel, number>;
|
|
4
|
+
export declare const DEFAULT_LOG_LEVEL: LogLevel;
|
|
5
|
+
export interface LogFields {
|
|
6
|
+
[key: string]: unknown;
|
|
7
|
+
}
|
|
8
|
+
export interface Logger {
|
|
9
|
+
debug(event: string, fields?: LogFields): void;
|
|
10
|
+
info(event: string, fields?: LogFields): void;
|
|
11
|
+
warn(event: string, fields?: LogFields): void;
|
|
12
|
+
error(event: string, fields?: LogFields): void;
|
|
13
|
+
}
|
|
14
|
+
export declare function createJsonConsoleLogger(minLevel?: LogLevel): Logger;
|
|
15
|
+
export declare function fromOpenCodeLogLevel(value: unknown): LogLevel | undefined;
|
package/dist/logging.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export const LOG_LEVEL_PRIORITY = {
|
|
2
|
+
debug: 10,
|
|
3
|
+
info: 20,
|
|
4
|
+
warn: 30,
|
|
5
|
+
error: 40
|
|
6
|
+
};
|
|
7
|
+
export const DEFAULT_LOG_LEVEL = "info";
|
|
8
|
+
function redactFields(fields) {
|
|
9
|
+
if (!fields) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
const redacted = {};
|
|
13
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
14
|
+
if (/token|secret|password|authorization/i.test(key)) {
|
|
15
|
+
redacted[key] = "[redacted]";
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
redacted[key] = value;
|
|
19
|
+
}
|
|
20
|
+
return redacted;
|
|
21
|
+
}
|
|
22
|
+
export function createJsonConsoleLogger(minLevel = DEFAULT_LOG_LEVEL) {
|
|
23
|
+
const minPriority = LOG_LEVEL_PRIORITY[minLevel];
|
|
24
|
+
const write = (level, event, fields) => {
|
|
25
|
+
if (LOG_LEVEL_PRIORITY[level] < minPriority) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const payload = {
|
|
29
|
+
ts: new Date().toISOString(),
|
|
30
|
+
level,
|
|
31
|
+
event,
|
|
32
|
+
...(redactFields(fields) ?? {})
|
|
33
|
+
};
|
|
34
|
+
const line = JSON.stringify(payload);
|
|
35
|
+
if (level === "error") {
|
|
36
|
+
console.error(line);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (level === "warn") {
|
|
40
|
+
console.warn(line);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
console.log(line);
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
debug: (event, fields) => write("debug", event, fields),
|
|
47
|
+
info: (event, fields) => write("info", event, fields),
|
|
48
|
+
warn: (event, fields) => write("warn", event, fields),
|
|
49
|
+
error: (event, fields) => write("error", event, fields)
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export function fromOpenCodeLogLevel(value) {
|
|
53
|
+
if (typeof value !== "string") {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
switch (value.toUpperCase()) {
|
|
57
|
+
case "DEBUG":
|
|
58
|
+
return "debug";
|
|
59
|
+
case "INFO":
|
|
60
|
+
return "info";
|
|
61
|
+
case "WARN":
|
|
62
|
+
return "warn";
|
|
63
|
+
case "ERROR":
|
|
64
|
+
return "error";
|
|
65
|
+
default:
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=logging.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"logging.js","sourceRoot":"","sources":["../src/logging.ts"],"names":[],"mappings":"AAIA,MAAM,CAAC,MAAM,kBAAkB,GAA6B;IAC1D,KAAK,EAAE,EAAE;IACT,IAAI,EAAE,EAAE;IACR,IAAI,EAAE,EAAE;IACR,KAAK,EAAE,EAAE;CACV,CAAC;AAEF,MAAM,CAAC,MAAM,iBAAiB,GAAa,MAAM,CAAC;AAalD,SAAS,YAAY,CAAC,MAAkB;IACtC,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,MAAM,QAAQ,GAAc,EAAE,CAAC;IAC/B,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;QAClD,IAAI,sCAAsC,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;YACrD,QAAQ,CAAC,GAAG,CAAC,GAAG,YAAY,CAAC;YAC7B,SAAS;QACX,CAAC;QACD,QAAQ,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;IACxB,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,MAAM,UAAU,uBAAuB,CAAC,WAAqB,iBAAiB;IAC5E,MAAM,WAAW,GAAG,kBAAkB,CAAC,QAAQ,CAAC,CAAC;IAEjD,MAAM,KAAK,GAAG,CAAC,KAAe,EAAE,KAAa,EAAE,MAAkB,EAAQ,EAAE;QACzE,IAAI,kBAAkB,CAAC,KAAK,CAAC,GAAG,WAAW,EAAE,CAAC;YAC5C,OAAO;QACT,CAAC;QACD,MAAM,OAAO,GAAG;YACd,EAAE,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;YAC5B,KAAK;YACL,KAAK;YACL,GAAG,CAAC,YAAY,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;SAChC,CAAC;QACF,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC;QACrC,IAAI,KAAK,KAAK,OAAO,EAAE,CAAC;YACtB,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;YACpB,OAAO;QACT,CAAC;QACD,IAAI,KAAK,KAAK,MAAM,EAAE,CAAC;YACrB,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACnB,OAAO;QACT,CAAC;QACD,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IACpB,CAAC,CAAC;IAEF,OAAO;QACL,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,CAAC,KAAK,CAAC,OAAO,EAAE,KAAK,EAAE,MAAM,CAAC;QACvD,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC;QACrD,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC;QACrD,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,CAAC,KAAK,CAAC,OAAO,EAAE,KAAK,EAAE,MAAM,CAAC;KACxD,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,oBAAoB,CAAC,KAAc;IACjD,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC9B,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,QAAQ,KAAK,CAAC,WAAW,EAAE,EAAE,CAAC;QAC5B,KAAK,OAAO;YACV,OAAO,OAAO,CAAC;QACjB,KAAK,MAAM;YACT,OAAO,MAAM,CAAC;QAChB,KAAK,MAAM;YACT,OAAO,MAAM,CAAC;QAChB,KAAK,OAAO;YACV,OAAO,OAAO,CAAC;QACjB;YACE,OAAO,SAAS,CAAC;IACrB,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
import { type Logger } from "./logging.js";
|
|
3
|
+
export interface OpenCodePluginFactoryOptions {
|
|
4
|
+
logger?: Logger;
|
|
5
|
+
fetchImpl?: typeof fetch;
|
|
6
|
+
now?: () => number;
|
|
7
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
export declare function createOpencodeRatelimitPlugin(factoryOptions?: OpenCodePluginFactoryOptions): Plugin;
|
|
10
|
+
export declare const OpencodeRatelimitPlugin: Plugin;
|
|
11
|
+
export default OpencodeRatelimitPlugin;
|
package/dist/opencode.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createJsonConsoleLogger, DEFAULT_LOG_LEVEL, fromOpenCodeLogLevel, LOG_LEVEL_PRIORITY } from "./logging.js";
|
|
2
|
+
import { installRateLimiter } from "./plugin.js";
|
|
3
|
+
const PLUGIN_SERVICE_NAME = "opencode-ratelimit-plugin";
|
|
4
|
+
/**
|
|
5
|
+
* Pipe plugin logs through OpenCode's `client.app.log` so they show up in the
|
|
6
|
+
* host's structured log stream, with the JSON console as a reliable fallback.
|
|
7
|
+
* Mirrors the pattern used by `@vymalo/opencode-oauth2` and `-models-info`.
|
|
8
|
+
*/
|
|
9
|
+
function createOpenCodeLogger(client, getMinLevel) {
|
|
10
|
+
const fallback = createJsonConsoleLogger("debug");
|
|
11
|
+
const write = (level, event, fields) => {
|
|
12
|
+
if (LOG_LEVEL_PRIORITY[level] < LOG_LEVEL_PRIORITY[getMinLevel()]) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
fallback[level](event, fields);
|
|
16
|
+
void client.app
|
|
17
|
+
.log({
|
|
18
|
+
body: {
|
|
19
|
+
service: PLUGIN_SERVICE_NAME,
|
|
20
|
+
level,
|
|
21
|
+
message: event,
|
|
22
|
+
extra: fields
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
.catch(() => {
|
|
26
|
+
/* best-effort */
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
debug: (event, fields) => write("debug", event, fields),
|
|
31
|
+
info: (event, fields) => write("info", event, fields),
|
|
32
|
+
warn: (event, fields) => write("warn", event, fields),
|
|
33
|
+
error: (event, fields) => write("error", event, fields)
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function createOpencodeRatelimitPlugin(factoryOptions = {}) {
|
|
37
|
+
return async ({ client }) => {
|
|
38
|
+
let currentLogLevel = DEFAULT_LOG_LEVEL;
|
|
39
|
+
const logger = factoryOptions.logger ?? createOpenCodeLogger(client, () => currentLogLevel);
|
|
40
|
+
return {
|
|
41
|
+
config: async (config) => {
|
|
42
|
+
currentLogLevel = fromOpenCodeLogLevel(config.logLevel) ?? DEFAULT_LOG_LEVEL;
|
|
43
|
+
installRateLimiter(config, {
|
|
44
|
+
logger,
|
|
45
|
+
fetchImpl: factoryOptions.fetchImpl,
|
|
46
|
+
now: factoryOptions.now,
|
|
47
|
+
sleep: factoryOptions.sleep
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export const OpencodeRatelimitPlugin = createOpencodeRatelimitPlugin();
|
|
54
|
+
export default OpencodeRatelimitPlugin;
|
|
55
|
+
//# sourceMappingURL=opencode.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"opencode.js","sourceRoot":"","sources":["../src/opencode.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,uBAAuB,EACvB,iBAAiB,EACjB,oBAAoB,EAEpB,kBAAkB,EAGnB,MAAM,cAAc,CAAC;AACtB,OAAO,EAA2B,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAE1E,MAAM,mBAAmB,GAAG,2BAA2B,CAAC;AAWxD;;;;GAIG;AACH,SAAS,oBAAoB,CAAC,MAA6B,EAAE,WAA2B;IACtF,MAAM,QAAQ,GAAG,uBAAuB,CAAC,OAAO,CAAC,CAAC;IAElD,MAAM,KAAK,GAAG,CAAC,KAAe,EAAE,KAAa,EAAE,MAAkB,EAAE,EAAE;QACnE,IAAI,kBAAkB,CAAC,KAAK,CAAC,GAAG,kBAAkB,CAAC,WAAW,EAAE,CAAC,EAAE,CAAC;YAClE,OAAO;QACT,CAAC;QACD,QAAQ,CAAC,KAAK,CAAC,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;QAC/B,KAAK,MAAM,CAAC,GAAG;aACZ,GAAG,CAAC;YACH,IAAI,EAAE;gBACJ,OAAO,EAAE,mBAAmB;gBAC5B,KAAK;gBACL,OAAO,EAAE,KAAK;gBACd,KAAK,EAAE,MAAM;aACd;SACF,CAAC;aACD,KAAK,CAAC,GAAG,EAAE;YACV,iBAAiB;QACnB,CAAC,CAAC,CAAC;IACP,CAAC,CAAC;IAEF,OAAO;QACL,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,CAAC,KAAK,CAAC,OAAO,EAAE,KAAK,EAAE,MAAM,CAAC;QACvD,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC;QACrD,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC;QACrD,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,CAAC,KAAK,CAAC,OAAO,EAAE,KAAK,EAAE,MAAM,CAAC;KACxD,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,6BAA6B,CAC3C,iBAA+C,EAAE;IAEjD,OAAO,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE;QAC1B,IAAI,eAAe,GAAa,iBAAiB,CAAC;QAClD,MAAM,MAAM,GAAG,cAAc,CAAC,MAAM,IAAI,oBAAoB,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,eAAe,CAAC,CAAC;QAE5F,OAAO;YACL,MAAM,EAAE,KAAK,EAAE,MAAsB,EAAE,EAAE;gBACvC,eAAe,GAAG,oBAAoB,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,iBAAiB,CAAC;gBAC7E,kBAAkB,CAAC,MAA4B,EAAE;oBAC/C,MAAM;oBACN,SAAS,EAAE,cAAc,CAAC,SAAS;oBACnC,GAAG,EAAE,cAAc,CAAC,GAAG;oBACvB,KAAK,EAAE,cAAc,CAAC,KAAK;iBAC5B,CAAC,CAAC;YACL,CAAC;SACF,CAAC;IACJ,CAAC,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,MAAM,uBAAuB,GAAG,6BAA6B,EAAE,CAAC;AAEvE,eAAe,uBAAuB,CAAC"}
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Logger } from "./logging.js";
|
|
2
|
+
import type { RateLimitOptions } from "./types.js";
|
|
3
|
+
/** Fallback backoff when a 429 carries no `x-ratelimit-reset` and no `Retry-After`. */
|
|
4
|
+
export declare const DEFAULT_BACKOFF_MS = 1000;
|
|
5
|
+
/**
|
|
6
|
+
* Mutable, in-memory rate-limit state for a single provider. Created fresh per
|
|
7
|
+
* {@link installRateLimiter} call and captured by the provider's fetch wrapper
|
|
8
|
+
* closure, so it lives for the process lifetime. Never persisted — a reset
|
|
9
|
+
* window is measured in seconds, so survival across process boots is pointless.
|
|
10
|
+
*/
|
|
11
|
+
export interface ProviderRateState {
|
|
12
|
+
/**
|
|
13
|
+
* Epoch ms until which new requests should be held back. 0 = no cooldown.
|
|
14
|
+
* This is the ONLY field that drives the gate — it is set from a response's
|
|
15
|
+
* `x-ratelimit-reset` (when `remaining` hits 0) or from a 429 backoff. We
|
|
16
|
+
* deliberately do not retain the raw `remaining`/`limit`: out-of-order
|
|
17
|
+
* responses would race on it and nothing reads it (the `ratelimit_quota` log
|
|
18
|
+
* uses the per-response snapshot directly).
|
|
19
|
+
*/
|
|
20
|
+
cooldownUntilMs: number;
|
|
21
|
+
/** A single in-flight wait shared by every caller during a cooldown window. */
|
|
22
|
+
cooldownPromise?: Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* Epoch ms at which {@link cooldownPromise} is scheduled to resolve. Lets a
|
|
25
|
+
* caller that needs a shorter wait detect a too-long shared timer and fall
|
|
26
|
+
* back to its own sleep, so it never waits past its required time / `maxWaitMs`.
|
|
27
|
+
*/
|
|
28
|
+
cooldownPromiseUntilMs?: number;
|
|
29
|
+
}
|
|
30
|
+
export declare function createProviderState(): ProviderRateState;
|
|
31
|
+
export interface RateLimitDeps {
|
|
32
|
+
logger: Logger;
|
|
33
|
+
/** Defaults to `Date.now`. */
|
|
34
|
+
now?: () => number;
|
|
35
|
+
/** Defaults to a real `setTimeout`-based, abort-aware sleep. */
|
|
36
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
37
|
+
/** Underlying fetch when a provider has no pre-existing `options.fetch`. Defaults to `globalThis.fetch`. */
|
|
38
|
+
fetchImpl?: typeof fetch;
|
|
39
|
+
}
|
|
40
|
+
export interface ProviderConfigLike {
|
|
41
|
+
options?: Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
export interface InstallConfigInput {
|
|
44
|
+
provider?: Record<string, ProviderConfigLike | undefined>;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Walk every provider in the assembled OpenCode config; for each one that has
|
|
48
|
+
* opted in via `options.meta.rateLimit`, wrap its `options.fetch` with a
|
|
49
|
+
* rate-limit-aware fetch. Synchronous — all real work happens lazily inside the
|
|
50
|
+
* wrapper at request time.
|
|
51
|
+
*/
|
|
52
|
+
export declare function installRateLimiter(input: InstallConfigInput, deps: RateLimitDeps): void;
|
|
53
|
+
/**
|
|
54
|
+
* Build the fetch wrapper for one provider. The wrapper:
|
|
55
|
+
* 1. Pre-request gate: if a cooldown window is armed, waits until it clears.
|
|
56
|
+
* 2. Sends the request via the underlying fetch.
|
|
57
|
+
* 3. Reads the rate-limit headers; if the window is exhausted, arms the gate
|
|
58
|
+
* (`cooldownUntilMs`) for the next callers.
|
|
59
|
+
* 4. On a 429, waits the reset window and retries (up to `maxRetries`).
|
|
60
|
+
*
|
|
61
|
+
* The Response is returned untouched (no `.clone()`) — we only read `status`
|
|
62
|
+
* and `headers`, so the body stream is delivered to OpenCode intact.
|
|
63
|
+
*/
|
|
64
|
+
export declare function makeRateLimitFetch(providerId: string, opts: RateLimitOptions, state: ProviderRateState, deps: RateLimitDeps, delegate?: typeof fetch): typeof fetch;
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { parseRateLimitOptions } from "./config.js";
|
|
2
|
+
import { parseRateLimit } from "./headers.js";
|
|
3
|
+
/** Fallback backoff when a 429 carries no `x-ratelimit-reset` and no `Retry-After`. */
|
|
4
|
+
export const DEFAULT_BACKOFF_MS = 1000;
|
|
5
|
+
export function createProviderState() {
|
|
6
|
+
return { cooldownUntilMs: 0 };
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Walk every provider in the assembled OpenCode config; for each one that has
|
|
10
|
+
* opted in via `options.meta.rateLimit`, wrap its `options.fetch` with a
|
|
11
|
+
* rate-limit-aware fetch. Synchronous — all real work happens lazily inside the
|
|
12
|
+
* wrapper at request time.
|
|
13
|
+
*/
|
|
14
|
+
export function installRateLimiter(input, deps) {
|
|
15
|
+
const providers = input.provider;
|
|
16
|
+
if (!providers) {
|
|
17
|
+
deps.logger.info("ratelimit_plugin_initialized", { providerCount: 0 });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
let enabledCount = 0;
|
|
21
|
+
for (const [providerId, providerConfig] of Object.entries(providers)) {
|
|
22
|
+
if (!providerConfig) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const opts = parseRateLimitOptions(providerConfig.options);
|
|
26
|
+
if (!opts) {
|
|
27
|
+
deps.logger.debug("ratelimit_provider_skipped", { providerId, reason: "not_opted_in" });
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const options = (providerConfig.options ??= {});
|
|
31
|
+
// Compose with any fetch a prior plugin already installed (capture it now,
|
|
32
|
+
// not at call time, so we delegate to the original — not to ourselves).
|
|
33
|
+
const delegate = typeof options.fetch === "function" ? options.fetch : undefined;
|
|
34
|
+
const state = createProviderState();
|
|
35
|
+
options.fetch = makeRateLimitFetch(providerId, opts, state, deps, delegate);
|
|
36
|
+
enabledCount += 1;
|
|
37
|
+
deps.logger.info("ratelimit_provider_enabled", {
|
|
38
|
+
providerId,
|
|
39
|
+
maxWaitMs: opts.maxWaitMs,
|
|
40
|
+
maxRetries: opts.maxRetries,
|
|
41
|
+
headerPrefix: opts.headerPrefix
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
deps.logger.info("ratelimit_plugin_initialized", { providerCount: enabledCount });
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Build the fetch wrapper for one provider. The wrapper:
|
|
48
|
+
* 1. Pre-request gate: if a cooldown window is armed, waits until it clears.
|
|
49
|
+
* 2. Sends the request via the underlying fetch.
|
|
50
|
+
* 3. Reads the rate-limit headers; if the window is exhausted, arms the gate
|
|
51
|
+
* (`cooldownUntilMs`) for the next callers.
|
|
52
|
+
* 4. On a 429, waits the reset window and retries (up to `maxRetries`).
|
|
53
|
+
*
|
|
54
|
+
* The Response is returned untouched (no `.clone()`) — we only read `status`
|
|
55
|
+
* and `headers`, so the body stream is delivered to OpenCode intact.
|
|
56
|
+
*/
|
|
57
|
+
export function makeRateLimitFetch(providerId, opts, state, deps, delegate) {
|
|
58
|
+
const now = deps.now ?? Date.now;
|
|
59
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
60
|
+
const underlying = delegate ?? deps.fetchImpl ?? globalThis.fetch;
|
|
61
|
+
const { logger } = deps;
|
|
62
|
+
const wrapped = async (input, init) => {
|
|
63
|
+
const signal = init?.signal ?? undefined;
|
|
64
|
+
// 1. Pre-request gate.
|
|
65
|
+
if (state.cooldownUntilMs > now()) {
|
|
66
|
+
const waitMs = clampWait(state.cooldownUntilMs - now(), opts.maxWaitMs);
|
|
67
|
+
if (waitMs > 0) {
|
|
68
|
+
logger.info("ratelimit_throttle_wait", { providerId, waitMs, reason: "remaining_zero" });
|
|
69
|
+
await waitGate(state, sleep, now, opts.maxWaitMs, signal, logger, providerId, "pre_request");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// 2-4. Attempt loop.
|
|
73
|
+
let attempt = 0;
|
|
74
|
+
for (;;) {
|
|
75
|
+
const response = await underlying(input, init);
|
|
76
|
+
const snapshot = readSnapshot(response, opts, now(), logger, providerId);
|
|
77
|
+
logger.debug("ratelimit_quota", {
|
|
78
|
+
providerId,
|
|
79
|
+
remaining: snapshot.remaining,
|
|
80
|
+
limit: snapshot.limit,
|
|
81
|
+
resetSeconds: snapshot.resetSeconds
|
|
82
|
+
});
|
|
83
|
+
if (response.status !== 429) {
|
|
84
|
+
// Arm the gate for the NEXT callers when the window is exhausted.
|
|
85
|
+
if (snapshot.remaining !== undefined &&
|
|
86
|
+
snapshot.remaining <= 0 &&
|
|
87
|
+
snapshot.resetAtMs !== undefined) {
|
|
88
|
+
state.cooldownUntilMs = snapshot.resetAtMs;
|
|
89
|
+
}
|
|
90
|
+
return response;
|
|
91
|
+
}
|
|
92
|
+
if (attempt >= opts.maxRetries) {
|
|
93
|
+
logger.error("ratelimit_giveup", { providerId, attempts: attempt });
|
|
94
|
+
return response;
|
|
95
|
+
}
|
|
96
|
+
const waitMs = clampWait(computeBackoff(snapshot, now()), opts.maxWaitMs);
|
|
97
|
+
state.cooldownUntilMs = now() + waitMs;
|
|
98
|
+
logger.warn("ratelimit_429_backoff", {
|
|
99
|
+
providerId,
|
|
100
|
+
attempt: attempt + 1,
|
|
101
|
+
waitMs,
|
|
102
|
+
resetSeconds: snapshot.resetSeconds
|
|
103
|
+
});
|
|
104
|
+
await waitGate(state, sleep, now, opts.maxWaitMs, signal, logger, providerId, "backoff");
|
|
105
|
+
attempt += 1;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
return wrapped;
|
|
109
|
+
}
|
|
110
|
+
function readSnapshot(response, opts, nowMs, logger, providerId) {
|
|
111
|
+
try {
|
|
112
|
+
return parseRateLimit(response.headers, opts.headerPrefix, nowMs);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
logger.debug("ratelimit_header_parse_failed", {
|
|
116
|
+
providerId,
|
|
117
|
+
error: error instanceof Error ? error.message : String(error)
|
|
118
|
+
});
|
|
119
|
+
return {};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function computeBackoff(snapshot, nowMs) {
|
|
123
|
+
if (snapshot.resetAtMs !== undefined) {
|
|
124
|
+
return Math.max(0, snapshot.resetAtMs - nowMs);
|
|
125
|
+
}
|
|
126
|
+
if (snapshot.retryAfterMs !== undefined) {
|
|
127
|
+
return snapshot.retryAfterMs;
|
|
128
|
+
}
|
|
129
|
+
return DEFAULT_BACKOFF_MS;
|
|
130
|
+
}
|
|
131
|
+
/** `maxWaitMs` of 0 means unlimited (wait the full reset window). */
|
|
132
|
+
function clampWait(ms, maxWaitMs) {
|
|
133
|
+
const nonNegative = Math.max(0, ms);
|
|
134
|
+
return maxWaitMs > 0 ? Math.min(nonNegative, maxWaitMs) : nonNegative;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Wait until `state.cooldownUntilMs` has elapsed. Two concurrency properties:
|
|
138
|
+
*
|
|
139
|
+
* - **One shared timer.** The first caller to hit the gate creates the timer on
|
|
140
|
+
* `state.cooldownPromise`; concurrent callers await that same timer rather
|
|
141
|
+
* than each starting their own, so a burst during cooldown produces ONE wait,
|
|
142
|
+
* not N. The shared timer is not tied to any single caller's `signal` — each
|
|
143
|
+
* caller races it against its own signal (see `raceWithAbort`), so one
|
|
144
|
+
* request's cancellation never aborts the others.
|
|
145
|
+
* - **Honors the longest window.** After the shared timer resolves we re-check
|
|
146
|
+
* `cooldownUntilMs`. If another caller extended the window (e.g. a 429 landed
|
|
147
|
+
* with a further-out reset) while we were waiting — or the shared timer was
|
|
148
|
+
* created for a shorter wait than we now require — we wait again for the
|
|
149
|
+
* remainder instead of returning early and hammering the gateway.
|
|
150
|
+
*
|
|
151
|
+
* `maxWaitMs` (when > 0) bounds the TOTAL wait of a single call: once a caller
|
|
152
|
+
* has waited that long it proceeds even if the window hasn't fully elapsed. A
|
|
153
|
+
* caller that needs LESS than the in-flight shared timer (the window shrank, or
|
|
154
|
+
* the timer was created by a caller with a later deadline) falls back to its own
|
|
155
|
+
* private sleep so it is never held past its own required time — the shared
|
|
156
|
+
* timer is reserved for the common case where everyone is waiting the same span.
|
|
157
|
+
*/
|
|
158
|
+
async function waitGate(state, sleep, now, maxWaitMs, signal, logger, providerId, phase) {
|
|
159
|
+
const deadlineMs = maxWaitMs > 0 ? now() + maxWaitMs : Number.POSITIVE_INFINITY;
|
|
160
|
+
for (;;) {
|
|
161
|
+
const target = Math.min(state.cooldownUntilMs, deadlineMs);
|
|
162
|
+
const remainingMs = target - now();
|
|
163
|
+
if (remainingMs <= 0) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// Reuse the shared timer only if it won't make us wait longer than we need.
|
|
167
|
+
let pending = state.cooldownPromise;
|
|
168
|
+
if (pending &&
|
|
169
|
+
state.cooldownPromiseUntilMs !== undefined &&
|
|
170
|
+
state.cooldownPromiseUntilMs - now() > remainingMs) {
|
|
171
|
+
pending = undefined;
|
|
172
|
+
}
|
|
173
|
+
if (!pending) {
|
|
174
|
+
const created = sleep(remainingMs).finally(() => {
|
|
175
|
+
if (state.cooldownPromise === created) {
|
|
176
|
+
state.cooldownPromise = undefined;
|
|
177
|
+
state.cooldownPromiseUntilMs = undefined;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
// Publish as the shared timer only when there isn't already a (shorter)
|
|
181
|
+
// one in flight — never clobber it with our longer/private wait.
|
|
182
|
+
if (!state.cooldownPromise) {
|
|
183
|
+
state.cooldownPromise = created;
|
|
184
|
+
state.cooldownPromiseUntilMs = now() + remainingMs;
|
|
185
|
+
}
|
|
186
|
+
pending = created;
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
await raceWithAbort(pending, signal);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
if (isAbortError(error)) {
|
|
193
|
+
logger.warn("ratelimit_wait_aborted", { providerId, phase });
|
|
194
|
+
}
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
// Loop: re-check in case the window was extended while we waited.
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function raceWithAbort(promise, signal) {
|
|
201
|
+
if (!signal) {
|
|
202
|
+
return promise;
|
|
203
|
+
}
|
|
204
|
+
if (signal.aborted) {
|
|
205
|
+
return Promise.reject(toAbortError(signal));
|
|
206
|
+
}
|
|
207
|
+
return new Promise((resolve, reject) => {
|
|
208
|
+
const onAbort = () => reject(toAbortError(signal));
|
|
209
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
210
|
+
promise.then(() => {
|
|
211
|
+
signal.removeEventListener("abort", onAbort);
|
|
212
|
+
resolve();
|
|
213
|
+
}, (error) => {
|
|
214
|
+
signal.removeEventListener("abort", onAbort);
|
|
215
|
+
reject(error);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
function defaultSleep(ms, signal) {
|
|
220
|
+
return new Promise((resolve, reject) => {
|
|
221
|
+
if (signal?.aborted) {
|
|
222
|
+
reject(toAbortError(signal));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const timer = setTimeout(() => {
|
|
226
|
+
signal?.removeEventListener("abort", onAbort);
|
|
227
|
+
resolve();
|
|
228
|
+
}, ms);
|
|
229
|
+
const onAbort = () => {
|
|
230
|
+
clearTimeout(timer);
|
|
231
|
+
reject(toAbortError(signal));
|
|
232
|
+
};
|
|
233
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
function toAbortError(signal) {
|
|
237
|
+
const reason = signal.reason;
|
|
238
|
+
if (reason instanceof Error) {
|
|
239
|
+
return reason;
|
|
240
|
+
}
|
|
241
|
+
return new DOMException("The operation was aborted", "AbortError");
|
|
242
|
+
}
|
|
243
|
+
function isAbortError(error) {
|
|
244
|
+
return error instanceof Error && error.name === "AbortError";
|
|
245
|
+
}
|
|
246
|
+
//# sourceMappingURL=plugin.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugin.js","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"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"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
2
|
+
/**
|
|
3
|
+
* Resolved, defaults-applied rate-limit configuration for a single provider,
|
|
4
|
+
* derived from `provider.options.meta.rateLimit`. See {@link parseRateLimitOptions}.
|
|
5
|
+
*/
|
|
6
|
+
export interface RateLimitOptions {
|
|
7
|
+
/** Whether rate-limit handling is active for this provider. */
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Upper bound on any single wait (pre-request throttle or 429 backoff), in
|
|
11
|
+
* milliseconds. `0` means unlimited — wait the full reset window.
|
|
12
|
+
*/
|
|
13
|
+
maxWaitMs: number;
|
|
14
|
+
/** How many times a 429 is retried before the response is handed back as-is. */
|
|
15
|
+
maxRetries: number;
|
|
16
|
+
/**
|
|
17
|
+
* Lowercased header-name prefix for the IETF draft-03 triple, e.g.
|
|
18
|
+
* `x-ratelimit` → `x-ratelimit-limit` / `-remaining` / `-reset`.
|
|
19
|
+
*/
|
|
20
|
+
headerPrefix: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* What a single response told us about the current rate-limit window. Every
|
|
24
|
+
* field is optional: a gateway may emit a partial set, and missing/garbage
|
|
25
|
+
* headers leave the field `undefined` (treated as "unknown → let requests flow").
|
|
26
|
+
*/
|
|
27
|
+
export interface RateLimitSnapshot {
|
|
28
|
+
/** Effective limit — the first integer token of `x-ratelimit-limit`. */
|
|
29
|
+
limit?: number;
|
|
30
|
+
/** Requests left in the current window (`x-ratelimit-remaining`). */
|
|
31
|
+
remaining?: number;
|
|
32
|
+
/** Seconds until the window resets (`x-ratelimit-reset`). */
|
|
33
|
+
resetSeconds?: number;
|
|
34
|
+
/** Absolute reset time in epoch ms, derived from `resetSeconds` + observation time. */
|
|
35
|
+
resetAtMs?: number;
|
|
36
|
+
/** Fallback wait derived from a `Retry-After` header, in ms (seconds or HTTP-date). */
|
|
37
|
+
retryAfterMs?: number;
|
|
38
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vymalo/opencode-ratelimit",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "OpenCode plugin that makes OpenAI-compatible providers rate-limit aware: it reads Envoy Gateway / IETF draft-03 rate-limit response headers (x-ratelimit-limit/remaining/reset), proactively throttles requests when the quota is exhausted, and backs off and retries on HTTP 429.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "vymalo contributors",
|
|
7
|
+
"homepage": "https://github.com/vymalo/opencode-oauth2#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/vymalo/opencode-oauth2.git",
|
|
11
|
+
"directory": "packages/opencode-ratelimit"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/vymalo/opencode-oauth2/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"opencode",
|
|
18
|
+
"opencode-plugin",
|
|
19
|
+
"rate-limit",
|
|
20
|
+
"throttle",
|
|
21
|
+
"429",
|
|
22
|
+
"envoy",
|
|
23
|
+
"ai-sdk"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"main": "dist/index.js",
|
|
27
|
+
"types": "dist/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./lib": {
|
|
34
|
+
"types": "./dist/lib.d.ts",
|
|
35
|
+
"import": "./dist/lib.js"
|
|
36
|
+
},
|
|
37
|
+
"./package.json": "./package.json"
|
|
38
|
+
},
|
|
39
|
+
"sideEffects": false,
|
|
40
|
+
"files": [
|
|
41
|
+
"dist"
|
|
42
|
+
],
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=22"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@opencode-ai/plugin": "1.15.10"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"vite": "^8.0.14",
|
|
54
|
+
"vitest": "^4.1.7"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "tsc -p tsconfig.json",
|
|
58
|
+
"lint": "biome lint .",
|
|
59
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
60
|
+
"test": "vitest run",
|
|
61
|
+
"format": "biome format --write .",
|
|
62
|
+
"format:check": "biome format ."
|
|
63
|
+
}
|
|
64
|
+
}
|