diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 23d8c96..2ec81d8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -8,7 +8,7 @@ on:
jobs:
typecheck:
- name: Typecheck (Node ${{ matrix.node }})
+ name: Typecheck & Test (Node ${{ matrix.node }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -28,3 +28,6 @@ jobs:
- name: Typecheck
run: npm run typecheck
+
+ - name: Test
+ run: npm test
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 0364d3a..54709b9 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -18,6 +18,7 @@ git clone https://github.com/yuseferi/opencode-litellm.git
cd opencode-litellm
npm install
npm run typecheck
+npm test
```
## Testing locally against your OpenCode
@@ -32,11 +33,12 @@ npm link opencode-plugin-litellm
opencode
```
-Plugin logs are prefixed with `[opencode-litellm]` — `tail -f ~/.opencode/logs/...` to watch them.
+Plugin logs are prefixed with `[opencode-litellm]` — find them under `~/.local/share/opencode/log/`.
## Pull request checklist
- [ ] `npm run typecheck` passes locally
+- [ ] `npm test` passes locally; behavior changes come with test updates
- [ ] No new runtime dependencies (or strong justification in the PR description)
- [ ] Public API changes are reflected in the README
- [ ] User-visible changes are added to `CHANGELOG.md` under `## [Unreleased]`
diff --git a/README.md b/README.md
index c4e74d0..06b9724 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,8 @@
Auto-detect a running LiteLLM proxy, pull every model from `/v1/models`, and register them in OpenCode.
**No model lists to hand-maintain. No restart loops. No surprises.**
+
+
[Quickstart](#-quickstart) · [Configuration](#%EF%B8%8F-configuration) · [How it works](#-how-it-works) · [FAQ](#-faq) · [Contributing](./CONTRIBUTING.md)
@@ -38,14 +40,8 @@ Maintaining a `models` block in `opencode.json` for every model your LiteLLM pro
## 🚀 Quickstart
-```bash
-# 1. Install
-npm install opencode-plugin-litellm
-# or: bun add opencode-plugin-litellm
-```
-
```jsonc
-// 2. Add to opencode.json
+// 1. Add to opencode.json — OpenCode installs the plugin from npm automatically
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-plugin-litellm@latest"],
@@ -61,10 +57,10 @@ npm install opencode-plugin-litellm
```
```bash
-# 3. Start LiteLLM (if it isn't already)
+# 2. Start LiteLLM (if it isn't already)
litellm --config config.yaml --port 4000
-# 4. Run OpenCode — every model in your LiteLLM model_list is now available.
+# 3. Run OpenCode — every model in your LiteLLM model_list is now available.
opencode
```
@@ -74,12 +70,12 @@ opencode
|---|---|
| 🔍 **Auto-detection** | Probes `localhost:4000`, `:8000`, `:8080` and adopts the first responsive proxy. |
| 📡 **Dynamic discovery** | Queries `/v1/models` so your OpenCode model picker always reflects your live `model_list`. |
-| 🏷️ **Smart formatting** | Turns `anthropic/claude-3-5-sonnet` into `Claude 3 5 Sonnet` in the picker — handles versions, sizes, quantizations, and brand-cased names like `gpt-4o`. |
+| ⚡ **Instant startup (SWR)** | Discovered models are cached on disk and loaded synchronously — startup never blocks on the network. A background refresh on new sessions keeps the cache fresh; entries expire after 7 days. |
+| 🏷️ **Smart formatting** | Turns `anthropic/claude-3-5-sonnet` into `Claude 3.5 Sonnet` in the picker — handles versions, sizes, quantizations, and brand-cased names like `gpt-4o`. |
| 🧠 **Modality-aware** | Enriches `/v1/models` entries with `/v1/model/info` (`mode`, token limits, capability flags) and hides embedding / image / audio models from the picker. |
-| 💵 **Real pricing** | Maps `input_cost_per_token` / `output_cost_per_token` from `/v1/model/info` into OpenCode's `cost` field, so the picker and `/cost` show what the proxy actually bills instead of `$0.00`. Models LiteLLM has no price for are left unpriced, not falsely marked free. |
-| 🧪 **Reasoning-aware routing** | Auto-routes `gpt-5*` / `o1`/`o3`/`o4*` models through a sibling `litellm-responses` provider that uses `/v1/responses`, so tools + `reasoning_effort` actually work. Override per model via `responsesApiModels` / `chatApiModels`. |
-| 🏢 **Provider extraction** | Pulls `litellm_provider` (or the `provider/model` prefix) into `organizationOwner` so models group correctly in the UI. |
-| 🔐 **Auth-aware** | Honours `LITELLM_API_KEY` / `LITELLM_MASTER_KEY` env vars or `provider.litellm.options.apiKey`. |
+| 💵 **Real pricing** | Maps `input_cost_per_token` / `output_cost_per_token` (and cache read/write costs) from `/v1/model/info` into OpenCode's `cost` field, so the picker and `/cost` show what the proxy actually bills instead of `$0.00`. Models LiteLLM has no price for are left unpriced, not falsely marked free. |
+| 🧩 **Reasoning-effort variants** | When LiteLLM reports per-model effort support (`supports_low_reasoning_effort`, …), the plugin surfaces each level as a picker variant automatically. |
+| 🔐 **Auth-aware** | Honours `LITELLM_API_KEY` / `LITELLM_MASTER_KEY` env vars, `provider.litellm.options.apiKey`, or the key you stored via OpenCode's `/connect`. |
| 🌐 **Gateway-friendly** | Supports `customHeaders` for proxies behind Cloudflare Access or other API gateways requiring extra HTTP headers. |
| ⏱️ **Non-blocking startup** | Health checks fail fast (3 s); discovery fetches are capped at **15 s** for slow remote proxies. Repeat config-hook invocations are a no-op. |
| 🤝 **Non-destructive merge** | Only adds models you don't already have configured. Hand-curated entries are preserved verbatim. |
@@ -183,18 +179,10 @@ If you want to rename a model in the picker, pin its `organizationOwner`, or oth
Here, `openai/gpt-4o` keeps your custom name; every other model from the proxy is still discovered and added automatically.
-### Reasoning models (gpt-5, o1/o3/o4)
-
-OpenAI's reasoning-tier models reject requests that combine `reasoning_effort`
-with function tools when sent to `/v1/chat/completions`. The OpenAI Responses
-API (`/v1/responses`) has no such restriction, so the plugin routes those
-models through a **second provider entry** named `litellm-responses` that
-uses an SDK speaking the Responses API.
+### Reasoning models and effort variants
-You don't need to do anything for the default behaviour — the plugin
-detects reasoning-tier models from their id (`gpt-5*`, `o1*`, `o3*`,
-`o4*`) and from LiteLLM's `mode === 'responses'` field, and creates the
-sibling provider lazily.
+All discovered models — reasoning-tier included — register under your single
+LiteLLM provider and are invoked through `/v1/chat/completions`.
If LiteLLM reports per-model reasoning-effort support (e.g.
`supports_low_reasoning_effort`, `supports_medium_reasoning_effort`,
@@ -203,39 +191,13 @@ surfaces those as OpenCode variants under the discovered model. Each variant
sets `reasoningEffort` to the reported level, so you can switch between
effort levels from the model picker without hand-curating every entry.
-To override the routing per model:
-
-```jsonc
-{
- "provider": {
- "litellm": {
- "options": {
- "baseURL": "http://localhost:4000/v1",
-
- // "auto" (default) | "chat" | "responses"
- "transport": "auto",
-
- // Force these into /v1/responses (highest precedence)
- "responsesApiModels": ["gpt-5-4-high", "my-custom-reasoning-model"],
-
- // Force these into /v1/chat/completions
- "chatApiModels": ["o1-mini-cheap"]
- }
- }
- }
-}
-```
-
-The two providers share `baseURL` and `apiKey`. Models curated by hand
-under either provider's `models` block are preserved verbatim, and a
-discovered model is skipped if its key already exists under **either**
-provider.
-
-> **Note**: this requires LiteLLM ≥ 1.40 (which proxies `/v1/responses`)
-> and an `@ai-sdk/openai` version that supports the Responses API. Older
-> AI SDKs may silently fall back to chat-completions, in which case set
-> `responsesApiModels` to an empty list and fix the upstream LiteLLM
-> config instead (e.g. `use_responses_api: true` per model).
+> **Note**: OpenAI's reasoning-tier models (gpt-5, o1, o3, o4) reject
+> requests that combine `reasoning_effort` with function tools on
+> `/v1/chat/completions`. If you hit that error, fix it on the LiteLLM
+> side — e.g. enable the Responses API for that model
+> (`use_responses_api: true` in its `litellm_params`) — or leave
+> `reasoningEffort` unset for that model in OpenCode. See the FAQ entry
+> below.
### Authentication
@@ -279,34 +241,41 @@ These headers are included in every request the plugin makes during model discov
sequenceDiagram
participant OC as OpenCode
participant Plugin as opencode-litellm
+ participant Cache as disk cache
participant LL as LiteLLM proxy
OC->>Plugin: config(initial)
alt provider.litellm configured
- Plugin->>LL: GET /v1/models @ baseURL
+ Plugin->>Plugin: use configured baseURL
else not configured
- Plugin->>LL: probe :4000, :8000, :8080
+ Plugin->>LL: probe :4000, :8000, :8080 (GET /v1/models, 3 s fail-fast)
LL-->>Plugin: 200 OK on one
Plugin->>Plugin: auto-create provider entry
end
- Plugin->>LL: GET /v1/models (with auth if set)
- Plugin->>LL: GET /v1/model/info (best-effort, for `mode` + limits)
- LL-->>Plugin: { data: [...models] } + per-model info
- Plugin->>Plugin: enrich models, hide non-chat (embedding/image/audio)
- Plugin->>Plugin: format names, infer modalities, extract owner
- Plugin->>Plugin: bucket each model by transport (chat vs responses)
- Plugin->>OC: merge chat-completions models into provider.litellm
- Plugin->>OC: merge responses models into provider.litellm-responses (lazy)
- OC->>OC: render model picker with all eligible discovered chat models
+ Plugin->>Cache: read SWR cache (no network yet)
+ alt cache hit
+ Cache-->>Plugin: cached models
+ Plugin->>OC: merge into provider.litellm (instant startup)
+ else cache miss
+ Plugin->>LL: GET /v1/models (with auth if set)
+ Plugin->>LL: GET /v1/model/info (best-effort)
+ LL-->>Plugin: { data: [...models] } + per-model info
+ Plugin->>Plugin: enrich models, hide non-chat (embedding/image/audio)
+ Plugin->>Plugin: format names, infer modalities + limits + pricing
+ Plugin->>OC: merge into provider.litellm
+ Plugin->>Cache: persist for next startup
+ end
+ Note over Plugin,Cache: on session.created, revalidate cache in
the background (throttled to 5 min)
+ OC->>OC: render model picker with all discovered models
```
1. On OpenCode startup the `config` lifecycle hook fires.
-2. If `provider.litellm` exists, its `baseURL` is used. Otherwise common ports are probed.
-3. A health check (`GET /v1/models`) verifies the proxy is reachable and authorized.
-4. Models from the response are enriched with `/v1/model/info` metadata (`mode`, token limits, capability flags, and per-token pricing — `/v1/models` omits these for database-defined models) and converted into OpenCode model entries keyed by `id`, with formatted `name`, `organizationOwner`, inferred `modalities`, and `cost` (USD/1M tokens, converted from LiteLLM's USD/token). Non-chat models (embedding / image / audio) are excluded from the picker.
-5. Each model is bucketed by transport — reasoning-tier models (`gpt-5*`, `o1`/`o3`/`o4*`, or anything with `mode === 'responses'`) go into the `litellm-responses` provider; everything else goes into `litellm`. Per-model overrides via `responsesApiModels` / `chatApiModels` win.
-6. Discovered models are merged on top of any user-defined ones — never overwriting them. A model is skipped if its key already exists under **either** provider.
-7. The whole flow is wrapped in a `Promise.race` against a 20 s timeout so a slow proxy never blocks boot.
+2. If `provider.litellm` exists, its `baseURL` is used. Otherwise common ports are probed — that probe is the only health check (3 s fail-fast per port).
+3. With a configured `baseURL` the proxy is not contacted during startup unless the cache is cold; the discovery fetch itself fails fast if the proxy is unreachable.
+4. **Fast path:** if a fresh on-disk cache exists (≤ 7 days old), its models are merged in synchronously — startup never waits on the network.
+5. **Cold path:** `/v1/models` and `/v1/model/info` are fetched in parallel. Models are enriched with info metadata (`mode`, token limits, capability flags, per-token pricing — `/v1/models` omits these for database-defined models) and converted into OpenCode model entries with formatted `name`, inferred `modalities`, and `cost` (USD/1M tokens, converted from LiteLLM's USD/token). Non-chat models (embedding / image / audio) are excluded from the picker.
+6. Discovered models are merged on top of any user-defined ones — never overwriting them — and persisted to the cache.
+7. On every `session.created` event the cache is revalidated in the background (throttled to once per 5 minutes); refreshed entries surface on the next OpenCode start. The whole cold path is capped by a 20 s timeout so a slow proxy never blocks boot.
## 📋 Requirements
@@ -331,7 +300,13 @@ sequenceDiagram
Why doesn't a model appear in OpenCode after I add it to LiteLLM?
-OpenCode reads the plugin output once at startup. After updating `litellm config.yaml`, restart **both** LiteLLM and OpenCode to refresh the model list.
+Once LiteLLM exposes the model (restart or hot-reload LiteLLM if you edited
+its `config.yaml`), the plugin picks it up automatically: whenever you open a
+new session (and at most every 5 minutes), it re-queries the proxy and
+updates the on-disk cache. The new model appears on your **next OpenCode
+start** — no OpenCode config change needed. To force an immediate refetch,
+delete the cache directory (`~/.cache/opencode-litellm/`, or
+`$XDG_CACHE_HOME/opencode-litellm/`).
@@ -343,7 +318,12 @@ Yes. Set `provider.litellm.options.baseURL` to your remote URL and (optionally)
What happens if LiteLLM is offline at startup?
-The plugin logs a warning and is a no-op. OpenCode starts normally; you just won't see LiteLLM-discovered models until you restart with the proxy up.
+OpenCode starts normally either way. With a warm on-disk cache, the
+previously discovered models are served from the cache — no network call —
+so the picker works as usual; only the background refresh is skipped until
+the proxy is back. With a cold cache (first run, or after the 7-day expiry),
+the plugin logs a warning and you won't see LiteLLM-discovered models until
+the proxy is reachable again.
@@ -391,29 +371,12 @@ The `customHeaders` map works for any gateway that requires extra HTTP headers
I get Function tools with reasoning_effort are not supported … in /v1/chat/completions — what do I do?
-This error comes from OpenAI: their reasoning-tier models (gpt-5, o1, o3, o4) refuse function-tool calls on `/v1/chat/completions` when `reasoning_effort` is set. They require `/v1/responses` instead.
+This error comes from OpenAI: their reasoning-tier models (gpt-5, o1, o3, o4) refuse function-tool calls on `/v1/chat/completions` when `reasoning_effort` is set. The plugin registers every discovered model through the chat-completions path, so fix this on the LiteLLM side:
-As of `0.2.0`, `opencode-litellm` automatically routes those models through a sibling `litellm-responses` provider that uses the Responses API. If your model id doesn't match the heuristic (e.g. you renamed it in LiteLLM), add it explicitly:
+- Enable the Responses API for that model in your LiteLLM config (e.g. `use_responses_api: true` in its `litellm_params`), or
+- Leave the model's `reasoningEffort` unset in OpenCode (don't pick a reasoning-effort variant for it).
-```jsonc
-"provider": {
- "litellm": {
- "options": {
- "responsesApiModels": ["my-renamed-gpt-5-high"]
- }
- }
-}
-```
-
-The model will appear under the **LiteLLM (responses)** provider in the picker; pick it from there and tool-calling will work.
-
-
-
-Why are there suddenly two providers (litellm and litellm-responses) in the picker?
-
-Same LiteLLM proxy, different transport. `litellm` talks to `/v1/chat/completions`; `litellm-responses` talks to `/v1/responses`. The split is required for OpenAI reasoning models — see the FAQ entry above.
-
-The responses provider is created lazily and only appears if at least one discovered model needs it. To collapse everything back into a single provider, set `"transport": "chat"` in `provider.litellm.options` (you'll lose tool-calling on reasoning models in exchange).
+If your model id doesn't look like a reasoning model to LiteLLM (e.g. you renamed it), also check that its `model_info` in `litellm config.yaml` carries the right `supports_*` flags.
## 🛠️ Development
@@ -423,6 +386,7 @@ git clone https://github.com/yuseferi/opencode-litellm.git
cd opencode-litellm
npm install
npm run typecheck
+npm test
```
The project is intentionally tiny:
@@ -433,11 +397,13 @@ src/
├── types/index.ts # LiteLLM API types
├── utils/
│ ├── litellm-api.ts # health check, discovery (/v1/models + /v1/model/info), auto-detect
-│ └── format-model-name.ts # owner extraction, name formatting, categorization
+│ ├── format-model-name.ts # name formatting, categorization
+│ ├── model-cache.ts # stale-while-revalidate on-disk model cache
+│ └── opencode-auth.ts # fallback to OpenCode's /connect-stored credentials
└── plugin/
- ├── index.ts # LiteLLMPlugin entry (config hook, enrichment, filtering)
- ├── discover.ts # V2 Model bucketing (unused by the config hook)
- └── build-model.ts # V2 Model entry builder
+ └── index.ts # LiteLLMPlugin entry (config hook, enrichment, filtering)
+
+test/ # vitest suite for the pure logic
```
See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the full contributor workflow.
@@ -445,9 +411,6 @@ See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the full contributor workflow.
## 🗺️ Roadmap
- [ ] Optional cost/latency overlay using LiteLLM's `/spend` and `/health` endpoints
-- [ ] In-memory cache with TTL to avoid re-querying on rapid restarts
-- [ ] Model categorization based on `litellm.proxy.config.model_list[].model_info`
-- [ ] Tests with [vitest](https://vitest.dev/)
- [ ] `chat.params` hook for injecting LiteLLM routing tags / fallbacks
Have an idea? [Open an issue](https://github.com/yuseferi/opencode-litellm/issues/new).
diff --git a/assets/architecture.svg b/assets/architecture.svg
new file mode 100644
index 0000000..516b563
--- /dev/null
+++ b/assets/architecture.svg
@@ -0,0 +1,63 @@
+
diff --git a/package-lock.json b/package-lock.json
index b1465ce..c30d5ee 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,15 +9,15 @@
"version": "0.11.1",
"license": "MIT",
"dependencies": {
- "@opencode-ai/plugin": "^1.14.0",
- "@opencode-ai/sdk": "^1.2.0"
+ "@opencode-ai/plugin": "^1.14.0"
},
"devDependencies": {
"@semantic-release/changelog": "^7.0.0",
"@semantic-release/git": "^11.0.1",
"@types/node": "^25.0.3",
"semantic-release": "^25.0.8",
- "typescript": "^5.9.3"
+ "typescript": "^5.9.3",
+ "vitest": "^4.1.11"
}
},
"node_modules/@actions/core": {
@@ -105,6 +105,13 @@
"node": ">=0.1.90"
}
},
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
@@ -372,6 +379,16 @@
"cross-spawn": "7.0.6"
}
},
+ "node_modules/@oxc-project/types": {
+ "version": "0.147.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz",
+ "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
"node_modules/@pnpm/config.env-replace": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz",
@@ -417,6 +434,286 @@
"node": ">=12"
}
},
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz",
+ "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz",
+ "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz",
+ "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz",
+ "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz",
+ "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz",
+ "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz",
+ "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz",
+ "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz",
+ "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz",
+ "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz",
+ "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz",
+ "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz",
+ "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz",
+ "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz",
+ "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@sec-ant/readable-stream": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
@@ -772,6 +1069,31 @@
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/node": {
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
@@ -789,6 +1111,119 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@vitest/expect": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
+ "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
+ "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.11",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
+ "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
+ "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.11",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
+ "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
+ "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
+ "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.11",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/agent-base": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz",
@@ -886,6 +1321,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/before-after-hook": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz",
@@ -923,6 +1368,16 @@
"node": ">=6"
}
},
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
@@ -1284,6 +1739,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -1393,8 +1855,8 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "devOptional": true,
"license": "Apache-2.0",
- "optional": true,
"engines": {
"node": ">=8"
}
@@ -1616,6 +2078,13 @@
"is-arrayish": "^0.2.1"
}
},
+ "node_modules/es-module-lexer": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1639,6 +2108,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
"node_modules/execa": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/execa/-/execa-10.0.0.tgz",
@@ -1680,6 +2159,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/fast-check": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz",
@@ -1795,6 +2284,21 @@
"node": ">=14.14"
}
},
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/function-timeout": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/function-timeout/-/function-timeout-1.0.2.tgz",
@@ -2225,39 +2729,312 @@
"dev": true,
"license": "MIT"
},
- "node_modules/json-parse-even-better-errors": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
- "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
+ "node_modules/json-parse-even-better-errors": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-with-bigint": {
+ "version": "3.5.10",
+ "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.10.tgz",
+ "integrity": "sha512-Vcx+JVNEBts/xfcoCS69sKrOhOk/3TVlvlT+XzUOefVKnnrbYSCKpDCm10pohsJFtsJVYnwa/cXRZ4eElzaM6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsonfile": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
+ "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/kubernetes-types": {
+ "version": "1.30.0",
+ "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
+ "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
"dev": true,
- "license": "MIT"
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
},
- "node_modules/json-with-bigint": {
- "version": "3.5.10",
- "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.10.tgz",
- "integrity": "sha512-Vcx+JVNEBts/xfcoCS69sKrOhOk/3TVlvlT+XzUOefVKnnrbYSCKpDCm10pohsJFtsJVYnwa/cXRZ4eElzaM6w==",
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
"dev": true,
- "license": "MIT"
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
},
- "node_modules/jsonfile": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
- "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
"dev": true,
- "license": "MIT",
- "dependencies": {
- "universalify": "^2.0.0"
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
},
- "optionalDependencies": {
- "graceful-fs": "^4.1.6"
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
}
},
- "node_modules/kubernetes-types": {
- "version": "1.30.0",
- "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
- "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
- "license": "Apache-2.0"
- },
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -2361,6 +3138,16 @@
"node": "20 || >=22"
}
},
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
"node_modules/make-asynchronous": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/make-asynchronous/-/make-asynchronous-1.1.0.tgz",
@@ -2556,6 +3343,25 @@
"thenify-all": "^1.0.0"
}
},
+ "node_modules/nanoid": {
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
"node_modules/neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
@@ -4576,6 +5382,20 @@
"node": ">=0.10.0"
}
},
+ "node_modules/obug": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
"node_modules/onetime": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz",
@@ -4810,6 +5630,13 @@
"node": ">=8"
}
},
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -4854,6 +5681,35 @@
"node": ">=4"
}
},
+ "node_modules/postcss": {
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.17",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
"node_modules/pretty-ms": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
@@ -5072,6 +5928,40 @@
"node": ">=8"
}
},
+ "node_modules/rolldown": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz",
+ "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.147.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm-eabi": "1.2.6",
+ "@rolldown/binding-android-arm64": "1.2.6",
+ "@rolldown/binding-darwin-arm64": "1.2.6",
+ "@rolldown/binding-darwin-x64": "1.2.6",
+ "@rolldown/binding-freebsd-x64": "1.2.6",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.6",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.6",
+ "@rolldown/binding-linux-arm64-musl": "1.2.6",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.6",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.6",
+ "@rolldown/binding-linux-x64-gnu": "1.2.6",
+ "@rolldown/binding-linux-x64-musl": "1.2.6",
+ "@rolldown/binding-openharmony-arm64": "1.2.6",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.6",
+ "@rolldown/binding-win32-x64-msvc": "1.2.6"
+ }
+ },
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
@@ -5226,6 +6116,13 @@
"node": ">=8"
}
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -5368,6 +6265,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/spawn-error-forwarder": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/spawn-error-forwarder/-/spawn-error-forwarder-1.0.0.tgz",
@@ -5421,6 +6328,20 @@
"through2": "~2.0.0"
}
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/stream-combiner2": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz",
@@ -5679,6 +6600,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
+ "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -5727,6 +6665,16 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/tinyrainbow": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
+ "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -5930,6 +6878,200 @@
"spdx-expression-parse": "^3.0.0"
}
},
+ "node_modules/vite": {
+ "version": "8.2.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
+ "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.26",
+ "rolldown": "~1.2.4",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.4.0 || ^0.5.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
+ "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.11",
+ "@vitest/mocker": "4.1.11",
+ "@vitest/pretty-format": "4.1.11",
+ "@vitest/runner": "4.1.11",
+ "@vitest/snapshot": "4.1.11",
+ "@vitest/spy": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.11",
+ "@vitest/browser-preview": "4.1.11",
+ "@vitest/browser-webdriverio": "4.1.11",
+ "@vitest/coverage-istanbul": "4.1.11",
+ "@vitest/coverage-v8": "4.1.11",
+ "@vitest/ui": "4.1.11",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/web-worker": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
@@ -5968,6 +7110,23 @@
"url": "https://github.com/sindresorhus/which-command?sponsor=1"
}
},
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/wordwrap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
diff --git a/package.json b/package.json
index 3d3ad2d..c18293f 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
],
"scripts": {
"typecheck": "tsc --noEmit",
+ "test": "vitest run",
"build": "npm run typecheck",
"prepublishOnly": "npm run build"
},
@@ -39,14 +40,14 @@
"provenance": true
},
"dependencies": {
- "@opencode-ai/plugin": "^1.14.0",
- "@opencode-ai/sdk": "^1.2.0"
+ "@opencode-ai/plugin": "^1.14.0"
},
"devDependencies": {
"@semantic-release/changelog": "^7.0.0",
"@semantic-release/git": "^11.0.1",
"@types/node": "^25.0.3",
"semantic-release": "^25.0.8",
- "typescript": "^5.9.3"
+ "typescript": "^5.9.3",
+ "vitest": "^4.1.11"
}
}
diff --git a/src/plugin/build-model.ts b/src/plugin/build-model.ts
deleted file mode 100644
index 4e4a17f..0000000
--- a/src/plugin/build-model.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import type { Model as ModelV2 } from '@opencode-ai/sdk/v2'
-import type { LiteLLMModel, LiteLLMModelInfo } from '../types'
-import {
- categorizeModel,
- formatModelName,
-} from '../utils/format-model-name'
-
-/**
- * Build an OpenCode V2 `Model` entry from a discovered LiteLLM model.
- *
- * The V2 schema requires a lot of fields we have no real data for
- * (`cost`, `limit`, `release_date`, …). We fill these with sensible
- * defaults — zero cost / zero limits / today's date — so the entry
- * type-checks and the picker renders something useful. Real values
- * can be added in a future release if LiteLLM exposes them via
- * `/v1/model/info`, which carries `max_tokens`, `input_cost_per_token`,
- * etc.
- */
-export function buildModelV2(
- providerID: string,
- api: { id: string; url: string; npm: string },
- model: LiteLLMModel,
- info?: LiteLLMModelInfo,
-): ModelV2 {
- const type = categorizeModel(model)
- const isVision = !!model.supports_vision
- const isAudio = type === 'audio'
- const isImageOut = type === 'image'
-
- return {
- id: model.id,
- providerID,
- api,
- name: formatModelName(model),
- capabilities: {
- temperature: true,
- reasoning: !!model.supports_reasoning,
- attachment: isVision || isAudio,
- toolcall: !!model.supports_function_calling,
- input: {
- text: true,
- audio: isAudio,
- image: isVision,
- video: false,
- pdf: !!model.supports_pdf_input,
- },
- output: {
- text: !isImageOut,
- audio: false,
- image: isImageOut,
- video: false,
- pdf: false,
- },
- interleaved: false,
- },
- cost: {
- input: info?.input_cost_per_token ?? 0,
- output: info?.output_cost_per_token ?? 0,
- cache: {
- read: info?.cache_read_input_token_cost ?? 0,
- write: info?.cache_creation_input_token_cost ?? 0,
- },
- },
- limit: {
- context: model.max_input_tokens ?? 0,
- input: model.max_input_tokens,
- output: model.max_output_tokens ?? 0,
- },
- status: 'active',
- options: {},
- headers: {},
- release_date: '',
- variants: info?.supports_reasoning_efforts?.length
- ? Object.fromEntries(
- info.supports_reasoning_efforts.map((effort) => [
- effort,
- { reasoningEffort: effort },
- ]),
- )
- : undefined,
- }
-}
diff --git a/src/plugin/discover.ts b/src/plugin/discover.ts
deleted file mode 100644
index 4978902..0000000
--- a/src/plugin/discover.ts
+++ /dev/null
@@ -1,204 +0,0 @@
-import type { Model as ModelV2, Provider as ProviderV2 } from '@opencode-ai/sdk/v2'
-import {
- autoDetectLiteLLM,
- checkLiteLLMHealth,
- discoverLiteLLMModelInfo,
- discoverLiteLLMModels,
- normalizeBaseURL,
-} from '../utils/litellm-api'
-import { requiresResponsesAPI } from '../utils/format-model-name'
-import type { LiteLLMModel, LiteLLMModelInfo, Transport, TransportPolicy } from '../types'
-import { buildModelV2 } from './build-model'
-
-const DISCOVERY_TIMEOUT_MS = 5000
-
-/**
- * Decide which transport bucket a model belongs to. Order of
- * precedence (highest first):
- *
- * 1. Explicit allowlist `responsesApiModels` → 'responses'
- * 2. Explicit denylist `chatApiModels` → 'chat'
- * 3. Global policy `transport: 'chat' | 'responses'`
- * 4. Heuristic via {@link requiresResponsesAPI} → 'responses' or 'chat'
- */
-function pickTransport(
- model: LiteLLMModel,
- policy: TransportPolicy,
- responsesApiModels: ReadonlySet,
- chatApiModels: ReadonlySet,
-): Transport {
- if (responsesApiModels.has(model.id)) return 'responses'
- if (chatApiModels.has(model.id)) return 'chat'
- if (policy === 'chat') return 'chat'
- if (policy === 'responses') return 'responses'
- return requiresResponsesAPI(model) ? 'responses' : 'chat'
-}
-
-/**
- * Resolve the LiteLLM `baseURL` and `apiKey` to use for discovery.
- *
- * Looks at the configured provider options first (so the user's
- * `opencode.json` wins), then falls back to env vars, and finally to
- * auto-detecting a local proxy on the common ports.
- */
-/**
- * Extract the `customHeaders` map from the provider options block.
- * Returns `undefined` when no custom headers are configured.
- */
-function readCustomHeaders(
- provider: ProviderV2 | undefined,
-): Record | undefined {
- const options = (provider?.options ?? {}) as Record
- const raw = options.customHeaders
- if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
- const out: Record = {}
- for (const [k, v] of Object.entries(raw as Record)) {
- if (typeof v === 'string') out[k] = v
- }
- return Object.keys(out).length > 0 ? out : undefined
- }
- return undefined
-}
-
-async function resolveEndpoint(
- provider: ProviderV2 | undefined,
-): Promise<{ baseURL: string; apiKey?: string; customHeaders?: Record } | null> {
- const options = (provider?.options ?? {}) as Record
- const configuredBase = typeof options.baseURL === 'string' ? options.baseURL : undefined
- const configuredKey = typeof options.apiKey === 'string' && options.apiKey ? options.apiKey : undefined
- const envKey = process.env.LITELLM_API_KEY ?? process.env.LITELLM_MASTER_KEY
- const customHeaders = readCustomHeaders(provider)
-
- if (configuredBase) {
- return { baseURL: normalizeBaseURL(configuredBase), apiKey: configuredKey ?? envKey, customHeaders }
- }
-
- const detected = await autoDetectLiteLLM(configuredKey ?? envKey, customHeaders)
- if (!detected) return null
- return { baseURL: normalizeBaseURL(detected), apiKey: configuredKey ?? envKey, customHeaders }
-}
-
-/**
- * Read the routing policy and per-model overrides off the provider's
- * `options` block. Defaults to `'auto'` with empty allow/deny lists.
- */
-function readRoutingOptions(
- provider: ProviderV2 | undefined,
-): {
- policy: TransportPolicy
- responsesApiModels: Set
- chatApiModels: Set
-} {
- const options = (provider?.options ?? {}) as Record
- const policy =
- typeof options.transport === 'string' &&
- (options.transport === 'auto' || options.transport === 'chat' || options.transport === 'responses')
- ? (options.transport as TransportPolicy)
- : 'auto'
- const responses = Array.isArray(options.responsesApiModels)
- ? options.responsesApiModels.filter((v): v is string => typeof v === 'string')
- : []
- const chat = Array.isArray(options.chatApiModels)
- ? options.chatApiModels.filter((v): v is string => typeof v === 'string')
- : []
- return {
- policy,
- responsesApiModels: new Set(responses),
- chatApiModels: new Set(chat),
- }
-}
-
-/**
- * Discover all models from the LiteLLM proxy and bucket them by the
- * transport (`chat` vs `responses`) they should use. Returns a map of
- * model id → V2 `Model` for the requested bucket only.
- *
- * Pass `bucket: 'all'` to ignore the routing heuristic and return
- * every discovered model. Useful for the default chat-only setup
- * where the user hasn't declared a sibling `litellm-responses`
- * provider — without `'all'`, gpt-5* / o-series models would be
- * silently dropped.
- *
- * Capped at {@link DISCOVERY_TIMEOUT_MS} so a slow / unreachable
- * proxy never stalls OpenCode startup.
- */
-export async function discoverBucket(
- bucket: Transport | 'all',
- provider: ProviderV2 | undefined,
- api: { id: string; url: string; npm: string },
-): Promise> {
- const out: Record = {}
-
- const work = async () => {
- const endpoint = await resolveEndpoint(provider)
- if (!endpoint) return
-
- const { baseURL, apiKey, customHeaders } = endpoint
- if (!(await checkLiteLLMHealth(baseURL, apiKey, customHeaders))) {
- console.warn(`[opencode-litellm] LiteLLM appears offline or unauthorized at ${baseURL}`)
- return
- }
-
- const [modelsResult, infoResult] = await Promise.allSettled([
- discoverLiteLLMModels(baseURL, apiKey, customHeaders),
- discoverLiteLLMModelInfo(baseURL, apiKey, customHeaders),
- ])
-
- if (modelsResult.status === 'rejected') {
- console.warn(
- '[opencode-litellm] Model discovery failed:',
- modelsResult.reason instanceof Error ? modelsResult.reason.message : String(modelsResult.reason),
- )
- return
- }
- const models = modelsResult.value
-
- let infoByName: Map | null = null
- if (infoResult.status === 'fulfilled') {
- infoByName = infoResult.value
- } else {
- console.warn(
- '[opencode-litellm] /v1/model/info unavailable; model costs will be zero:',
- infoResult.reason instanceof Error ? infoResult.reason.message : String(infoResult.reason),
- )
- }
-
- if (models.length === 0) {
- console.warn(
- '[opencode-litellm] LiteLLM responded but exposed zero models. Check your `model_list` in litellm config.yaml',
- )
- return
- }
-
- const resolvedApi = { ...api, url: `${baseURL}/v1` }
-
- const routing = readRoutingOptions(provider)
- for (const model of models) {
- if (bucket !== 'all') {
- const transport = pickTransport(
- model,
- routing.policy,
- routing.responsesApiModels,
- routing.chatApiModels,
- )
- if (transport !== bucket) continue
- }
- // OpenCode's @ai-sdk/openai-compatible adapter uses `api.id` as
- // the wire model name sent to the upstream LiteLLM endpoint
- // (verified empirically — without this override the wire request
- // sends the provider id "litellm" instead, and LiteLLM rejects
- // it with "team not allowed"). Set `api.id` per-model so each
- // entry carries the correct upstream model name.
- const perModelApi = { ...resolvedApi, id: model.id }
- const info = infoByName?.get(model.id) ?? {}
- out[model.id] = buildModelV2(resolvedApi.id, perModelApi, model, info)
- }
- }
-
- await Promise.race([
- work(),
- new Promise((resolve) => setTimeout(resolve, DISCOVERY_TIMEOUT_MS)),
- ])
-
- return out
-}
diff --git a/src/plugin/index.ts b/src/plugin/index.ts
index 383ef26..ecc8636 100644
--- a/src/plugin/index.ts
+++ b/src/plugin/index.ts
@@ -25,24 +25,27 @@ const REFRESH_MIN_INTERVAL_MS = 5 * 60 * 1000 // 5 minutes
/**
* OpenCode invokes the `config` hook several times per run with a
* cumulative config object. Track which model ids we already injected
- * per baseURL so repeat invocations can return early instead of
- * re-querying the proxy.
+ * per provider (keyed by `providerId@baseURL`) so repeat invocations
+ * can return early instead of re-querying the proxy.
*/
const injectedModelIds = new Map>()
/**
- * Per-baseURL fetch context captured during the `config` hook, so the
+ * Per-provider fetch context captured during the `config` hook, so the
* `event` hook can revalidate the cache in the background (SWR) without
- * re-deriving auth/headers.
+ * re-deriving auth/headers. Keyed by `providerId@baseURL` — the same
+ * key as the on-disk cache — so providers sharing a proxy keep
+ * independent refresh contexts.
*/
interface RefreshContext {
+ baseURL: string
apiKey?: string
customHeaders?: Record
providerId: string
}
const refreshContexts = new Map()
-/** baseURLs with an in-flight background refresh, to avoid pile-ups. */
+/** Cache keys with an in-flight background refresh, to avoid pile-ups. */
const refreshInFlight = new Set()
/**
@@ -115,6 +118,9 @@ function enrichModel(model: LiteLLMModel, info: LiteLLMModelInfo): LiteLLMModel
supports_audio_input: model.supports_audio_input ?? info.supports_audio_input,
input_cost_per_token: model.input_cost_per_token ?? info.input_cost_per_token,
output_cost_per_token: model.output_cost_per_token ?? info.output_cost_per_token,
+ cache_read_input_token_cost: model.cache_read_input_token_cost ?? info.cache_read_input_token_cost,
+ cache_creation_input_token_cost:
+ model.cache_creation_input_token_cost ?? info.cache_creation_input_token_cost,
}
}
@@ -144,9 +150,12 @@ function toConfigModel(
const entry: Record = {
name: formatModelName(model),
}
- if (model.max_input_tokens || model.max_output_tokens) {
+ // Some deployments only report the OpenAI-style `max_tokens` total;
+ // use it as the context limit when `max_input_tokens` is absent.
+ const contextLimit = model.max_input_tokens ?? model.max_tokens
+ if (contextLimit || model.max_output_tokens) {
entry.limit = {
- context: model.max_input_tokens ?? 0,
+ context: contextLimit ?? 0,
output: model.max_output_tokens ?? 0,
}
}
@@ -164,10 +173,17 @@ function toConfigModel(
// to their own default instead of us asserting "this model is free"
// for something LiteLLM simply has no price anchor for (e.g. rerank).
if (model.input_cost_per_token != null || model.output_cost_per_token != null) {
- entry.cost = {
+ const cost: Record = {
input: (model.input_cost_per_token ?? 0) * USD_PER_TOKEN_TO_PER_MILLION,
output: (model.output_cost_per_token ?? 0) * USD_PER_TOKEN_TO_PER_MILLION,
}
+ if (model.cache_read_input_token_cost != null) {
+ cost.cache_read = model.cache_read_input_token_cost * USD_PER_TOKEN_TO_PER_MILLION
+ }
+ if (model.cache_creation_input_token_cost != null) {
+ cost.cache_write = model.cache_creation_input_token_cost * USD_PER_TOKEN_TO_PER_MILLION
+ }
+ entry.cost = cost
}
const input: Array<'text' | 'image' | 'pdf' | 'audio'> = ['text']
if (model.supports_vision) input.push('image')
@@ -309,37 +325,37 @@ function mergeModels(
}
/**
- * Revalidate a baseURL's model cache off the critical path (SWR). The
+ * Revalidate a provider's model cache off the critical path (SWR). The
* refreshed entries land in the on-disk cache and surface on the next
* OpenCode start — OpenCode only reads provider config at startup, so
* we can't mutate the live picker here.
*/
-async function backgroundRefresh(baseURL: string): Promise {
- if (refreshInFlight.has(baseURL)) return
- const ctx = refreshContexts.get(baseURL)
+async function backgroundRefresh(cacheKey: string): Promise {
+ if (refreshInFlight.has(cacheKey)) return
+ const ctx = refreshContexts.get(cacheKey)
if (!ctx) return
// Skip if the cache was refreshed recently — a burst of new sessions
// shouldn't hammer the proxy with health checks and discovery calls.
- const savedAt = readModelCacheSavedAt(baseURL)
+ const savedAt = readModelCacheSavedAt(cacheKey)
if (savedAt !== null && Date.now() - savedAt < REFRESH_MIN_INTERVAL_MS) {
return
}
- refreshInFlight.add(baseURL)
+ refreshInFlight.add(cacheKey)
try {
const built = await withTimeout(
- discoverModels(baseURL, ctx.apiKey, ctx.customHeaders, ctx.providerId),
+ discoverModels(ctx.baseURL, ctx.apiKey, ctx.customHeaders, ctx.providerId),
DISCOVERY_TIMEOUT_MS,
)
if (built && Object.keys(built).length > 0) {
- writeModelCache(baseURL, built)
+ writeModelCache(cacheKey, built)
console.log(
- `[opencode-litellm] Background-refreshed model cache for ${baseURL} (${Object.keys(built).length} models)`,
+ `[opencode-litellm] Background-refreshed model cache for ${ctx.baseURL} (${Object.keys(built).length} models)`,
)
}
} catch {
// Best-effort — a failed refresh just leaves the stale cache in place.
} finally {
- refreshInFlight.delete(baseURL)
+ refreshInFlight.delete(cacheKey)
}
}
@@ -479,13 +495,15 @@ export const LiteLLMPlugin: Plugin = async (_input: PluginInput) => {
const models = actualProvider.models as Record
+ const cacheKey = `${providerId}@${baseURL}`
+
// Remember how to reach this proxy so the `event` hook can
// revalidate its cache in the background on new sessions.
- refreshContexts.set(baseURL, { apiKey, customHeaders, providerId })
+ refreshContexts.set(cacheKey, { baseURL, apiKey, customHeaders, providerId })
// Repeat config-hook invocations within a run are a no-op once
- // we've injected this baseURL's models.
- const alreadyInjected = injectedModelIds.get(baseURL)
+ // we've injected this provider's models.
+ const alreadyInjected = injectedModelIds.get(cacheKey)
if (
alreadyInjected &&
[...alreadyInjected].every((id) => Object.hasOwn(models, id))
@@ -496,10 +514,12 @@ export const LiteLLMPlugin: Plugin = async (_input: PluginInput) => {
// SWR fast path: serve cached entries synchronously so startup
// isn't blocked on the network. A background refresh (see the
// `event` hook) keeps the cache fresh for the next launch.
- const cached = readModelCache(baseURL)
+ // The cache is scoped per provider so two providers pointing at
+ // the same proxy (with different keys) don't share model lists.
+ const cached = readModelCache(cacheKey)
if (cached && Object.keys(cached).length > 0) {
const added = mergeModels(models, cached)
- injectedModelIds.set(baseURL, new Set(added))
+ injectedModelIds.set(cacheKey, new Set(added))
console.log(
`[opencode-litellm] Loaded ${Object.keys(cached).length} models from cache for provider "${providerId}" (${baseURL}); refresh happens in the background on new sessions.`,
)
@@ -515,8 +535,8 @@ export const LiteLLMPlugin: Plugin = async (_input: PluginInput) => {
)
if (built && Object.keys(built).length > 0) {
const added = mergeModels(models, built)
- injectedModelIds.set(baseURL, new Set(added))
- writeModelCache(baseURL, built)
+ injectedModelIds.set(cacheKey, new Set(added))
+ writeModelCache(cacheKey, built)
}
}
},
@@ -525,8 +545,8 @@ export const LiteLLMPlugin: Plugin = async (_input: PluginInput) => {
// opens. Fresh data lands in the cache and surfaces on the next
// OpenCode start (SWR).
if (event.type !== 'session.created') return
- for (const baseURL of refreshContexts.keys()) {
- void backgroundRefresh(baseURL)
+ for (const cacheKey of refreshContexts.keys()) {
+ void backgroundRefresh(cacheKey)
}
},
}
diff --git a/src/types/index.ts b/src/types/index.ts
index c736ad4..e093d26 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -40,6 +40,8 @@ export interface LiteLLMModel {
*/
input_cost_per_token?: number
output_cost_per_token?: number
+ cache_read_input_token_cost?: number
+ cache_creation_input_token_cost?: number
}
export interface LiteLLMModelsResponse {
@@ -83,66 +85,3 @@ export interface LiteLLMModelInfoEntry {
export interface LiteLLMModelInfoResponse {
data?: LiteLLMModelInfoEntry[]
}
-
-export type ModelType = 'chat' | 'embedding' | 'image' | 'audio' | 'unknown'
-
-/**
- * Which OpenAI-compatible HTTP surface a model should be invoked through.
- *
- * - `chat` → `/v1/chat/completions` (most models)
- * - `responses` → `/v1/responses` (gpt-5*, o-series with reasoning)
- */
-export type Transport = 'chat' | 'responses'
-
-/**
- * User-facing routing override. Defaults to `'auto'`.
- *
- * - `'auto'` → use the heuristic + LiteLLM `mode` field
- * - `'chat'` → force every discovered model into the chat-completions provider
- * - `'responses'` → force every discovered model into the responses provider
- */
-export type TransportPolicy = 'auto' | Transport
-
-export interface LiteLLMOptions {
- baseURL?: string
- apiKey?: string
- /**
- * Routing policy for discovered models. See {@link TransportPolicy}.
- * Defaults to `'auto'`.
- */
- transport?: TransportPolicy
- /**
- * Explicit allowlist of model ids that MUST be routed through the
- * OpenAI Responses API (`/v1/responses`). Takes priority over the
- * heuristic and over the `transport` policy.
- *
- * Match is exact against the LiteLLM model id (e.g. `"gpt-5-4-high"`).
- */
- responsesApiModels?: string[]
- /**
- * Explicit denylist of model ids that MUST be routed through chat
- * completions (`/v1/chat/completions`), even if the heuristic would
- * otherwise put them in the responses bucket. Takes priority over
- * the heuristic but is overridden by `responsesApiModels`.
- */
- chatApiModels?: string[]
- /**
- * Arbitrary HTTP headers to include in every request to the LiteLLM
- * proxy during model discovery (health check + `/v1/models`).
- *
- * Useful for proxies behind Cloudflare Access or other gateways that
- * require extra authentication headers beyond the standard
- * `Authorization: Bearer` token.
- *
- * Example (Cloudflare Access Service Token):
- * ```json
- * {
- * "customHeaders": {
- * "CF-Access-Client-Id": "{env:CF_ACCESS_CLIENT_ID}",
- * "CF-Access-Client-Secret": "{env:CF_ACCESS_CLIENT_SECRET}"
- * }
- * }
- * ```
- */
- customHeaders?: Record
-}
diff --git a/src/utils/format-model-name.ts b/src/utils/format-model-name.ts
index a39adfc..359823e 100644
--- a/src/utils/format-model-name.ts
+++ b/src/utils/format-model-name.ts
@@ -1,19 +1,5 @@
import type { LiteLLMModel } from '../types'
-/**
- * Extract owner / provider from a LiteLLM model ID.
- *
- * LiteLLM model IDs commonly take the shape `/` (e.g.
- * `openai/gpt-4o`, `anthropic/claude-3-5-sonnet`, `bedrock/...`). When
- * available we prefer the `litellm_provider` field returned by the API.
- */
-export function extractModelOwner(model: LiteLLMModel): string | undefined {
- if (model.litellm_provider) return model.litellm_provider
- const parts = model.id.split('/')
- if (parts.length > 1) return parts[0]
- return undefined
-}
-
/**
* Strip Vertex-style version pinning suffixes that have no place in a
* display name. LiteLLM exposes Anthropic-on-Vertex models with ids
@@ -46,22 +32,32 @@ function looksLikeVersionComponent(token: string): boolean {
* "Claude Opus 4 5".
*
* Conservative on purpose:
- * - Only the LAST adjacent pair is collapsed, so `gpt-3-5-turbo-16k`
- * keeps `[3, 5, turbo, 16k]` (`turbo` between blocks the merge).
- * - Both tokens must look like real version components (1–2 digits) —
- * this avoids merging date stamps in ids like
- * `claude-opus-4-5-20251101`, where the trailing `20251101` is a
- * YYYYMMDD revision and must stay separate.
+ * - Only the FIRST mergeable pair scanning from the right is collapsed:
+ * `gpt-3-5-turbo-16k` merges `3-5` into `3.5` and stops there.
+ * - Both tokens must look like real version components (1–2 digits),
+ * so a `YYYYMMDD` revision stamp like `20251101` is never merged
+ * into a version number itself.
* - We refuse to collapse if the token immediately AFTER the pair is
- * also numeric (would create ambiguity in `1-2-3` runs).
+ * also a short number, or if the token immediately BEFORE it is one
+ * too — both signal an ambiguous numeric run (`1-2-3`) rather than
+ * a version pair. A trailing 6–8 digit `YYYYMMDD` revision stamp
+ * does not block the merge — it stays its own token, so
+ * `claude-opus-4-5-20251101` renders "Claude Opus 4.5 20251101",
+ * not "Claude Opus 4 5 20251101".
*/
function joinTrailingVersionPair(tokens: string[]): string[] {
for (let i = tokens.length - 1; i >= 1; i--) {
const a = tokens[i - 1]
const b = tokens[i]
if (looksLikeVersionComponent(a) && looksLikeVersionComponent(b)) {
+ const prev = tokens[i - 2]
const next = tokens[i + 1]
- if (next === undefined || !/^\d+$/.test(next)) {
+ const prevIsShortNumeric = prev !== undefined && looksLikeVersionComponent(prev)
+ const nextIsDateStamp = next !== undefined && /^\d{6,8}$/.test(next)
+ if (
+ !prevIsShortNumeric &&
+ (next === undefined || nextIsDateStamp || !/^\d+$/.test(next))
+ ) {
const merged = [...tokens.slice(0, i - 1), `${a}.${b}`, ...tokens.slice(i + 1)]
return merged
}
@@ -159,32 +155,3 @@ export function formatModelName(model: LiteLLMModel): string {
return tokens.join(' ') || id
}
-
-/**
- * Decide whether a model must be invoked through the OpenAI Responses
- * API (`/v1/responses`) instead of `/v1/chat/completions`.
- *
- * OpenAI's reasoning-tier models (gpt-5*, o1/o3/o4*) reject requests
- * that combine `reasoning_effort` with function tools when sent to
- * `/v1/chat/completions`. The Responses API has no such restriction,
- * so we route those models there.
- *
- * Detection rules (first match wins):
- * 1. `model.mode === 'responses'` (LiteLLM exposes this on newer versions)
- * 2. The model id (after any `provider/` prefix) matches the
- * `gpt-5*` / `o[134]*` family.
- */
-export function requiresResponsesAPI(model: LiteLLMModel): boolean {
- if (model.mode && model.mode.toLowerCase() === 'responses') return true
-
- const id = model.id.toLowerCase()
- const slashIdx = id.indexOf('/')
- const tail = slashIdx >= 0 ? id.slice(slashIdx + 1) : id
-
- // gpt-5, gpt-5.4, gpt-5-4-high, gpt-5o, ...
- if (/^gpt-?5(?:[-.].*)?$/.test(tail)) return true
- // o1, o1-mini, o3, o3-mini, o4-mini, o4, ... but NOT "openai", "ollama"
- if (/^o[134](?:[-.].*)?$/.test(tail)) return true
-
- return false
-}
diff --git a/src/utils/model-cache.ts b/src/utils/model-cache.ts
index b5a76dc..04853dc 100644
--- a/src/utils/model-cache.ts
+++ b/src/utils/model-cache.ts
@@ -37,23 +37,27 @@ function cacheDir(): string {
return join(base, 'opencode-litellm')
}
-/** Stable, filesystem-safe filename derived from the normalized baseURL. */
-function cacheFile(baseURL: string): string {
- const hash = createHash('sha256').update(baseURL).digest('hex').slice(0, 16)
+/**
+ * Stable, filesystem-safe filename derived from the cache key. Callers
+ * pass `providerId@baseURL` so two providers pointing at the same proxy
+ * (possibly with different API keys) keep separate caches.
+ */
+function cacheFile(cacheKey: string): string {
+ const hash = createHash('sha256').update(cacheKey).digest('hex').slice(0, 16)
return join(cacheDir(), `models-${hash}.json`)
}
/**
- * Read cached model entries for a baseURL. Returns `null` on any
+ * Read cached model entries for a cache key. Returns `null` on any
* problem (missing file, parse error, version mismatch, or an entry
* older than `CACHE_MAX_AGE_MS`) — the caller treats that as a cache
* miss and falls back to a live fetch.
*/
export function readModelCache(
- baseURL: string,
+ cacheKey: string,
): Record | null {
try {
- const raw = readFileSync(cacheFile(baseURL), 'utf8')
+ const raw = readFileSync(cacheFile(cacheKey), 'utf8')
const parsed = JSON.parse(raw) as ModelCacheFile
if (parsed.version !== CACHE_VERSION) return null
if (!parsed.models || typeof parsed.models !== 'object') return null
@@ -70,13 +74,13 @@ export function readModelCache(
}
/**
- * Return the `savedAt` timestamp (epoch ms) of a baseURL's cache entry,
- * or `null` if there's no readable/valid cache. Used to throttle
+ * Return the `savedAt` timestamp (epoch ms) of a cache entry, or
+ * `null` if there's no readable/valid cache. Used to throttle
* background refreshes so still-fresh caches aren't re-fetched.
*/
-export function readModelCacheSavedAt(baseURL: string): number | null {
+export function readModelCacheSavedAt(cacheKey: string): number | null {
try {
- const raw = readFileSync(cacheFile(baseURL), 'utf8')
+ const raw = readFileSync(cacheFile(cacheKey), 'utf8')
const parsed = JSON.parse(raw) as ModelCacheFile
if (parsed.version !== CACHE_VERSION) return null
if (typeof parsed.savedAt !== 'number') return null
@@ -87,11 +91,11 @@ export function readModelCacheSavedAt(baseURL: string): number | null {
}
/**
- * Persist model entries for a baseURL. Best-effort: never throws, so a
- * read-only or full filesystem can't break discovery.
+ * Persist model entries for a cache key. Best-effort: never throws, so
+ * a read-only or full filesystem can't break discovery.
*/
export function writeModelCache(
- baseURL: string,
+ cacheKey: string,
models: Record,
): void {
try {
@@ -104,7 +108,7 @@ export function writeModelCache(
// Write to a temp file and rename so concurrent OpenCode processes
// never observe a partially-written JSON file (rename is atomic on
// the same filesystem).
- const target = cacheFile(baseURL)
+ const target = cacheFile(cacheKey)
const tmp = `${target}.${process.pid}.tmp`
try {
writeFileSync(tmp, JSON.stringify(payload), 'utf8')
diff --git a/test/format-model-name.test.ts b/test/format-model-name.test.ts
new file mode 100644
index 0000000..0beb61a
--- /dev/null
+++ b/test/format-model-name.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest'
+import { categorizeModel, formatModelName } from '../src/utils/format-model-name'
+import type { LiteLLMModel } from '../src/types'
+
+function model(id: string, extra: Partial = {}): LiteLLMModel {
+ return { id, object: 'model', ...extra }
+}
+
+describe('formatModelName', () => {
+ const cases: Array<[string, string]> = [
+ ['openai/gpt-4o-mini', 'GPT 4o Mini'],
+ ['anthropic/claude-3-5-sonnet', 'Claude 3.5 Sonnet'],
+ ['claude-opus-4-5', 'Claude Opus 4.5'],
+ ['claude-opus-4-5@20251101', 'Claude Opus 4.5'],
+ ['claude-sonnet-4-6@default', 'Claude Sonnet 4.6'],
+ ['bedrock/amazon.nova-pro-v1', 'Amazon Nova Pro V1'],
+ ['qwen/qwen3-30b-a3b', 'Qwen3 30B A3B'],
+ ]
+
+ it.each(cases)('formats %s as %s', (id, expected) => {
+ expect(formatModelName(model(id))).toBe(expected)
+ })
+
+ it('merges a version pair followed by a dash-separated date stamp', () => {
+ expect(formatModelName(model('claude-opus-4-5-20251101'))).toBe(
+ 'Claude Opus 4.5 20251101',
+ )
+ })
+
+ it('does not merge version pairs inside longer numeric runs', () => {
+ // `turbo` separates the pair; trailing `16k` is a size token.
+ expect(formatModelName(model('gpt-3-5-turbo-16k'))).toBe('GPT 3.5 Turbo 16K')
+ // A short number before the pair also blocks the merge.
+ expect(formatModelName(model('model-1-2-3'))).toBe('Model 1 2 3')
+ })
+
+ it('keeps a provider-prefixed vendor name in the display', () => {
+ expect(formatModelName(model('bedrock/amazon.nova-pro-v1'))).toContain('Amazon')
+ })
+
+ it('falls back to the raw id when tokenisation yields nothing', () => {
+ expect(formatModelName(model('---'))).toBe('---')
+ })
+})
+
+describe('categorizeModel', () => {
+ it('uses the mode field when present', () => {
+ expect(categorizeModel(model('text-embedding-3-small', { mode: 'embedding' }))).toBe('embedding')
+ expect(categorizeModel(model('dall-e-3', { mode: 'image_generation' }))).toBe('image')
+ expect(categorizeModel(model('whisper-1', { mode: 'audio_transcription' }))).toBe('audio')
+ expect(categorizeModel(model('tts-1', { mode: 'audio_speech' }))).toBe('audio')
+ expect(categorizeModel(model('gpt-4o', { mode: 'chat' }))).toBe('chat')
+ })
+
+ it('falls back to id heuristics without a mode', () => {
+ expect(categorizeModel(model('mistral/mistral-embed'))).toBe('embedding')
+ expect(categorizeModel(model('flux-dev'))).toBe('image')
+ expect(categorizeModel(model('whisper-large-v3'))).toBe('audio')
+ expect(categorizeModel(model('some-unknown-model'))).toBe('chat')
+ })
+})
diff --git a/test/litellm-api.test.ts b/test/litellm-api.test.ts
new file mode 100644
index 0000000..8d09d0e
--- /dev/null
+++ b/test/litellm-api.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest'
+import { buildAPIURL, normalizeBaseURL } from '../src/utils/litellm-api'
+
+describe('normalizeBaseURL', () => {
+ it('strips trailing slashes', () => {
+ expect(normalizeBaseURL('http://localhost:4000/')).toBe('http://localhost:4000')
+ expect(normalizeBaseURL('http://localhost:4000///')).toBe('http://localhost:4000')
+ })
+
+ it('strips a /v1 suffix so the plugin can re-append endpoint paths', () => {
+ expect(normalizeBaseURL('http://localhost:4000/v1')).toBe('http://localhost:4000')
+ expect(normalizeBaseURL('https://proxy.example.com/v1/')).toBe('https://proxy.example.com')
+ })
+
+ it('leaves other paths untouched', () => {
+ expect(normalizeBaseURL('https://proxy.example.com/api')).toBe('https://proxy.example.com/api')
+ })
+
+ it('defaults to localhost:4000', () => {
+ expect(normalizeBaseURL(undefined)).toBe('http://localhost:4000')
+ })
+})
+
+describe('buildAPIURL', () => {
+ it('appends /v1/models by default', () => {
+ expect(buildAPIURL('http://localhost:4000/v1')).toBe('http://localhost:4000/v1/models')
+ })
+
+ it('appends a custom endpoint', () => {
+ expect(buildAPIURL('http://localhost:4000/', '/v1/model/info')).toBe(
+ 'http://localhost:4000/v1/model/info',
+ )
+ })
+})
diff --git a/test/model-cache.test.ts b/test/model-cache.test.ts
new file mode 100644
index 0000000..1c958f5
--- /dev/null
+++ b/test/model-cache.test.ts
@@ -0,0 +1,85 @@
+import { createHash } from 'node:crypto'
+import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { readModelCache, readModelCacheSavedAt, writeModelCache } from '../src/utils/model-cache'
+
+const KEY = 'litellm@http://localhost:4000'
+
+let cacheHome: string
+const originalCacheHome = process.env.XDG_CACHE_HOME
+
+function cachePathFor(key: string): string {
+ const hash = createHash('sha256').update(key).digest('hex').slice(0, 16)
+ return join(cacheHome, 'opencode-litellm', `models-${hash}.json`)
+}
+
+function rewriteCacheFile(key: string, mutate: (parsed: Record) => void) {
+ const parsed = JSON.parse(readFileSync(cachePathFor(key), 'utf8'))
+ mutate(parsed)
+ writeFileSync(cachePathFor(key), JSON.stringify(parsed), 'utf8')
+}
+
+beforeAll(() => {
+ cacheHome = mkdtempSync(join(tmpdir(), 'opencode-litellm-test-'))
+ process.env.XDG_CACHE_HOME = cacheHome
+})
+
+afterAll(() => {
+ rmSync(cacheHome, { recursive: true, force: true })
+ if (originalCacheHome === undefined) {
+ delete process.env.XDG_CACHE_HOME
+ } else {
+ process.env.XDG_CACHE_HOME = originalCacheHome
+ }
+})
+
+describe('model cache', () => {
+ it('returns null when nothing was written', () => {
+ expect(readModelCache('missing@http://nowhere')).toBeNull()
+ expect(readModelCacheSavedAt('missing@http://nowhere')).toBeNull()
+ })
+
+ it('round-trips written models', () => {
+ const models = { 'openai/gpt-4o': { name: 'GPT 4o' } }
+ writeModelCache(KEY, models)
+ expect(readModelCache(KEY)).toEqual(models)
+ const savedAt = readModelCacheSavedAt(KEY)
+ expect(savedAt).not.toBeNull()
+ expect(Date.now() - (savedAt as number)).toBeLessThan(5000)
+ })
+
+ it('keeps caches for different keys separate', () => {
+ writeModelCache('a@http://x', { a: {} })
+ writeModelCache('b@http://x', { b: {} })
+ expect(Object.keys(readModelCache('a@http://x') ?? {})).toEqual(['a'])
+ expect(Object.keys(readModelCache('b@http://x') ?? {})).toEqual(['b'])
+ })
+
+ it('ignores cache files written by a different plugin version', () => {
+ writeModelCache(KEY, { m: {} })
+ rewriteCacheFile(KEY, (parsed) => {
+ parsed.version = 999
+ })
+ expect(readModelCache(KEY)).toBeNull()
+ writeModelCache(KEY, { m: {} })
+ })
+
+ it('treats entries older than the max age as a miss', () => {
+ writeModelCache(KEY, { m: {} })
+ const eightDaysAgo = Date.now() - 8 * 24 * 60 * 60 * 1000
+ rewriteCacheFile(KEY, (parsed) => {
+ parsed.savedAt = eightDaysAgo
+ })
+ expect(readModelCache(KEY)).toBeNull()
+ // The throttle helper still reports the raw timestamp.
+ expect(readModelCacheSavedAt(KEY)).toBe(eightDaysAgo)
+ writeModelCache(KEY, { m: {} })
+ })
+
+ it('never leaves temp files behind', () => {
+ const dir = join(cacheHome, 'opencode-litellm')
+ expect(readdirSync(dir).filter((f) => f.endsWith('.tmp'))).toEqual([])
+ })
+})
diff --git a/tsconfig.json b/tsconfig.json
index 8207a42..9791e0a 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -13,6 +13,6 @@
"declarationMap": true,
"sourceMap": true
},
- "include": ["src/**/*"],
+ "include": ["src/**/*", "test/**/*"],
"exclude": ["node_modules"]
}