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.** +How opencode-litellm works: OpenCode loads models through the config hook, the plugin discovers them from the LiteLLM proxy and caches them on disk for instant startup + [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 @@ + + + + + + + + + + + + + + + + + + OpenCode + model picker + + + + opencode-litellm + discover · merge · cache + config hook · SWR + + + + LiteLLM proxy + :4000 · :8000 · :8080 auto-detect + /v1/models · /v1/model/info + + + + config hook + + + discovery + + + + read / write + + + + + disk cache (SWR) + instant startup · refresh on new sessions + + + + chat completions + + + + upstream providers + OpenAI · Anthropic · Bedrock · Ollama · … + + + zero config · zero runtime deps · no restart loops + opencode-litellm + + 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"] }