From 8ffbb47624440d95c0008eaf83a307f20a3d4dbe Mon Sep 17 00:00:00 2001 From: "M.M" Date: Fri, 22 May 2026 04:16:42 +0200 Subject: [PATCH] feat(@omniroute/opencode-plugin): introducing the OmniRoute OpenCode plugin (live models, combos, Gemini sanitize, multi-instance) (#2529) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(@omniroute/opencode-plugin): introducing the OmniRoute OpenCode plugin — integrated into release/v3.8.2 --- .github/workflows/opencode-plugin-ci.yml | 62 + @omniroute/opencode-plugin/.gitignore | 4 + @omniroute/opencode-plugin/LICENSE | 21 + @omniroute/opencode-plugin/README.md | 218 ++ @omniroute/opencode-plugin/package-lock.json | 2414 +++++++++++++++++ @omniroute/opencode-plugin/package.json | 73 + @omniroute/opencode-plugin/src/index.ts | 2384 ++++++++++++++++ @omniroute/opencode-plugin/tests/auth.test.ts | 112 + .../opencode-plugin/tests/combos.test.ts | 641 +++++ .../opencode-plugin/tests/config-shim.test.ts | 715 +++++ .../opencode-plugin/tests/features.test.ts | 611 +++++ .../tests/fetch-interceptor.test.ts | 269 ++ .../tests/gemini-sanitize.test.ts | 410 +++ .../tests/multi-instance.test.ts | 136 + .../tests/options-schema.test.ts | 104 + .../opencode-plugin/tests/provider.test.ts | 269 ++ .../opencode-plugin/tests/scaffold.test.ts | 73 + @omniroute/opencode-plugin/tsconfig.json | 20 + @omniroute/opencode-plugin/tsup.config.ts | 20 + 19 files changed, 8556 insertions(+) create mode 100644 .github/workflows/opencode-plugin-ci.yml create mode 100644 @omniroute/opencode-plugin/.gitignore create mode 100644 @omniroute/opencode-plugin/LICENSE create mode 100644 @omniroute/opencode-plugin/README.md create mode 100644 @omniroute/opencode-plugin/package-lock.json create mode 100644 @omniroute/opencode-plugin/package.json create mode 100644 @omniroute/opencode-plugin/src/index.ts create mode 100644 @omniroute/opencode-plugin/tests/auth.test.ts create mode 100644 @omniroute/opencode-plugin/tests/combos.test.ts create mode 100644 @omniroute/opencode-plugin/tests/config-shim.test.ts create mode 100644 @omniroute/opencode-plugin/tests/features.test.ts create mode 100644 @omniroute/opencode-plugin/tests/fetch-interceptor.test.ts create mode 100644 @omniroute/opencode-plugin/tests/gemini-sanitize.test.ts create mode 100644 @omniroute/opencode-plugin/tests/multi-instance.test.ts create mode 100644 @omniroute/opencode-plugin/tests/options-schema.test.ts create mode 100644 @omniroute/opencode-plugin/tests/provider.test.ts create mode 100644 @omniroute/opencode-plugin/tests/scaffold.test.ts create mode 100644 @omniroute/opencode-plugin/tsconfig.json create mode 100644 @omniroute/opencode-plugin/tsup.config.ts diff --git a/.github/workflows/opencode-plugin-ci.yml b/.github/workflows/opencode-plugin-ci.yml new file mode 100644 index 0000000000..6c070d3faf --- /dev/null +++ b/.github/workflows/opencode-plugin-ci.yml @@ -0,0 +1,62 @@ +name: opencode-plugin CI + +on: + push: + branches: [main, release/v3.8.1, release/v3.8.2] + paths: + - "@omniroute/opencode-plugin/**" + pull_request: + branches: [main, release/v3.8.1, release/v3.8.2] + paths: + - "@omniroute/opencode-plugin/**" + types: [opened, synchronize, reopened, ready_for_review] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +defaults: + run: + working-directory: "@omniroute/opencode-plugin" + +jobs: + test: + name: Test (Node ${{ matrix.node }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ["22", "24"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + cache-dependency-path: "@omniroute/opencode-plugin/package-lock.json" + - run: npm install --no-audit --no-fund + - run: npm run build + - run: npm test + + build: + name: Build + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: "@omniroute/opencode-plugin/package-lock.json" + - run: npm install --no-audit --no-fund + - run: npm run build + - uses: actions/upload-artifact@v4 + with: + name: opencode-plugin-dist + path: "@omniroute/opencode-plugin/dist" + retention-days: 7 diff --git a/@omniroute/opencode-plugin/.gitignore b/@omniroute/opencode-plugin/.gitignore new file mode 100644 index 0000000000..7535211682 --- /dev/null +++ b/@omniroute/opencode-plugin/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +*.log +.DS_Store diff --git a/@omniroute/opencode-plugin/LICENSE b/@omniroute/opencode-plugin/LICENSE new file mode 100644 index 0000000000..e50b22c855 --- /dev/null +++ b/@omniroute/opencode-plugin/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OmniRoute contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/@omniroute/opencode-plugin/README.md b/@omniroute/opencode-plugin/README.md new file mode 100644 index 0000000000..a12b85973f --- /dev/null +++ b/@omniroute/opencode-plugin/README.md @@ -0,0 +1,218 @@ +# @omniroute/opencode-plugin + +First-class OpenCode plugin for the [OmniRoute AI Gateway](https://github.com/diegosouzapw/OmniRoute). Pulls a live model catalog from `/v1/models` (including `-low`/`-medium`/`-high`/`-thinking` variants as first-class IDs), aggregates combos via `/api/combos` using a least-common-denominator capability/limit join, sanitizes Gemini tool schemas in flight, and supports multiple side-by-side OmniRoute instances out of the box. + +## Install + +Once published to npm: + +```sh +npm install @omniroute/opencode-plugin +``` + +Until then (or for local development), reference the built artifact directly. Either extract the package into your OpenCode plugins dir and point at the extracted `dist/index.js`: + +```sh +# from inside the OmniRoute repo +cd @omniroute/opencode-plugin && npm run build && npm pack +# then extract into ~/.config/opencode/plugins/omniroute-opencode-plugin/ +``` + +Peer dep: `@opencode-ai/plugin` (managed by your OpenCode install). + +## Quick start (single instance) + +```jsonc +// opencode.json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + [ + "@omniroute/opencode-plugin", + { + "providerId": "omniroute", + "baseURL": "https://or.example.com", + }, + ], + ], +} +``` + +```sh +opencode auth login --provider omniroute +# prompts for the OmniRoute API key, writes to ~/.local/share/opencode/auth.json +``` + +> ⚠ Use the `--provider` flag explicitly. `opencode auth login omniroute` is parsed as a positional `url` argument by current OC releases (≤1.15.5) and fails with `fetch() URL is invalid`. Tracked upstream. + +Restart OpenCode. `/models` lists the full live catalog. Variants (`-low`, `-medium`, `-high`, `-thinking`) and combos appear as first-class IDs — OmniRoute is the source of truth, no client-side synthesis. + +## Multi-instance (prod + preprod side-by-side) + +> ⚠ OC ≤1.15.5 dedupes plugin loads by absolute module path. Two `plugin:` entries pointing at the same `dist/index.js` collapse into one (last-listed options win). Workaround: install the plugin twice into separate directories so each entry resolves to a distinct module file. v0.2.x will introduce an `instances: [...]` shape that registers N providers from a single load. + +### Dual-install workaround (works today on OC ≤1.15.5) + +Pack the plugin once, extract it twice into named directories, then point each `plugin:` entry at its own copy: + +```sh +# 1. Build + pack the plugin (run from the plugin worktree) +cd /path/to/OmniRoute/@omniroute/opencode-plugin +npm run build +npm pack +# produces omniroute-opencode-plugin-0.1.0.tgz + +# 2. Extract one copy per OmniRoute endpoint +mkdir -p ~/.config/opencode/plugins/omniroute-opencode-plugin-prod +mkdir -p ~/.config/opencode/plugins/omniroute-opencode-plugin-preprod +tar -xzf omniroute-opencode-plugin-0.1.0.tgz -C ~/.config/opencode/plugins/omniroute-opencode-plugin-prod --strip-components=1 +tar -xzf omniroute-opencode-plugin-0.1.0.tgz -C ~/.config/opencode/plugins/omniroute-opencode-plugin-preprod --strip-components=1 +``` + +Then in `~/.config/opencode/opencode.json` reference each directory by absolute path: + +```jsonc +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + [ + "./plugins/omniroute-opencode-plugin-prod/dist/index.js", + { + "providerId": "omniroute", + "displayName": "OmniRoute", + "baseURL": "https://or.example.com", + }, + ], + [ + "./plugins/omniroute-opencode-plugin-preprod/dist/index.js", + { + "providerId": "omniroute-preprod", + "displayName": "OmniRoute Preprod", + "baseURL": "https://or-preprod.example.com", + }, + ], + ], +} +``` + +Paths are relative to `~/.config/opencode/`. Each entry now resolves to a distinct module file, so OC loads them as two separate plugin instances. Authenticate each: + +```sh +opencode auth login --provider omniroute +opencode auth login --provider omniroute-preprod +``` + +Each entry gets its own provider id, its own model picker entry, its own slot in `auth.json`, and its own TTL cache. Closures are isolated per plugin instance — no cross-talk. + +### After publish (`@omniroute/opencode-plugin` npm) + +Once the package is published, the dual-install becomes two `npm install --prefix` commands instead of `tar -xzf`: + +```sh +mkdir -p ~/.config/opencode/plugins/omniroute-opencode-plugin-prod +mkdir -p ~/.config/opencode/plugins/omniroute-opencode-plugin-preprod +npm install --prefix ~/.config/opencode/plugins/omniroute-opencode-plugin-prod @omniroute/opencode-plugin +npm install --prefix ~/.config/opencode/plugins/omniroute-opencode-plugin-preprod @omniroute/opencode-plugin +``` + +`opencode.json` paths become `./plugins/omniroute-opencode-plugin-prod/node_modules/@omniroute/opencode-plugin/dist/index.js` (and the preprod equivalent). + +## Features + +| Feature | What it does | Hook | +| ------------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------ | +| Dynamic `/v1/models` | Pulls live catalog (455+ entries on prod) on each refresh, TTL-cached | `provider.models` | +| Variants pass-through | `-low`/`-medium`/`-high`/`-thinking` ship as first-class IDs from OmniRoute (no client synthesis) | `provider.models` | +| Combo LCD aggregation | Combos appear with intersected capabilities + min context/output across members | `provider.models` | +| Nice names | `combo.name` / `model.id` surfaces as `ModelV2.name` | `provider.models` | +| Bearer injection + suffix-spoof guard | Adds `Authorization` on baseURL-matched requests only | `auth.loader.fetch` | +| Gemini schema sanitization | Strips `$schema`/`$ref`/`additionalProperties` for `gemini-*`/`google-vertex-gemini/*` | `auth.loader.fetch` wrap | +| Multi-instance | Each plugin entry binds to its own `providerId`; closures isolated | factory | +| Config-hook shim | OC ≤1.14.48 fallback: writes static catalog into `config.provider[id]` | `config` | + +## Plugin options + +| Option | Type | Default | Description | +| --------------- | -------- | ------------------------------------------ | ---------------------------------------------------------- | +| `providerId` | `string` | `"omniroute"` | OpenCode provider id; must be unique across plugin entries | +| `displayName` | `string` | `"OmniRoute"` or `OmniRoute ()` | Label in the OC UI | +| `modelCacheTtl` | `number` | `300000` (5 min) | `/v1/models` TTL in ms | +| `baseURL` | `string` | resolved from `auth.json` after `/connect` | Override OmniRoute base URL | +| `features` | `object` | see below | Feature toggles (all opt-in/out, defaults preserve v0.1.0) | + +### `features` block + +Every field is optional. Defaults mirror v0.1.0 behaviour so existing `opencode.json` files do not need to change. + +| Feature | Type | Default | What it does | +| --------------------- | --------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `combos` | `boolean` | `true` | Discover `/api/combos` and surface them as pseudo-models with LCD capabilities | +| `enrichment` | `boolean` | `true` | Pull display names from `/api/pricing/models` AND per-million-token pricing (`input`, `output`, `cached` → `cacheRead`, `cache_creation` → `cacheWrite`) from `/api/pricing`, then overlay both onto the live catalog (so the UI shows `Claude 4.7 Opus` with `cost.input: 5`, `cost.output: 25` instead of raw IDs and zeroed cost). | +| `compressionMetadata` | `boolean` | `false` | Pull `/api/context/combos` so combo names get tagged with their compression pipeline, e.g. `claude-primary [rtk:standard → caveman:full]` | +| `geminiSanitization` | `boolean` | `true` | Strip `$schema`/`$ref`/`additionalProperties` from tool params when the model id matches `gemini` | +| `mcpAutoEmit` | `boolean` | `false` | Auto-write an `mcp.` remote entry into the OC config pointing at `/api/mcp/stream` with the resolved Bearer token | +| `mcpToken` | `string` | _unset_ | Optional separate Bearer for the auto-emitted MCP entry. Falls back to the provider's `apiKey` (from `auth.json`) when unset | +| `fetchInterceptor` | `boolean` | `true` | Inject `Authorization: Bearer` + default `Content-Type` on every outbound request targeting `baseURL` (suffix-spoof guarded) | + +#### Example — enrichment + compression tags + MCP auto-emit + +```jsonc +{ + "plugin": [ + [ + "@omniroute/opencode-plugin", + { + "providerId": "omniroute", + "baseURL": "https://or.example.com", + "features": { + "combos": true, + "enrichment": true, + "compressionMetadata": true, + "mcpAutoEmit": true, + }, + }, + ], + ], +} +``` + +With `mcpAutoEmit: true`, the plugin synthesises an `mcp.omniroute` entry equivalent to a manual: + +```jsonc +"mcp": { + "omniroute": { + "type": "remote", + "url": "https://or.example.com/api/mcp/stream", + "enabled": true, + "headers": { "Authorization": "Bearer " } + } +} +``` + +If you want a narrower-scoped Bearer for MCP (different from the chat/inference key), set `features.mcpToken`. Operator overrides win: if you already set `mcp.omniroute` in `opencode.json`, the plugin will not overwrite it. + +## Comparison vs `@omniroute/opencode-provider` + +[`@omniroute/opencode-provider`](https://github.com/diegosouzapw/OmniRoute/tree/main/%40omniroute/opencode-provider) is the existing config-generator package — it writes a frozen `provider.` block into `opencode.json` at build time. This plugin is the runtime integration. + +| | `@omniroute/opencode-plugin` (this) | `@omniroute/opencode-provider` | +| ----------------- | ----------------------------------- | --------------------------------- | +| Type | OC plugin | Config generator (CLI/build-time) | +| Models | Live from `/v1/models` | Frozen at scaffold | +| Combos | LCD-aggregated live | None | +| Gemini sanitize | Yes | N/A | +| OC UI integration | `/connect`, `/models` | None | +| Multi-instance | Native | Manual | + +Both can coexist; pick the one that fits your environment. + +## Requirements + +- Node `>=22.22.3` (per `engines.node`); tested on Node 22 and 24. +- OpenCode: verified end-to-end against `opencode@1.15.5` with `@opencode-ai/plugin@1.15.6`. +- OC plugin peer (`@opencode-ai/plugin`) `>=1.14.49` for the full feature set (provider hook surfaces models in `/models`). On `<=1.14.48`, the plugin falls back to its `config` hook, writing a static catalog snapshot into `config.provider[id]` so models still appear. +- The plugin uses the OC v1 plugin shape (`default: { id, server }`) — older OC releases that only walk named exports will reject it. Stay on OC ≥1.15. + +## License + +MIT. See [LICENSE](./LICENSE). diff --git a/@omniroute/opencode-plugin/package-lock.json b/@omniroute/opencode-plugin/package-lock.json new file mode 100644 index 0000000000..5a30932d62 --- /dev/null +++ b/@omniroute/opencode-plugin/package-lock.json @@ -0,0 +1,2414 @@ +{ + "name": "@omniroute/opencode-plugin", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@omniroute/opencode-plugin", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "zod": "^4.4.3" + }, + "devDependencies": { + "@opencode-ai/plugin": "^1.15.6", + "@types/node": "^22.19.19", + "tsup": "^8.5.1", + "tsx": "^4.22.3", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.22.3" + }, + "peerDependencies": { + "@opencode-ai/plugin": "*" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "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", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.15.7.tgz", + "integrity": "sha512-FqmEMGsXWNx4JWFOu2j5qecxmfo7ASRXfN+cqdkljXuUyVM3aZSrGId3nmL9ELvJUAnRL/6aonggtfSEHjlTsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@opencode-ai/sdk": "1.15.7", + "effect": "4.0.0-beta.66", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.2.15", + "@opentui/keymap": ">=0.2.15", + "@opentui/solid": ">=0.2.15" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/keymap": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/plugin/node_modules/zod": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", + "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.15.7.tgz", + "integrity": "sha512-fNwx2coNzA8VAv4hazG9REGdBuUtV1UYjK3hxMo8+/9SZakOgdjihH1xzoTESJA0e0d0JJIKBCJ7FZVF2WVSXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/effect": { + "version": "4.0.0-beta.66", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.66.tgz", + "integrity": "sha512-4arEr62cziFa8BBVDUwJCJJmaVepXf/kRg7KtC0h8+bufngscrHbwWFhr9c+HonwOF+31U3iD3xUJmw9KzX7Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.6.0", + "find-my-way-ts": "^0.1.6", + "ini": "^6.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^1.11.9", + "multipasta": "^0.2.7", + "toml": "^4.1.1", + "uuid": "^13.0.0", + "yaml": "^2.8.3" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/fast-check": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz", + "integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "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/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "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==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "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/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "1.11.12", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz", + "integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/multipasta": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", + "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "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", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", + "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/tsx": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", + "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/@omniroute/opencode-plugin/package.json b/@omniroute/opencode-plugin/package.json new file mode 100644 index 0000000000..a998eae655 --- /dev/null +++ b/@omniroute/opencode-plugin/package.json @@ -0,0 +1,73 @@ +{ + "name": "@omniroute/opencode-plugin", + "version": "0.1.0", + "description": "OpenCode plugin for the OmniRoute AI Gateway. Drives dynamic model discovery, /connect auth flow, and multi-instance OmniRoute providers via the official @opencode-ai/plugin contract.", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsup", + "clean": "rm -rf dist", + "test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts", + "prepublishOnly": "npm run clean && npm run build && npm test" + }, + "keywords": [ + "omniroute", + "opencode", + "opencode-plugin", + "ai-sdk", + "openai-compatible", + "provider", + "gemini", + "combos", + "mcp" + ], + "author": "OmniRoute contributors", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/diegosouzapw/OmniRoute.git", + "directory": "@omniroute/opencode-plugin" + }, + "bugs": { + "url": "https://github.com/diegosouzapw/OmniRoute/issues" + }, + "homepage": "https://github.com/diegosouzapw/OmniRoute/tree/main/%40omniroute/opencode-plugin#readme", + "engines": { + "node": ">=22.22.3" + }, + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "@opencode-ai/plugin": "*" + }, + "dependencies": { + "zod": "^4.4.3" + }, + "devDependencies": { + "@opencode-ai/plugin": "^1.15.6", + "@types/node": "^22.19.19", + "tsup": "^8.5.1", + "tsx": "^4.22.3", + "typescript": "^5.9.3" + } +} diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts new file mode 100644 index 0000000000..9402b57120 --- /dev/null +++ b/@omniroute/opencode-plugin/src/index.ts @@ -0,0 +1,2384 @@ +/** + * OpenCode plugin for the OmniRoute AI Gateway. + * + * Implements the official `@opencode-ai/plugin` Plugin contract (auth + + * provider + config hooks) to drive a running OmniRoute instance from + * OpenCode without hand-curated `provider..models` blocks in + * opencode.json[c]: + * + * - `auth` — registers `/connect ` flow (API key prompt) + * - `provider` — dynamic `/v1/models` fetch with TTL cache, capabilities + * pass-through (OmniRoute is the source of truth — no + * client-side variant synthesis) + * - `config` — backward-compat shim for OC versions that predate the + * `provider.models` hook (≤ 1.14.48) + * + * Two ways to consume the plugin: + * + * 1. Single-instance (default `providerId: "omniroute"`): + * + * ```json + * { + * "$schema": "https://opencode.ai/config.json", + * "plugin": ["@omniroute/opencode-plugin"] + * } + * ``` + * + * 2. Multi-instance via plugin options (prod + preprod side by side): + * + * ```json + * { + * "$schema": "https://opencode.ai/config.json", + * "plugin": [ + * ["@omniroute/opencode-plugin", { "providerId": "omniroute" }], + * ["@omniroute/opencode-plugin", { "providerId": "omniroute-preprod" }] + * ] + * } + * ``` + * + * Then `opencode connect ` to provision the API key per instance. + * + * Companion library: `@omniroute/opencode-provider` (build-time config generator) + * remains supported for users who can't run plugins (CI, scripted scaffolding). + * + * @see https://opencode.ai/docs/plugins for the OpenCode plugin contract. + * @see https://github.com/diegosouzapw/OmniRoute for the AI Gateway. + */ + +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { AuthHook, Config, Plugin, PluginOptions, ProviderHook } from "@opencode-ai/plugin"; +import type { Model as ModelV2 } from "@opencode-ai/sdk/v2"; +import { z } from "zod"; + +/** + * Zod schema for plugin options accepted as the second element of the + * `plugin: [name, opts]` tuple in opencode.json. Strict by design — unknown + * keys are rejected so typos in opencode.json surface immediately at plugin + * construction time instead of silently being dropped. + * + * Doc per field: + * + * - `providerId` OpenCode provider id this plugin instance binds to. + * Multiple plugin instances coexist by giving each a + * different `providerId` ("omniroute", "omniroute-preprod", + * "omniroute-local"). Maps to `ProviderHook.id` and + * `AuthHook.provider` in the @opencode-ai/plugin contract. + * Default: "omniroute". + * - `displayName` Label rendered in the OpenCode UI. Default derives + * from providerId. + * - `modelCacheTtl` `/v1/models` TTL cache duration in milliseconds. + * Default: 300_000 (5 min). + * - `baseURL` Override base URL for this OmniRoute instance. When + * absent, the loader falls back to a credential-attached + * baseURL set by `/connect`. + */ +/** + * Optional feature toggles. Every field is opt-in/out per call; defaults + * mirror the v0.1.0 behaviour so existing opencode.json files do not need + * to change. + * + * - `combos` Discover `/api/combos` and surface them as + * pseudo-models with LCD capabilities. Default true. + * - `enrichment` Pull display names + pricing from + * `/api/pricing/models` and overlay them onto the + * ModelV2 entries derived from `/v1/models`. Solves + * the "raw id in UI" complaint without client-side + * heuristics. Default true. + * - `compressionMetadata` Pull `/api/context/combos` so combo entries can + * be tagged with their compression pipeline + * (e.g. `rtk:standard → caveman:full`). Off by + * default — adds one network call per refresh and + * the data is only useful for combo entries. + * - `geminiSanitization` Strip `$schema`/`$ref`/`additionalProperties` + * from `tools[].function.parameters` when the + * model id contains "gemini". Default true. + * - `mcpAutoEmit` Auto-write an `mcp.` remote entry + * into the OC config pointing at + * `/api/mcp/stream` with the resolved + * Bearer token. Default false — keeps opencode.json + * in control unless explicitly opted in. + * - `mcpToken` Optional separate Bearer token to use in the + * auto-emitted MCP entry. Falls back to the + * provider's API key (from auth.json) when unset. + * Useful when a narrower-scoped MCP-only key is + * preferred over the chat/inference key. + * - `fetchInterceptor` Inject Authorization: Bearer + Content-Type on + * every outbound request to baseURL. Default true. + */ +const featuresSchema = z + .object({ + combos: z.boolean().optional(), + enrichment: z.boolean().optional(), + compressionMetadata: z.boolean().optional(), + geminiSanitization: z.boolean().optional(), + mcpAutoEmit: z.boolean().optional(), + mcpToken: z.string().min(1).optional(), + fetchInterceptor: z.boolean().optional(), + }) + .strict(); + +const optionsSchema = z + .object({ + providerId: z + .string() + .min(1) + .regex(/^[a-z0-9-]+$/i, "providerId must be a slug") + .optional(), + displayName: z.string().min(1).optional(), + modelCacheTtl: z.number().positive().optional(), + baseURL: z.string().url().optional(), + features: featuresSchema.optional(), + }) + .strict(); + +/** + * Plugin options shape — inferred directly from the Zod schema so the + * validator and the static type can never drift. Replaces the standalone + * interface previously declared here (T-02). Every consumer continues to + * import `OmniRoutePluginOptions` as before; only the source of truth + * shifted from a hand-written interface to `z.infer`. + */ +export type OmniRoutePluginOptions = z.infer; + +export const OMNIROUTE_PROVIDER_KEY = "omniroute" as const; + +export const DEFAULT_MODEL_CACHE_TTL_MS = 300_000 as const; + +/** + * Resolve effective options from the optional plugin-options object, + * applying defaults. Centralises the providerId fallback so every hook + * sees a consistent identifier. + */ +export function resolveOmniRoutePluginOptions( + opts?: OmniRoutePluginOptions +): Required> & + Pick { + const providerId = opts?.providerId ?? OMNIROUTE_PROVIDER_KEY; + const displayName = + opts?.displayName ?? + (providerId === OMNIROUTE_PROVIDER_KEY ? "OmniRoute" : `OmniRoute (${providerId})`); + const modelCacheTtl = + typeof opts?.modelCacheTtl === "number" && opts.modelCacheTtl > 0 + ? opts.modelCacheTtl + : DEFAULT_MODEL_CACHE_TTL_MS; + return { + providerId, + displayName, + modelCacheTtl, + baseURL: opts?.baseURL, + features: opts?.features, + }; +} + +/** + * Strict parse of raw plugin options (as received from opencode.json or a + * direct factory call) into the validated `OmniRoutePluginOptions` shape. + * + * - `null` / `undefined` → `{}` (no opts is valid, defaults take over). + * - Unknown keys → throws (strict schema catches typos in opencode.json). + * - Empty / malformed values (e.g. empty providerId, non-URL baseURL, + * negative modelCacheTtl) → throws. + * + * Validation happens at plugin invocation time (inside `OmniRoutePlugin`), + * NOT at module import — so a bad opencode.json fails the affected plugin + * instance with an actionable message instead of crashing the whole TUI on + * startup. + * + * Exported so callers and tests can validate options independent of the + * full plugin factory invocation. + */ +export function parseOmniRoutePluginOptions(opts: unknown): OmniRoutePluginOptions { + if (opts === null || opts === undefined) return {}; + const result = optionsSchema.safeParse(opts); + if (!result.success) { + const errs = result.error.issues + .map((i) => { + const path = i.path.length > 0 ? i.path.join(".") : ""; + return `${path}: ${i.message}`; + }) + .join("; "); + throw new Error(`Invalid @omniroute/opencode-plugin options: ${errs}`); + } + return result.data; +} + +/** + * Internal coercion shim. Delegates to `parseOmniRoutePluginOptions` to keep + * the public surface stable while routing all validation through the Zod + * schema. Always returns an object (never undefined) so downstream hooks see + * the same shape regardless of whether opencode.json passed `null`, + * `undefined`, or an empty bag. + */ +function coercePluginOptions(opts?: PluginOptions): OmniRoutePluginOptions { + return parseOmniRoutePluginOptions(opts); +} + +/** + * Build the AuthHook portion of the plugin for a given options bag. Exported + * standalone so the auth contract can be unit-tested without faking the full + * PluginInput / Hooks surface. + * + * Contract notes: + * - `provider` binds to `providerId` (NOT a hardcoded module constant — fixes + * the multi-instance bug in opencode-omniroute-auth@1.2.1 which pinned + * `OMNIROUTE_PROVIDER_ID = "omniroute"` at module scope). + * - `methods[0]` is the `api` flavor (no OAuth flow; OmniRoute issues bearer + * keys directly). Label includes the resolved displayName so multi-instance + * setups stay distinguishable in the OC TUI. + * - `methods[0].prompts` uses the official `{type:"text", key, message}` + * shape from `@opencode-ai/plugin@1.15.6`. The contract does NOT expose + * a `mask: true` flag on text prompts — the OC TUI is expected to handle + * credential masking by itself (per OC's `auth login` UX). + * - `loader` reads the stored credentials via `getAuth()` and projects them + * into the AI-SDK `openai-compatible` options shape (`apiKey`, `baseURL`). + * The fetch interceptor (`fetch`) is wired in T-04; left absent here so + * downstream code falls back to the SDK default fetch. + * - The loader rejects non-`api` auth flavors (oauth / wellknown) and empty + * keys by returning `{}` — OC then surfaces the `/connect` flow to the + * user instead of dispatching a request with bogus credentials. + */ +export function createOmniRouteAuthHook(opts?: OmniRoutePluginOptions): AuthHook { + const { providerId, displayName, baseURL } = resolveOmniRoutePluginOptions(opts); + + const hook: AuthHook = { + provider: providerId, + methods: [ + { + type: "api", + label: `${displayName} API Key`, + prompts: [ + { + type: "text", + key: "apiKey", + message: `OmniRoute API key (${providerId})`, + }, + ], + }, + ], + loader: async (getAuth, _provider) => { + const auth = await getAuth(); + if ( + auth && + typeof auth === "object" && + (auth as { type?: unknown }).type === "api" && + typeof (auth as { key?: unknown }).key === "string" && + (auth as { key: string }).key.length > 0 + ) { + const apiKey = (auth as { key: string }).key; + // baseURL resolution: plugin opts win, then a credential-attached + // baseURL (some auth backends stash it alongside the key), else empty. + // Re-cast through `unknown` first: Auth is a discriminated union + // (api | oauth | wellknown) and TS refuses a direct narrowing to a + // hypothetical `{ baseURL: string }` shape because WellKnownAuth has + // no `baseURL`. We've already checked the runtime type via typeof so + // the unknown-bridge is a safe assertion, not a lie. + const authBaseURL = (auth as unknown as { baseURL?: unknown }).baseURL; + const resolvedBaseURL = baseURL ?? (typeof authBaseURL === "string" ? authBaseURL : ""); + // Without a baseURL the interceptor can't tell which requests to + // intercept (it would either gate-keep nothing or, worse, all + // outbound traffic). Fall back to apiKey-only and let the SDK use + // its default fetch. The /connect flow + plugin opts should make + // this branch unreachable in practice. + if (!resolvedBaseURL) { + return { apiKey }; + } + return { + apiKey, + baseURL: resolvedBaseURL, + // Composition: sanitise Gemini tool schemas FIRST (T-06), then + // inject Bearer (T-04). Both layers are pure with respect to the + // other's concern (body vs headers) so order is logically free; + // wrapping the pure body-transform around the header-injecting + // interceptor reads cleaner and keeps T-06 testable in isolation + // against any inner fetch (real or stub). + fetch: createGeminiSanitizingFetch( + createOmniRouteFetchInterceptor({ + apiKey, + baseURL: resolvedBaseURL, + }) + ), + }; + } + return {}; + }, + }; + + return hook; +} + +/** + * Plugin factory. Returns the OpenCode Plugin object wired with the three + * hooks. Concrete hook bodies land in subsequent tickets (T-03 provider.models, + * T-04 fetch interceptor, T-06 Gemini sanitization, T-07 config backward-compat). + * + * Per `@opencode-ai/plugin@1.15.6`, the Plugin signature is + * `(input: PluginInput, options?: PluginOptions) => Promise` — opts + * arrive as the SECOND argument (from the `[name, opts]` tuple in + * opencode.json), NOT as a closure binding. Multi-instance support follows + * from each plugin tuple invoking the factory with its own opts. + */ +export const OmniRoutePlugin: Plugin = async (_input, options) => { + const resolved = coercePluginOptions(options); + // T-07: a single per-plugin-instance cache shared between the provider + // hook (T-03/T-05) and the config-shim hook (T-07). On OC ≥1.14.49 both + // hooks fire within the same Plugin invocation, so a shared cache keeps + // /v1/models + /api/combos at exactly one round-trip per TTL refresh + // instead of two. On OC ≤1.14.48 only the config hook runs; the cache + // still works (single producer + single consumer through the same map). + // Each `OmniRoutePlugin(...)` invocation gets its OWN cache via closure, + // so prod + preprod side-by-side instances do NOT collide. + const sharedCache: OmniRouteFetchCache = new Map(); + // Debug breadcrumb: confirm server() invocation + resolved options. + // Useful when diagnosing "is the plugin even running" from OC logs. + console.warn( + `[omniroute-plugin] initialized providerId=${resolved.providerId} displayName="${resolved.displayName}" baseURL=${resolved.baseURL ?? "(from auth.json)"} modelCacheTtl=${resolved.modelCacheTtl}ms` + ); + return { + auth: createOmniRouteAuthHook(resolved), + provider: createOmniRouteProviderHook(resolved, { cache: sharedCache }), + config: createOmniRouteConfigHook(resolved, { cache: sharedCache }), + }; +}; + +/** + * v1 plugin shape per OC plugin loader (`packages/opencode/src/plugin/shared.ts:readV1Plugin`). + * OC checks the default export for an object with `{id, server}` shape FIRST. + * If that fails it falls back to legacy `getLegacyPlugins` which walks every + * named export and rejects any non-function value — our package has + * constants (OMNIROUTE_PROVIDER_KEY, DEFAULT_MODEL_CACHE_TTL_MS) + types + + * schemas as named exports, so the legacy path always fails for us. + * + * Using v1 shape skips the legacy walk entirely. The `id` field is the + * plugin MODULE identifier (one per published package); per-instance + * `providerId` still flows through `options.providerId` as before. + */ +const OmniRouteV1Plugin = { + id: "@omniroute/opencode-plugin", + server: OmniRoutePlugin, +}; + +export default OmniRouteV1Plugin; + +// ──────────────────────────────────────────────────────────────────────────── +// Provider hook (T-03) — /v1/models pass-through with TTL cache +// ──────────────────────────────────────────────────────────────────────────── + +/** + * Raw shape of a `/v1/models` entry from OmniRoute. Captured verbatim from + * the prod gateway response (sample at /tmp/prod-v1-models.json: 455 entries). + * STRICT source-of-truth (OQ-3): every field that lands in ModelV2 traces + * back to this shape — no client-side variant synthesis. + */ +export interface OmniRouteRawModelEntry { + id: string; + object?: string; + owned_by?: string; + root?: string | null; + parent?: string | null; + context_length?: number; + max_input_tokens?: number; + max_output_tokens?: number; + input_modalities?: string[]; + output_modalities?: string[]; + capabilities?: { + tool_calling?: boolean; + reasoning?: boolean; + vision?: boolean; + thinking?: boolean; + attachment?: boolean; + structured_output?: boolean; + temperature?: boolean; + }; + release_date?: string; + last_updated?: string; + api_format?: string; +} + +/** + * Fetcher contract: returns the raw `/v1/models` entry list from a running + * OmniRoute instance. Surfaced as a dependency so unit tests can inject a + * stub without monkey-patching global `fetch`. + * + * Why we inline this instead of using `@omniroute/opencode-provider`'s + * `fetchLiveModels`: the sibling helper returns a stripped `{id, name, + * contextLength?}` shape (see opencode-provider/src/index.ts:480-569) that + * drops the `capabilities` / `*_modalities` / `max_*_tokens` blocks T-03 + * needs for ModelV2 pass-through. Adopting the sibling here would force a + * client-side re-fetch or re-introduce the synthesis we explicitly rejected + * in OQ-3. A 30-line raw fetcher is cheaper than mutating the sibling's + * stable v0.1.0 contract. + */ +export type OmniRouteModelsFetcher = ( + baseURL: string, + apiKey: string, + timeoutMs?: number +) => Promise; + +/** + * Default fetcher: `GET /v1/models` with bearer auth + AbortController + * timeout. Accepts both the `{object:"list", data:[…]}` envelope OmniRoute + * emits today and a bare-array envelope (defensive — keeps the plugin + * working if a future OmniRoute build trims the wrapper). Anything that + * isn't an object with a string `id` is filtered out silently. + */ +export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async ( + baseURL, + apiKey, + timeoutMs = 10_000 +) => { + if (!apiKey) throw new Error("@omniroute/opencode-plugin: apiKey required to fetch /v1/models"); + if (!baseURL) throw new Error("@omniroute/opencode-plugin: baseURL required to fetch /v1/models"); + + const trimmed = baseURL.replace(/\/+$/, ""); + // Tolerate both `https://host` and `https://host/v1` forms — the gateway + // exposes /v1/models either way; we just don't want a double `/v1/v1`. + const url = /\/v\d+$/.test(trimmed) ? `${trimmed}/models` : `${trimmed}/v1/models`; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + signal: controller.signal, + }); + if (!res.ok) { + throw new Error( + `@omniroute/opencode-plugin: GET ${url} failed: ${res.status} ${res.statusText}` + ); + } + const body = (await res.json()) as unknown; + const rawList: unknown[] = Array.isArray(body) + ? body + : body && typeof body === "object" && Array.isArray((body as { data?: unknown }).data) + ? ((body as { data: unknown[] }).data as unknown[]) + : []; + const out: OmniRouteRawModelEntry[] = []; + for (const r of rawList) { + if (r && typeof r === "object" && typeof (r as { id?: unknown }).id === "string") { + out.push(r as OmniRouteRawModelEntry); + } + } + return out; + } finally { + clearTimeout(timer); + } +}; + +/** + * Map a raw `/v1/models` entry → `ModelV2` (the type @opencode-ai/sdk/v2 + * exports as `Model`, re-exported by @opencode-ai/plugin as `ModelV2`). + * + * ModelV2 (as of @opencode-ai/sdk@v2 — see node_modules path + * `@opencode-ai/sdk/dist/v2/gen/types.gen.d.ts:964-1043`) requires a much + * richer shape than the T-03 spec's mapping table assumed. Concretely it + * expects: + * - flat `id`, `name`, `providerID`, `api: {id,url,npm}` + * - nested `capabilities: { temperature, reasoning, attachment, toolcall, + * input:{text,audio,image,video,pdf}, output:{…}, interleaved }` + * - `cost: { input, output, cache:{read,write} }` (NOT optional) + * - `limit: { context, input?, output }` + * - `status: "alpha"|"beta"|"deprecated"|"active"`, `options:{}`, `headers:{}` + * - `release_date: string` + * + * Deviations from the T-03 spec (documented per ticket §2 "CRITICAL: Check + * the actual ModelV2 type and adapt if field names differ"): + * 1. Spec's flat `tool_call` / `reasoning` / `attachment` / `modalities` + * top-level fields don't exist in ModelV2 — folded into + * `capabilities.{toolcall, reasoning, attachment, input.*, output.*}`. + * 2. `cost: undefined` is illegal (cost is required). OmniRoute doesn't + * surface pricing on /v1/models, so we emit a zeroed cost block. + * Downstream OC reads this for display only — the live pricing is + * OmniRoute's responsibility at routing time. + * 3. `tool_call` (spec) → `toolcall` (ModelV2 field name; one word). + * 4. `attachment` (spec) maps from `capabilities.vision` per OmniRoute + * convention: vision = ability to receive image attachments. If the + * raw entry happens to expose an explicit `capabilities.attachment` + * (some combo entries do), that wins. + * 5. `thinking` from OmniRoute has no 1:1 ModelV2 slot. We OR it into + * `reasoning` so thinking-only models still surface a non-false + * reasoning flag. + * 6. `last_updated` from OmniRoute has no ModelV2 slot — dropped (the + * spec also flagged this as "may not exist", and the prod sample + * confirms it's optional). `release_date` lands in ModelV2.release_date + * with `""` fallback (the field is required as `string`). + * 7. `temperature: true` per OmniRoute convention (OpenAI-compat mode + * always supports the temperature knob). If a raw entry sets + * `capabilities.temperature` explicitly, that wins. + * 8. Input/output modality arrays: each known modality flips its boolean. + * Unknown strings (future OmniRoute additions) are ignored — when the + * server adds new modalities we can map them here without breaking + * existing entries. + * 9. `status: "active"` — OmniRoute doesn't tier models alpha/beta on + * /v1/models, and OC needs a non-deprecated status to expose the + * model in the picker. If a future entry surfaces an explicit + * lifecycle hint we can map it then. + * 10. `options: {}` and `headers: {}` left empty — they're escape hatches + * for OC users to attach per-model overrides; the provider plugin + * must not preempt them. + * 11. `limit.input` is OPTIONAL on ModelV2 (the `?` modifier). We only + * emit it when OmniRoute supplies `max_input_tokens` — keeps the + * shape clean for combo entries that only carry context_length. + */ + +export function mapRawModelToModelV2( + raw: OmniRouteRawModelEntry, + ctx: { providerId: string; baseURL: string } +): ModelV2 { + const caps = raw.capabilities ?? {}; + const inMods = new Set(raw.input_modalities ?? ["text"]); + const outMods = new Set(raw.output_modalities ?? ["text"]); + + return { + id: raw.id, + /** + * Display name. Falls back to raw.id when no enrichment is available; + * the caller (`createOmniRouteProviderHook`) overlays + * `/api/pricing/models` data via `applyEnrichment` when + * `features.enrichment` is true. + */ + name: raw.id, + capabilities: { + temperature: caps.temperature ?? true, + reasoning: Boolean(caps.reasoning || caps.thinking), + attachment: Boolean(caps.attachment ?? caps.vision ?? false), + toolcall: Boolean(caps.tool_calling ?? false), + input: { + text: inMods.has("text"), + audio: inMods.has("audio"), + image: inMods.has("image"), + video: inMods.has("video"), + pdf: inMods.has("pdf"), + }, + output: { + text: outMods.has("text"), + audio: outMods.has("audio"), + image: outMods.has("image"), + video: outMods.has("video"), + pdf: outMods.has("pdf"), + }, + interleaved: false, + }, + cost: { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }, + limit: { + context: typeof raw.context_length === "number" ? raw.context_length : 0, + ...(typeof raw.max_input_tokens === "number" ? { input: raw.max_input_tokens } : {}), + output: typeof raw.max_output_tokens === "number" ? raw.max_output_tokens : 0, + }, + status: "active", + options: {}, + headers: {}, + release_date: raw.release_date ?? "", + providerID: ctx.providerId, + api: { + id: "openai-compatible", + url: ctx.baseURL, + npm: "@ai-sdk/openai-compatible", + }, + }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Combo discovery (T-05) — /api/combos pass-through with LCD capability roll-up +// ──────────────────────────────────────────────────────────────────────────── + +/** + * Raw shape of a single combo entry as returned by OmniRoute's `/api/combos`. + * + * Schema established via a live probe against + * `https://or4269-preprod.mrmm.xyz/api/combos` with a management-scoped key + * (response saved at /tmp/t05-combos.json) cross-referenced against the + * source-of-truth in this repo: + * + * - `src/app/api/combos/route.ts` GET handler — emits `{combos: [...]}` + * envelope after `getCombos()`. + * - `src/lib/db/combos.ts` `getCombos()` — returns rows persisted via + * `createCombo` / `updateCombo`, each shaped by `normalizeStoredCombo`. + * - `src/lib/combos/steps.ts` `ComboModelStep` + `ComboRefStep` — define + * the `models[]` array entry shape (a step references a member model + * by its full provider-prefixed id, e.g. `"claude-opus-4-5-thinking"`). + * + * Note: the preprod gateway returned `{combos: []}` at probe time (no combos + * provisioned). The defensive parser accepts both `{combos:[...]}` and a + * bare array envelope so the plugin keeps working if a future OmniRoute + * build trims the wrapper (mirrors the same pattern in the sibling + * `@omniroute/opencode-provider#listCombos`). + * + * STRICT source-of-truth (OQ-3, per T-03): every ModelV2 field a combo + * surfaces traces back to either (a) this raw combo entry or (b) the LCD + * roll-up across its raw member models. No client-side variant synthesis. + */ +export interface OmniRouteRawComboMemberRef { + /** Step kind: "model" references a raw model id; "combo-ref" nests another combo. */ + kind?: "model" | "combo-ref"; + /** Full model id referenced by this step (when kind === "model"). */ + model?: string; + /** Nested combo name (when kind === "combo-ref"). */ + comboName?: string; + /** Routing weight inside the combo (0–100, advisory at LCD time). */ + weight?: number; + /** Step-local label, distinct from the parent combo's display name. */ + label?: string; +} + +export interface OmniRouteRawCombo { + id: string; + name?: string; + /** Routing strategy. Surfaced for forward-compat but not consumed by LCD. */ + strategy?: string; + /** Member step list. Only `kind: "model"` steps participate in LCD. */ + models?: OmniRouteRawComboMemberRef[]; + /** Hidden combos are excluded from the OC model picker. */ + isHidden?: boolean; + /** When OmniRoute attaches a lifecycle hint we forward it; today it doesn't. */ + release_date?: string; +} + +/** + * Fetcher contract for `/api/combos`. Same DI shape as + * `OmniRouteModelsFetcher` so unit tests can inject a stub instead of + * monkey-patching global `fetch`. + */ +export type OmniRouteCombosFetcher = ( + baseURL: string, + apiKey: string, + timeoutMs?: number +) => Promise; + +/** + * Default fetcher: `GET /api/combos` with bearer auth + + * AbortController timeout. Accepts both the `{combos: [...]}` envelope the + * gateway emits today and a bare-array envelope (defensive — keeps the + * plugin working if a future OmniRoute build trims the wrapper). + * + * Differences from `defaultOmniRouteModelsFetcher`: + * - URL is `/api/combos`, NOT `/v1/combos`. The `/v1/...` namespace is the + * OpenAI-compatible surface (chat completions, models); combo discovery + * lives on the management plane under `/api/...`. We tolerate both + * `https://host` and `https://host/v1` baseURL forms by stripping the + * trailing `/v1` segment before appending `/api/combos`. + * - Combos endpoint requires a management-scoped API key when + * `REQUIRE_API_KEY` is enabled. We don't enforce that here; the + * gateway returns 401/403 with an actionable error which we propagate. + * + * Anything that isn't an object with a string `id` is filtered out silently. + */ +export const defaultOmniRouteCombosFetcher: OmniRouteCombosFetcher = async ( + baseURL, + apiKey, + timeoutMs = 10_000 +) => { + if (!apiKey) throw new Error("@omniroute/opencode-plugin: apiKey required to fetch /api/combos"); + if (!baseURL) + throw new Error("@omniroute/opencode-plugin: baseURL required to fetch /api/combos"); + + // Strip trailing slashes, then strip a trailing `/v1` so we land on the + // management plane. Models live under `/v1/models`; combos live under + // `/api/combos` from the same gateway root. + const trimmed = baseURL.replace(/\/+$/, ""); + const root = trimmed.replace(/\/v\d+$/, ""); + const url = `${root}/api/combos`; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + signal: controller.signal, + }); + if (!res.ok) { + throw new Error( + `@omniroute/opencode-plugin: GET ${url} failed: ${res.status} ${res.statusText}` + ); + } + const body = (await res.json()) as unknown; + const rawList: unknown[] = Array.isArray(body) + ? body + : body && typeof body === "object" && Array.isArray((body as { combos?: unknown }).combos) + ? ((body as { combos: unknown[] }).combos as unknown[]) + : []; + const out: OmniRouteRawCombo[] = []; + for (const r of rawList) { + if (r && typeof r === "object" && typeof (r as { id?: unknown }).id === "string") { + out.push(r as OmniRouteRawCombo); + } + } + return out; + } finally { + clearTimeout(timer); + } +}; + +/** + * Map a raw combo entry → `ModelV2` by computing the lowest-common-denominator + * (LCD) of its underlying member models. The LCD policy is the only way to + * surface a single capability vector to OpenCode without lying: if any member + * lacks a capability, the combo as a whole cannot guarantee it. + * + * LCD rules: + * - `limit.context` = `min(...members.context_length)`. + * - `limit.output` = `min(...members.max_output_tokens)`. + * - `limit.input` = `min(...members.max_input_tokens)` ONLY when every + * member declares one (ModelV2.limit.input is optional — better to + * omit than to fabricate a min over partial data). + * - `capabilities.toolcall` / `reasoning` / `attachment` / `temperature`: + * `every(member ⇒ supports?)`. The `reasoning` axis ORs across + * `reasoning` and `thinking` per member before AND-ing across the + * combo (mirrors `mapRawModelToModelV2`). The `attachment` axis ORs + * across `attachment` and `vision` per member. The `temperature` axis + * uses default-true semantics: a member supports temperature unless + * it explicitly declares `temperature: false`. + * - `capabilities.input.*` / `output.*`: flattened AND across members' + * modality flags. Missing arrays default to `["text"]` (same default + * as `mapRawModelToModelV2`). + * + * Defensive: empty members array → ALL capabilities `false`, limits zero. + * That's an intentional safety posture (you can't route through an empty + * combo, so OC should grey it out in the picker). + * + * Spec mapping (T-05 §Scope.3): `cost` zeroed; `status = "active"`; + * `release_date = combo.release_date ?? ""`; `api.id = "openai-compatible"`; + * `name = combo.name ?? combo.id`. + * + * @param combo Raw `/api/combos` entry. + * @param members Raw `/v1/models` entries for THIS combo's member ids. + * Caller resolves `combo.models[].model` ids; unknown ids + * are silently dropped before this call. + * @param providerId OpenCode provider id (multi-instance aware). + * @param baseURL Resolved gateway base URL for ModelV2.api.url. + */ +export function mapComboToModelV2( + combo: OmniRouteRawCombo, + members: OmniRouteRawModelEntry[], + providerId: string, + baseURL: string +): ModelV2 { + // `every` over an empty array returns true (would lie about an empty + // combo's capabilities) — short-circuit to all-false when no members. + const hasMembers = members.length > 0; + + const memberInMods = members.map((m) => new Set(m.input_modalities ?? ["text"])); + const memberOutMods = members.map((m) => new Set(m.output_modalities ?? ["text"])); + + const modalityAllHave = (sets: Array>, key: string): boolean => + hasMembers && sets.every((s) => s.has(key)); + + const contextValues = members + .map((m) => m.context_length) + .filter((v): v is number => typeof v === "number" && v > 0); + const outputValues = members + .map((m) => m.max_output_tokens) + .filter((v): v is number => typeof v === "number" && v > 0); + const inputValues = members + .map((m) => m.max_input_tokens) + .filter((v): v is number => typeof v === "number" && v > 0); + + const everyDeclaresInput = hasMembers && inputValues.length === members.length; + + const capabilities: ModelV2["capabilities"] = { + temperature: + hasMembers && members.every((m) => (m.capabilities?.temperature ?? true) !== false), + reasoning: + hasMembers && + members.every((m) => Boolean(m.capabilities?.reasoning || m.capabilities?.thinking)), + attachment: + hasMembers && + members.every((m) => Boolean(m.capabilities?.attachment ?? m.capabilities?.vision ?? false)), + toolcall: hasMembers && members.every((m) => Boolean(m.capabilities?.tool_calling ?? false)), + input: { + text: modalityAllHave(memberInMods, "text"), + audio: modalityAllHave(memberInMods, "audio"), + image: modalityAllHave(memberInMods, "image"), + video: modalityAllHave(memberInMods, "video"), + pdf: modalityAllHave(memberInMods, "pdf"), + }, + output: { + text: modalityAllHave(memberOutMods, "text"), + audio: modalityAllHave(memberOutMods, "audio"), + image: modalityAllHave(memberOutMods, "image"), + video: modalityAllHave(memberOutMods, "video"), + pdf: modalityAllHave(memberOutMods, "pdf"), + }, + interleaved: false, + }; + + return { + id: combo.id, + providerID: providerId, + api: { + id: "openai-compatible", + url: baseURL, + npm: "@ai-sdk/openai-compatible", + }, + name: combo.name && combo.name.trim().length > 0 ? combo.name : combo.id, + capabilities, + cost: { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }, + limit: { + context: contextValues.length > 0 ? Math.min(...contextValues) : 0, + ...(everyDeclaresInput ? { input: Math.min(...inputValues) } : {}), + output: outputValues.length > 0 ? Math.min(...outputValues) : 0, + }, + status: "active", + options: {}, + headers: {}, + release_date: combo.release_date ?? "", + }; +} + +// ───────────────────────────────────────────────────────────────────────── +// ENRICHMENT — pull display names + pricing from /api/pricing/models so +// the UI doesn't have to render raw model ids. Gated by features.enrichment. +// ───────────────────────────────────────────────────────────────────────── + +/** + * Per-model enrichment overlay derived from OmniRoute's + * `/api/pricing/models` endpoint. The endpoint returns a per-provider + * catalog with curated `name` strings (e.g. `Claude 4.7 Opus`, + * `GPT 5.5 Pro`, `Gemini 3.1 Pro`) and per-million-token pricing + * (`pricing.input`, `pricing.output`, `pricing.cacheRead`, + * `pricing.cacheWrite`). These overlay the ModelV2 entries produced by + * `mapRawModelToModelV2`. + */ +export interface OmniRouteEnrichmentEntry { + /** Human-readable display name. Replaces ModelV2.name when present. */ + name?: string; + /** Per-million-token cost overlay onto ModelV2.cost. */ + pricing?: { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + }; +} + +/** Map keyed by full model id (possibly namespaced, e.g. `cc/claude-sonnet-4-6`). */ +export type OmniRouteEnrichmentMap = Map; + +export type OmniRouteEnrichmentFetcher = ( + baseURL: string, + apiKey: string, + timeoutMs?: number +) => Promise; + +/** + * Default enrichment fetcher — pulls nice display names from + * `GET /api/pricing/models` and merges per-million-token pricing from + * `GET /api/pricing` (the actual pricing source — `/api/pricing/models` is + * a catalog endpoint whose entries are `{id, name, custom}` only). + * + * `/api/pricing/models` shape (catalog): + * - `{ [providerAlias]: { id, alias, name, models: [{ id, name, custom }] } }` + * + * `/api/pricing` shape (pricing only): + * - `{ [providerAlias]: { [modelId]: { input, output, cached, reasoning, cache_creation } } }` + * where values are USD per million tokens. + * + * The two responses are joined on `(providerAlias, modelId)` and the merged + * entries are stored under both `${providerAlias}/${modelId}` and bare + * `${modelId}` keys so downstream lookups against either form succeed. + * + * Soft-fails (returns whatever was collected) on non-2xx or parse errors; + * the two fetches are independent so one missing source still surfaces the + * other. + */ +export const defaultOmniRouteEnrichmentFetcher: OmniRouteEnrichmentFetcher = async ( + baseURL, + apiKey, + timeoutMs = 10_000 +) => { + const out: OmniRouteEnrichmentMap = new Map(); + if (!baseURL || !apiKey) return out; + const root = baseURL.replace(/\/v1\/?$/, "").replace(/\/$/, ""); + const headers = { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }; + + // ── 1. Catalog with nice display names ──────────────────────────────── + const catalogAc = new AbortController(); + const catalogTimer = setTimeout(() => catalogAc.abort(), timeoutMs); + try { + const res = await fetch(`${root}/api/pricing/models`, { + method: "GET", + headers, + signal: catalogAc.signal, + }); + if (res.ok) { + const body = (await res.json()) as unknown; + const providers = + (body as { providers?: Record })?.providers ?? + (body as Record); + if (providers && typeof providers === "object") { + for (const [providerAlias, slot] of Object.entries(providers)) { + if (!slot || typeof slot !== "object") continue; + const models = (slot as { models?: unknown[] }).models; + if (!Array.isArray(models)) continue; + for (const m of models) { + if (!m || typeof m !== "object") continue; + const id = (m as { id?: unknown }).id; + if (typeof id !== "string" || id.length === 0) continue; + const name = (m as { name?: unknown }).name; + const entry: OmniRouteEnrichmentEntry = {}; + if (typeof name === "string" && name.trim().length > 0) entry.name = name; + const namespaced = `${providerAlias}/${id}`; + if (!out.has(namespaced)) out.set(namespaced, entry); + if (!out.has(id)) out.set(id, entry); + } + } + } + } + } catch { + // Soft-fail; keep going to pricing fetch. + } finally { + clearTimeout(catalogTimer); + } + + // ── 2. Pricing values from /api/pricing ─────────────────────────────── + const priceAc = new AbortController(); + const priceTimer = setTimeout(() => priceAc.abort(), timeoutMs); + try { + const res = await fetch(`${root}/api/pricing`, { + method: "GET", + headers, + signal: priceAc.signal, + }); + if (res.ok) { + const body = (await res.json()) as unknown; + if (body && typeof body === "object" && !Array.isArray(body)) { + for (const [providerAlias, slot] of Object.entries(body as Record)) { + if (!slot || typeof slot !== "object" || Array.isArray(slot)) continue; + for (const [modelId, raw] of Object.entries(slot as Record)) { + if (!raw || typeof raw !== "object") continue; + const p = raw as Record; + const parsed: NonNullable = {}; + // OmniRoute `/api/pricing` keys: + // input → cost.input + // output → cost.output + // cached → cost.cache.read (alias: cacheRead) + // cache_creation → cost.cache.write (alias: cacheWrite) + // Tolerate alternative spellings for forward-compat. + if (typeof p.input === "number") parsed.input = p.input; + if (typeof p.output === "number") parsed.output = p.output; + const cacheRead = + typeof p.cached === "number" + ? p.cached + : typeof p.cacheRead === "number" + ? p.cacheRead + : undefined; + if (typeof cacheRead === "number") parsed.cacheRead = cacheRead; + const cacheWrite = + typeof p.cache_creation === "number" + ? p.cache_creation + : typeof p.cacheWrite === "number" + ? p.cacheWrite + : undefined; + if (typeof cacheWrite === "number") parsed.cacheWrite = cacheWrite; + if (Object.keys(parsed).length === 0) continue; + const namespaced = `${providerAlias}/${modelId}`; + const existingNs = out.get(namespaced); + if (existingNs) existingNs.pricing = { ...(existingNs.pricing ?? {}), ...parsed }; + else out.set(namespaced, { pricing: parsed }); + const existingBare = out.get(modelId); + if (existingBare) existingBare.pricing = { ...(existingBare.pricing ?? {}), ...parsed }; + else out.set(modelId, { pricing: parsed }); + } + } + } + } + } catch { + // Soft-fail; return whatever names we collected. + } finally { + clearTimeout(priceTimer); + } + + return out; +}; + +/** + * Apply enrichment overlay onto a ModelV2 entry. Mutates and returns the + * passed entry for convenience. + */ +export function applyEnrichment( + model: ModelV2, + enrichment: OmniRouteEnrichmentEntry | undefined +): ModelV2 { + if (!enrichment) return model; + if (enrichment.name && enrichment.name.trim().length > 0) { + model.name = enrichment.name; + } + if (enrichment.pricing) { + if (typeof enrichment.pricing.input === "number") { + model.cost.input = enrichment.pricing.input; + } + if (typeof enrichment.pricing.output === "number") { + model.cost.output = enrichment.pricing.output; + } + if (typeof enrichment.pricing.cacheRead === "number") { + model.cost.cache.read = enrichment.pricing.cacheRead; + } + if (typeof enrichment.pricing.cacheWrite === "number") { + model.cost.cache.write = enrichment.pricing.cacheWrite; + } + } + return model; +} + +// ───────────────────────────────────────────────────────────────────────── +// COMPRESSION METADATA — pull /api/context/combos so combo entries can be +// tagged with their compression pipeline. Gated by +// features.compressionMetadata (off by default). +// ───────────────────────────────────────────────────────────────────────── + +/** Single step in a compression combo's pipeline. */ +export interface OmniRouteCompressionStep { + engine: string; // "rtk" | "caveman" | "aggressive" | ... + intensity?: string; // "minimal" | "lite" | "standard" | "full" | "ultra" | "aggressive" +} + +/** Compression combo as returned by /api/context/combos. */ +export interface OmniRouteCompressionCombo { + id: string; + name?: string; + description?: string; + pipeline: OmniRouteCompressionStep[]; + isDefault?: boolean; +} + +export type OmniRouteCompressionMetaFetcher = ( + baseURL: string, + apiKey: string, + timeoutMs?: number +) => Promise; + +/** + * Default compression-metadata fetcher — calls `GET /api/context/combos`. + * Tolerates envelope shapes `{ combos: [...] }`, `[...]`, or + * `{ data: [...] }`. Soft-fails (returns []) on non-2xx or parse errors. + */ +export const defaultOmniRouteCompressionMetaFetcher: OmniRouteCompressionMetaFetcher = async ( + baseURL, + apiKey, + timeoutMs = 10_000 +) => { + const empty: OmniRouteCompressionCombo[] = []; + if (!baseURL || !apiKey) return empty; + const root = baseURL.replace(/\/v1\/?$/, "").replace(/\/$/, ""); + const url = `${root}/api/context/combos`; + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), timeoutMs); + try { + const res = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + signal: ac.signal, + }); + if (!res.ok) return empty; + const body = (await res.json()) as unknown; + const list = Array.isArray(body) + ? body + : Array.isArray((body as { combos?: unknown[] })?.combos) + ? (body as { combos: unknown[] }).combos + : Array.isArray((body as { data?: unknown[] })?.data) + ? (body as { data: unknown[] }).data + : []; + const out: OmniRouteCompressionCombo[] = []; + for (const raw of list) { + if (!raw || typeof raw !== "object") continue; + const id = (raw as { id?: unknown }).id; + const pipeline = (raw as { pipeline?: unknown }).pipeline; + if (typeof id !== "string" || id.length === 0) continue; + if (!Array.isArray(pipeline)) continue; + const steps: OmniRouteCompressionStep[] = []; + for (const step of pipeline) { + if (!step || typeof step !== "object") continue; + const engine = (step as { engine?: unknown }).engine; + if (typeof engine !== "string" || engine.length === 0) continue; + const intensity = (step as { intensity?: unknown }).intensity; + const entry: OmniRouteCompressionStep = { engine }; + if (typeof intensity === "string" && intensity.length > 0) { + entry.intensity = intensity; + } + steps.push(entry); + } + const combo: OmniRouteCompressionCombo = { id, pipeline: steps }; + const name = (raw as { name?: unknown }).name; + if (typeof name === "string" && name.length > 0) combo.name = name; + const description = (raw as { description?: unknown }).description; + if (typeof description === "string") combo.description = description; + const isDefault = (raw as { isDefault?: unknown }).isDefault; + if (typeof isDefault === "boolean") combo.isDefault = isDefault; + out.push(combo); + } + return out; + } catch { + return empty; + } finally { + clearTimeout(timer); + } +}; + +/** + * Format a compression pipeline as a short human-readable string for combo + * `name` decoration. Example: `[rtk:standard → caveman:full]`. + */ +export function formatCompressionPipeline(pipeline: OmniRouteCompressionStep[]): string { + if (!pipeline || pipeline.length === 0) return ""; + return ( + "[" + + pipeline.map((s) => (s.intensity ? `${s.engine}:${s.intensity}` : s.engine)).join(" → ") + + "]" + ); +} + +/** + * Internal cache key: `${baseURL}::sha256(apiKey)`. We hash the apiKey so + * the key is safe to log / inspect via debugger without leaking the secret. + * Different (baseURL, apiKey) tuples MUST keep independent cache entries: + * a single OC user may register prod + preprod OmniRoute side-by-side with + * distinct keys, and serving one's catalog from the other's cache would be + * a correctness bug, not just a privacy one. + */ +function modelsCacheKey(baseURL: string, apiKey: string): string { + const h = createHash("sha256").update(apiKey).digest("hex"); + return `${baseURL}::${h}`; +} + +/** + * Shared fetch-result cache entry. Holds the RAW `/v1/models` + `/api/combos` + * responses (NOT a pre-derived ModelV2 / static-entry shape) so the provider + * hook (T-03/T-05) and the config-shim hook (T-07) can derive their own + * output shapes from the same source without re-fetching. + * + * Why raw instead of derived: + * - provider hook emits ModelV2 (rich nested capabilities + cost + limits). + * - config hook emits the stripped sibling shape + * (`{name, attachment, reasoning, tool_call, temperature, limit?}`). + * - These overlap but neither is a superset of the other (ModelV2 has no + * `tool_call` field — it's `toolcall`; the stripped shape has no + * `cost`/`status`/`headers`). Caching the raw responses is the only + * lossless option. + * - On OC ≥1.14.49 cold start BOTH hooks fire within the same + * OmniRoutePlugin instance — sharing the cache means /v1/models + + * /api/combos each hit the gateway exactly ONCE per TTL refresh, not + * twice. + */ +export interface OmniRouteFetchCacheEntry { + rawModels: OmniRouteRawModelEntry[]; + rawCombos: OmniRouteRawCombo[]; + /** Display-name + pricing overlay from /api/pricing/models. Empty Map when feature is disabled or fetch failed. */ + rawEnrichment: OmniRouteEnrichmentMap; + /** Compression combos from /api/context/combos. Empty array when feature is disabled or fetch failed. */ + rawCompressionCombos: OmniRouteCompressionCombo[]; + expiresAt: number; +} + +export type OmniRouteFetchCache = Map; + +/** + * Build the ProviderHook portion of the plugin for a given options bag. + * Exported standalone so the contract is unit-testable without faking the + * full PluginInput / Hooks surface, and so multi-instance setups can each + * own their own cache (a fresh hook closure per plugin tuple). + * + * Behavioural contract: + * - `id` binds to the resolved `providerId` (multi-instance: each plugin + * tuple's hook lists models under its own provider id). + * - `models(provider, ctx)` extracts the api key from `ctx.auth` (rejecting + * non-`api` flavors with `{}` — same posture as the auth loader); calls + * both `/v1/models` and `/api/combos` fetchers; maps raw `/v1/models` + * entries through `mapRawModelToModelV2`; maps each `/api/combos` entry + * through `mapComboToModelV2` (LCD across its member models); merges + * combos into the same map under their combo id; caches the unified + * result by `(baseURL, sha256(apiKey))` for `modelCacheTtl`. + * - **Combo / model ID collisions: combos win.** OmniRoute treats combos + * as the curated routing surface; if a combo and a raw model share an + * id the operator's intent is clearly the combo. We emit a + * `console.warn` exactly once per `(baseURL, apiKey, comboId)` + * collision so the operator can spot the unusual naming choice + * without log spam on every cache refresh. + * - **Combos fetch failure does NOT break the catalog**: soft-fail with + * a `console.warn` and fall back to a models-only catalog. Rationale: + * `/api/combos` requires a management-scoped key and OmniRoute may + * not have any combos provisioned (preprod returned `{combos: []}` + * at probe time). Hard-failing the entire catalog when combos are + * optional would silently hide the whole provider from OC's model + * picker. + * - **`/v1/models` fetch failure DOES propagate.** Without models + * there's no catalog at all, so an empty `{}` would just mask the + * error. + * - Cache is in-memory per hook instance, shared between models and + * combos (one fetch pair per (baseURL, apiKey) per TTL refresh). + * + * @param opts Plugin options (providerId, baseURL, modelCacheTtl, …). + * @param deps Dependency injection. `fetcher` defaults to the live + * `/v1/models` HTTP fetcher; `combosFetcher` defaults to the + * live `/api/combos` HTTP fetcher (override for tests / to + * disable combos by injecting one that returns `[]`). `now` + * defaults to `Date.now` (overridable for TTL tests). `cache` + * lets the caller share state across reconstructions (unused + * outside tests today). + */ +export function createOmniRouteProviderHook( + opts?: OmniRoutePluginOptions, + deps: { + fetcher?: OmniRouteModelsFetcher; + combosFetcher?: OmniRouteCombosFetcher; + enrichmentFetcher?: OmniRouteEnrichmentFetcher; + compressionMetaFetcher?: OmniRouteCompressionMetaFetcher; + now?: () => number; + cache?: OmniRouteFetchCache; + } = {} +): ProviderHook { + const resolved = resolveOmniRoutePluginOptions(opts); + const fetcher = deps.fetcher ?? defaultOmniRouteModelsFetcher; + // T-05: combo discovery merges `/api/combos` entries into the same map as + // `/v1/models`. Default fetcher is declared further down the file; the + // reference resolves at hook-invocation time, not at hook-construction + // time, so source-order beyond hoisting rules has no semantic effect. + const combosFetcher = deps.combosFetcher ?? defaultOmniRouteCombosFetcher; + const enrichmentFetcher = deps.enrichmentFetcher ?? defaultOmniRouteEnrichmentFetcher; + const compressionMetaFetcher = + deps.compressionMetaFetcher ?? defaultOmniRouteCompressionMetaFetcher; + // Features defaults (mirror v0.1.0 behavior when unset). + const features = resolved.features ?? {}; + const wantCombos = features.combos !== false; + const wantEnrichment = features.enrichment !== false; + const wantCompressionMeta = features.compressionMetadata === true; + const now = deps.now ?? Date.now; + // T-07: cache holds RAW fetch results (not pre-derived ModelV2) so that + // the config-shim hook can share the same cache and derive its stripped + // sibling shape from the same source without a second round-trip. + const cache: OmniRouteFetchCache = deps.cache ?? new Map(); + // T-05: collision-warning deduper. Emit warn once per (cacheKey, comboId) + // tuple per hook instance so the operator sees the unusual naming choice + // once per session, not once per cache refresh. + const collisionWarned = new Set(); + + return { + id: resolved.providerId, + async models(_provider, ctx) { + // Auth narrowing — same posture as the auth loader (T-02). Non-api + // flavors and empty keys → empty catalog. OC then exposes the + // /connect flow rather than spamming /v1/models with bad creds. + const auth = ctx?.auth; + if ( + !auth || + typeof auth !== "object" || + (auth as { type?: unknown }).type !== "api" || + typeof (auth as { key?: unknown }).key !== "string" || + (auth as { key: string }).key.length === 0 + ) { + return {}; + } + const apiKey = (auth as { key: string }).key; + + // baseURL resolution: plugin opts first, then credential-attached + // baseURL (auth backends sometimes stash it next to the key). No + // silent default to localhost: a misconfigured plugin should surface + // a clear error, not phantom /v1/models calls. Cast through unknown + // because the Auth union (OAuth | ApiAuth | WellKnownAuth) doesn't + // declare baseURL on any branch — we duck-type it as a defensive + // extension point. + const authBaseURL = (auth as unknown as { baseURL?: unknown }).baseURL; + const baseURL = resolved.baseURL ?? (typeof authBaseURL === "string" ? authBaseURL : ""); + if (!baseURL) { + return {}; + } + + const cacheKey = modelsCacheKey(baseURL, apiKey); + const t = now(); + const cached = cache.get(cacheKey); + + let rawModels: OmniRouteRawModelEntry[]; + let rawCombos: OmniRouteRawCombo[]; + let rawEnrichment: OmniRouteEnrichmentMap; + let rawCompressionCombos: OmniRouteCompressionCombo[]; + if (cached && cached.expiresAt > t) { + rawModels = cached.rawModels; + rawCombos = cached.rawCombos; + rawEnrichment = cached.rawEnrichment; + rawCompressionCombos = cached.rawCompressionCombos; + } else { + // Models fetch is required (no catalog otherwise → silent provider + // disappearance). We do NOT wrap this in a try; let the error + // propagate to OC's UI. + rawModels = await fetcher(baseURL, apiKey, 10_000); + + // T-05: combos fetch is best-effort, gated by features.combos. + // Soft-fail on any error: emit a console.warn and fall back to a + // models-only catalog. Rationale: /api/combos requires a + // management-scoped key and OmniRoute may not have any combos + // provisioned. Hard-failing when combos are optional would + // silently hide the whole provider from OC's picker. + rawCombos = []; + if (wantCombos) { + try { + rawCombos = await combosFetcher(baseURL, apiKey, 10_000); + } catch (err) { + console.warn( + "[omniroute-plugin] combos fetch failed, falling back to models-only catalog", + err + ); + } + } + + // Enrichment fetch (nice names + pricing). Best-effort, gated by + // features.enrichment. Soft-fails to empty map. + rawEnrichment = new Map(); + if (wantEnrichment) { + try { + rawEnrichment = await enrichmentFetcher(baseURL, apiKey, 10_000); + } catch (err) { + console.warn( + "[omniroute-plugin] enrichment fetch failed, falling back to raw ids", + err + ); + } + } + + // Compression metadata fetch. Off by default, gated by + // features.compressionMetadata. Soft-fails to empty array. + rawCompressionCombos = []; + if (wantCompressionMeta) { + try { + rawCompressionCombos = await compressionMetaFetcher(baseURL, apiKey, 10_000); + } catch (err) { + console.warn("[omniroute-plugin] compression-metadata fetch failed", err); + } + } + + cache.set(cacheKey, { + rawModels, + rawCombos, + rawEnrichment, + rawCompressionCombos, + expiresAt: t + resolved.modelCacheTtl, + }); + + // Debug breadcrumb: surface fetch result so operators can confirm + // the dynamic pipeline fired and how much catalog OmniRoute returned. + // Emitted once per cache miss (TTL refresh) — quiet on cache hits. + console.warn( + `[omniroute-plugin] catalog refreshed for providerId=${resolved.providerId} baseURL=${baseURL}: ` + + `${rawModels.length} models + ${rawCombos.length} combos + ` + + `${rawEnrichment.size} enrichment entries + ` + + `${rawCompressionCombos.length} compression combos ` + + `(TTL=${resolved.modelCacheTtl}ms)` + ); + } + + // Lookup index for LCD member resolution: O(1) per member lookup. + // Indexed by raw model `id` — combo steps reference this exact + // string per ComboModelStep in src/lib/combos/steps.ts. + const rawModelById = new Map(); + for (const entry of rawModels) { + if (entry.id) rawModelById.set(entry.id, entry); + } + + // Map raw models → ModelV2 keyed by id. When enrichment data is + // present (features.enrichment, default on), overlay the nicer + // display name + pricing from /api/pricing/models. The enrichment + // map keys on both namespaced (`/`) and bare ids so + // we just try the bare id first, then fall back. + const models: Record = {}; + for (const entry of rawModels) { + if (!entry.id) continue; + const model = mapRawModelToModelV2(entry, { + providerId: resolved.providerId, + baseURL, + }); + applyEnrichment(model, rawEnrichment.get(entry.id)); + models[entry.id] = model; + } + + // Default compression combo (used to decorate ALL combo names when + // compression metadata is present). OmniRoute returns at most one + // entry with `isDefault: true` per /api/context/combos. + const defaultCompression = wantCompressionMeta + ? rawCompressionCombos.find((c) => c.isDefault === true) + : undefined; + + // T-05: map raw combos → ModelV2. Skip hidden combos (operator + // preference — provisioned but intentionally not surfaced). + // Resolve each combo's member step list into the matching raw + // model entries; unknown member ids are silently dropped before + // mapComboToModelV2 sees them, which then degrades to the + // all-false LCD posture if zero members remain. + for (const combo of rawCombos) { + if (!combo.id) continue; + if (combo.isHidden === true) continue; + + const memberSteps = Array.isArray(combo.models) ? combo.models : []; + const memberEntries: OmniRouteRawModelEntry[] = []; + for (const step of memberSteps) { + // Use the unknown-bridge pattern from commit 91b137e6 so the + // DTS pass stays clean: ComboMemberRef declares `model?: string` + // but we still verify the runtime shape before consuming it. + const modelId = (step as unknown as { model?: unknown }).model; + if (typeof modelId !== "string" || modelId.length === 0) continue; + const member = rawModelById.get(modelId); + if (member) memberEntries.push(member); + } + + const mapped = mapComboToModelV2(combo, memberEntries, resolved.providerId, baseURL); + + // Apply enrichment overlay to combos too (OmniRoute's + // /api/pricing/models surfaces combos alongside provider-scoped + // models with curated names). + applyEnrichment(mapped, rawEnrichment.get(combo.id)); + + // Optionally decorate combo name with its compression pipeline. + // Only fires when features.compressionMetadata: true and OmniRoute + // returned at least one default compression combo. + if (defaultCompression && defaultCompression.pipeline.length > 0) { + const tag = formatCompressionPipeline(defaultCompression.pipeline); + if (tag.length > 0 && !mapped.name.includes(tag)) { + mapped.name = `${mapped.name} ${tag}`; + } + } + + // Collision policy: combos win. Warn ONCE per (cacheKey, comboId) + // when overwriting a same-id raw model so the operator can spot + // the unusual naming choice without log spam. + if (Object.prototype.hasOwnProperty.call(models, combo.id)) { + const dedupeKey = `${cacheKey}::${combo.id}`; + if (!collisionWarned.has(dedupeKey)) { + collisionWarned.add(dedupeKey); + console.warn( + `[omniroute-plugin] combo id "${combo.id}" collides with a model id; combo wins.` + ); + } + } + models[combo.id] = mapped; + } + + return models; + }, + }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Fetch interceptor (T-04) — Bearer + Content-Type injection on outbound +// provider requests targeting the configured OmniRoute baseURL +// ──────────────────────────────────────────────────────────────────────────── + +/** + * Build a `fetch`-compatible interceptor that injects `Authorization: Bearer` + * (and a default `Content-Type`) onto outbound requests targeting the given + * `baseURL`. Requests to any other host pass through untouched — the apiKey + * is treated as a secret bound to the configured OmniRoute instance and + * MUST NOT leak to third-party endpoints (a vector AI-SDKs occasionally + * exercise when a tool call rewrites the URL mid-flight). + * + * Ported from Alph4d0g's `opencode-omniroute-auth@1.2.1` `createFetchInterceptor` + * (their `dist/src/plugin.js:477-516`) with these intentional deviations: + * + * - **`baseURL` is required** here (no `localhost:20128/v1` fallback). T-04 + * callers always have an authoritative baseURL (from plugin opts or + * auth.json); a silent local default would be a footgun. + * - **Content-Type defaulting is gated on `init.body` presence**. Their + * version unconditionally sets `application/json` even on `GET /v1/models`, + * which is harmless but noisy; we only set it when there's a body to + * describe. + * - **Gemini schema sanitisation is NOT applied here** — that's T-06's + * responsibility and will land as a body-transform step inside this + * same function (or as a thin wrapper around it). + * - **Header merge strategy mirrors theirs**: Request-attached headers + * first, then `init.headers` overlay, then our injected + * Authorization/Content-Type — so the apiKey we own ALWAYS wins over + * any caller-supplied Bearer for the same OmniRoute provider. + * + * @see https://opencode.ai/docs/plugins for the AuthLoaderResult.fetch contract + * (the returned function is invoked by the AI-SDK in lieu of global fetch). + */ +export function createOmniRouteFetchInterceptor(config: { + apiKey: string; + baseURL: string; +}): typeof fetch { + const trimmed = config.baseURL.replace(/\/+$/, ""); + // Use `/` for prefix matching to prevent suffix-spoof attacks + // (e.g. baseURL `https://or.example.com/v1` should NOT match + // `https://or.example.com/v1-attacker.evil/...`). + const prefix = `${trimmed}/`; + return async (input, init = {}) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + + const targetsOmniRoute = url === trimmed || url.startsWith(prefix); + if (!targetsOmniRoute) { + return fetch(input, init); + } + + // Merge order: Request-attached headers (when input is a Request) → + // init.headers overlay → our injected headers last (so we win). + const headers = new Headers(input instanceof Request ? input.headers : undefined); + if (init.headers) { + const initHeaders = new Headers(init.headers); + initHeaders.forEach((value, key) => { + headers.set(key, value); + }); + } + + headers.set("Authorization", `Bearer ${config.apiKey}`); + // Only default Content-Type when the caller actually has a body AND + // hasn't already declared the media type themselves. + const hasBody = init.body != null || input instanceof Request; + if (!headers.has("Content-Type") && hasBody) { + headers.set("Content-Type", "application/json"); + } + + return fetch(input, { ...init, headers }); + }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Gemini tool-schema sanitisation (T-06) — strip JSON-schema keywords that +// the Gemini API rejects from outbound chat-completion / responses bodies +// when the target model is a Gemini variant. +// ──────────────────────────────────────────────────────────────────────────── + +/** + * JSON-Schema keywords that the Gemini API rejects when present anywhere in + * a function-calling tool definition. Standard OpenAI / Anthropic clients + * happily emit these (they're valid Draft-07 schema) but Gemini's tool + * validator throws on them, breaking OmniRoute → Gemini chains transparently. + * + * Source: behavioural reverse-engineering from Alph4d0g's + * opencode-omniroute-auth@1.2.1 (dist/src/plugin.js:517). + */ +const GEMINI_SCHEMA_KEYS_TO_REMOVE = new Set(["$schema", "$ref", "ref", "additionalProperties"]); + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +/** + * Recursively strip `GEMINI_SCHEMA_KEYS_TO_REMOVE` from an arbitrary + * JSON-Schema-shaped record. Walks both the record's own properties and + * any nested objects / arrays so deeply nested `properties.x.properties.y` + * trees are reached without a separate traversal pass. Mutates in place + * and reports whether any key was deleted so callers can skip a + * `JSON.stringify` round-trip when nothing changed. + */ +function stripSchemaKeys(schema: Record): boolean { + let changed = false; + for (const key of Object.keys(schema)) { + if (GEMINI_SCHEMA_KEYS_TO_REMOVE.has(key)) { + delete schema[key]; + changed = true; + continue; + } + const value = schema[key]; + if (Array.isArray(value)) { + for (const item of value) { + if (isRecord(item)) { + changed = stripSchemaKeys(item) || changed; + } + } + continue; + } + if (isRecord(value)) { + changed = stripSchemaKeys(value) || changed; + } + } + return changed; +} + +/** + * Walk every tool definition in the payload and strip Gemini-incompatible + * schema keywords. Handles both chat-completion shape + * (`tools[].function.parameters`) and Responses-API shape + * (`tools[].input_schema`), plus the Gemini-native `function_declaration` + * variant some adapters use. + * + * Also strips top-level schema keywords from the payload itself — clients + * occasionally attach a top-level `$schema` declaration when re-serialising + * tool bundles, and Gemini rejects those too. + */ +function sanitizeToolSchemaContainer(payload: Record): boolean { + let changed = false; + // Top-level keyword strip — covers payload-level `$schema` etc. + for (const key of Object.keys(payload)) { + if (GEMINI_SCHEMA_KEYS_TO_REMOVE.has(key)) { + delete payload[key]; + changed = true; + } + } + const tools = (payload as { tools?: unknown }).tools; + if (!Array.isArray(tools)) { + return changed; + } + for (const tool of tools) { + if (!isRecord(tool)) continue; + const fn = (tool as { function?: unknown }).function; + if (isRecord(fn) && isRecord((fn as { parameters?: unknown }).parameters)) { + changed = stripSchemaKeys(fn.parameters as Record) || changed; + } + const fnDecl = (tool as { function_declaration?: unknown }).function_declaration; + if (isRecord(fnDecl) && isRecord((fnDecl as { parameters?: unknown }).parameters)) { + changed = stripSchemaKeys(fnDecl.parameters as Record) || changed; + } + const inputSchema = (tool as { input_schema?: unknown }).input_schema; + if (isRecord(inputSchema)) { + changed = stripSchemaKeys(inputSchema) || changed; + } + } + return changed; +} + +/** + * Pure function — recursively strip Gemini-incompatible JSON-Schema + * keywords (`$schema`, `$ref`, `ref`, `additionalProperties`) from the + * tool definitions on a chat-completions / responses payload. + * + * Walks: + * - `payload.tools[].function.parameters` (OpenAI chat-completions shape) + * - `payload.tools[].function_declaration.parameters` (Gemini-native shape + * some adapters round-trip) + * - `payload.tools[].input_schema` (Responses-API shape) + * - all `properties.` (and `properties..properties.`…) inside + * each container, recursing through nested objects and arrays. + * - top-level payload keys (some clients attach a payload-level `$schema`). + * + * Returns the cleaned payload. Does NOT mutate input — clones first via + * `structuredClone` so callers can keep a reference to the original. If + * the payload is not a record, or carries no tools and no top-level + * stripped keys, returns a (still cloned) equivalent. + * + * Exported so the body-transform layer is unit-testable independent of the + * fetch wrapper. + */ +export function sanitizeGeminiToolSchemas(payload: unknown): unknown { + if (!isRecord(payload)) { + // Non-record payloads (string, array, number, null) can't carry tool + // schemas. Pass back the same value — there's nothing to clone-and-strip + // and propagating the original keeps caller semantics simple. + return payload; + } + // structuredClone is available in Node 18+; the package's engines field + // already requires Node >=22.22.3 so we can rely on it without a + // JSON round-trip fallback. + const cloned = structuredClone(payload) as Record; + sanitizeToolSchemaContainer(cloned); + return cloned; +} + +/** + * Detect whether a payload is bound for a Gemini model. Returns true if + * `payload.model` is a string AND matches any known Gemini routing pattern: + * + * - case-insensitive substring `gemini` (covers bare `gemini-1.5-pro`, + * `gemini-2.5-flash`, etc.) + * - `models/gemini-…` (Google Generative AI canonical id form) + * - `google-vertex/gemini-…` (OpenCode + AI-SDK Vertex routing prefix) + * - `gemini-cli/…` (real OmniRoute alias surfaced on b35 prod `/v1/models`) + * + * Liberal by design: a false positive (cleaning a payload that didn't + * need cleaning) costs only a structuredClone + one walk; a false negative + * breaks the whole chain by forwarding $schema/additionalProperties to + * Gemini which throws 400 INVALID_ARGUMENT. The first three checks + * collapse into the case-insensitive substring check, but they're + * documented separately so future maintainers see the intent. + * + * Exported so callers and tests can probe detection independent of the + * fetch wrapper. + */ +export function shouldSanitizeForGemini(payload: unknown): boolean { + if (!isRecord(payload)) return false; + const model = (payload as { model?: unknown }).model; + if (typeof model !== "string") return false; + return /gemini/i.test(model); +} + +/** + * Module-level latch so the streaming-body warning fires AT MOST once per + * Node process. ReadableStream bodies can't be safely cloned + JSON-parsed + * without consuming the stream (and re-creating a stream that survives both + * read paths is non-trivial), so the sanitiser skips them — but we want + * the operator to see one heads-up that schema stripping won't run on + * those requests. + */ +let geminiStreamingWarningEmitted = false; + +/** + * Wrapper over an inner `fetch` that applies Gemini schema sanitisation to + * outbound chat-completion / responses request bodies. + * + * Behaviour: + * - URL gate: only inspects requests whose URL path contains + * `/chat/completions` or `/responses` (lenient about prefix — works for + * `/v1/chat/completions`, `/openai/v1/chat/completions`, …). + * - Body extraction handles `string`, `Buffer` / `Uint8Array`, + * `URLSearchParams` (calls `.toString()`), `Blob` (`await .text()`), + * AND `Request` input where the body lives on the Request not init. + * `ReadableStream` bodies are skipped (see below). + * - Body must JSON.parse to a record; otherwise pass-through. + * - `shouldSanitizeForGemini` gates the actual transform — non-Gemini + * payloads pass through unchanged regardless of endpoint. + * - Fail-open: ANY error during extraction / parse / sanitise falls back + * to forwarding the original `(input, init)` to the inner fetch. + * Sanitisation is a best-effort guard, never a hard failure mode. + * - `ReadableStream` bodies → skipped with a ONE-TIME `console.warn`. + * The Gemini-quirk only manifests with tool calls in the body, and + * OC streams plain text deltas; the operator should still know. + * + * @param inner The next fetch in the chain (typically the Bearer-injecting + * interceptor from `createOmniRouteFetchInterceptor`). + */ +export function createGeminiSanitizingFetch(inner: typeof fetch): typeof fetch { + return async (input, init) => { + try { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input instanceof Request + ? input.url + : ""; + + // URL gate — match the path substring with prefix tolerance. + const targetsCompletions = url.includes("/chat/completions") || url.includes("/responses"); + if (!targetsCompletions) { + return inner(input, init); + } + + // Body extraction. Cover the body shapes the AI-SDK + adapter layer + // actually emit; bail to pass-through on anything we can't read + // synchronously without consuming a stream. + let rawBody: string | undefined; + const initBody = init?.body as unknown; + + if (typeof initBody === "string") { + rawBody = initBody; + } else if (initBody instanceof URLSearchParams) { + // Form-encoded bodies are never chat-completion JSON; pass-through. + return inner(input, init); + } else if (typeof Buffer !== "undefined" && initBody instanceof Buffer) { + rawBody = initBody.toString("utf8"); + } else if (initBody instanceof Uint8Array) { + rawBody = new TextDecoder().decode(initBody); + } else if (initBody instanceof ReadableStream) { + // Streaming body — skip with one-shot warning. + if (!geminiStreamingWarningEmitted) { + geminiStreamingWarningEmitted = true; + // eslint-disable-next-line no-console + console.warn( + "[omniroute-plugin] sanitizeGemini: streaming Request body, skipping schema strip (Gemini may reject)" + ); + } + return inner(input, init); + } else if ( + initBody !== null && + initBody !== undefined && + typeof (initBody as { text?: unknown }).text === "function" + ) { + // Blob-like (has .text(): Promise). Streaming was already + // matched above — anything left with a `.text` method we can buffer. + try { + rawBody = await (initBody as { text(): Promise }).text(); + } catch { + return inner(input, init); + } + } else if (initBody === undefined && input instanceof Request) { + // Body lives on the Request object itself, not init. Clone before + // reading — consuming the original Request body would make it + // unreadable downstream. + try { + rawBody = await (input as Request).clone().text(); + } catch { + return inner(input, init); + } + } + + if (rawBody === undefined || rawBody.length === 0) { + return inner(input, init); + } + + let payload: unknown; + try { + payload = JSON.parse(rawBody); + } catch { + // Non-JSON body → pass-through, never throw. + return inner(input, init); + } + + if (!shouldSanitizeForGemini(payload)) { + return inner(input, init); + } + + const cleaned = sanitizeGeminiToolSchemas(payload); + const newBody = JSON.stringify(cleaned); + // Cloning init: we need to replace `body` without mutating the caller's + // init bag. If init was undefined (Request-input path), construct one. + const newInit: RequestInit = { ...(init ?? {}), body: newBody }; + return inner(input, newInit); + } catch { + // Total fail-open — never let a sanitiser bug break the request path. + return inner(input, init); + } + }; +} + +/** + * Test-only hook: reset the module-level streaming-warning latch so each + * test can independently assert the one-shot semantics. Not part of the + * public stability contract — prefixed with `__` per convention to signal + * "do not depend on this from production code". + */ +export function __resetGeminiStreamingWarning(): void { + geminiStreamingWarningEmitted = false; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Config hook (T-07) — backward-compat shim for OC ≤1.14.48 +// +// OC ≤1.14.48 does NOT call `provider.models()` at startup; it reads the +// catalog from the static `provider.` config block instead. OC ≥1.14.49 +// calls `provider.models()` dynamically AND merges the dynamic catalog over +// any static block (dynamic wins on collision). To support both, the plugin +// publishes a static block via `config` AND a dynamic one via `provider.models` +// — OC's resolution order picks the right one per OC version. This module +// implements the static-publish half. +// +// Sibling shape source-of-truth: see +// `@omniroute/opencode-provider/src/index.ts` (`createOmniRouteProvider`, +// `OpenCodeProviderEntry`, `OpenCodeModelEntry`). We replicate that shape +// here rather than depending on the sibling package — the plugin must stay +// self-contained (npm-installable on its own, no peer dep on the provider +// builder). +// ──────────────────────────────────────────────────────────────────────────── + +/** + * Per-model entry shape under `provider..models[modelId]`. Mirrors + * `OpenCodeModelEntry` exported by `@omniroute/opencode-provider`. Stripped + * down to the fields OC's static catalog reader actually consumes — NOT a + * full ModelV2 (that's the dynamic-hook shape). Optional fields are omitted + * when OmniRoute didn't surface a value, NOT emitted as `undefined` — the + * resulting JSON must be diffable across OmniRoute deployments without + * `undefined` noise. + */ +export interface OmniRouteStaticModelEntry { + /** Display label rendered in OC's model picker. Defaults to the model id. */ + name: string; + /** Model accepts image / file attachments. */ + attachment?: boolean; + /** Model exposes a reasoning / extended-thinking surface. */ + reasoning?: boolean; + /** Model honours the `temperature` parameter. */ + temperature?: boolean; + /** Model supports function / tool calling. */ + tool_call?: boolean; + /** Context-window limits. */ + limit?: { + context: number; + input?: number; + output?: number; + }; +} + +/** + * Static `provider.` block written to `input.provider` by the config hook. + * Mirrors `OpenCodeProviderEntry` from `@omniroute/opencode-provider`. + * + * - `npm` is always `"@ai-sdk/openai-compatible"` — OmniRoute exposes an + * OpenAI-compatible surface and that's the AI-SDK adapter that speaks it. + * - `options.baseURL` MUST be the fully-qualified `/v1` URL (the AI-SDK + * appends paths like `/chat/completions` directly under it). + * - `options.apiKey` is the bearer token; the fetch interceptor (T-04) + * also injects it on the dynamic path, but the static block needs it + * embedded too so OC ≤1.14.48 can construct the SDK client without + * going through the auth hook. + */ +export interface OmniRouteStaticProviderEntry { + npm: "@ai-sdk/openai-compatible"; + name: string; + options: { + baseURL: string; + apiKey: string; + }; + models: Record; +} + +/** + * Build the static `provider.` block from raw `/v1/models` + `/api/combos` + * responses. Pure function — no I/O, no side effects, no dependency on the + * sibling provider package. Exported so callers and tests can construct the + * block independently of the auth.json + fetch pipeline. + * + * Mapping rules (per the sibling `createOmniRouteProvider` output spec): + * + * - One entry per raw model AND one entry per non-hidden combo. + * - `name` = model id (no separate display name on `/v1/models`). + * - `attachment` = `caps.attachment ?? caps.vision ?? false` — same + * convention as `mapRawModelToModelV2` (T-03). + * - `reasoning` = `caps.reasoning || caps.thinking`. Booleans only — we + * do NOT emit the field when both source flags are absent (keeps the + * stripped shape minimal). + * - `temperature` = `caps.temperature ?? true` — OpenAI-compat surface + * supports temperature by default; only an explicit `false` suppresses. + * - `tool_call` = `caps.tool_calling ?? false`. + * - `limit.context` = raw `context_length` when > 0; omitted otherwise. + * - `limit.input` = raw `max_input_tokens` when present. + * - `limit.output` = raw `max_output_tokens` when present. + * + * For combos: LCD across member raw models (matches `mapComboToModelV2`): + * + * - `attachment`, `reasoning`, `tool_call`, `temperature`: `every` member. + * - `limit.context` = min(member context_lengths). + * - `limit.input` = min(member max_input_tokens) ONLY when every member + * declares one. + * - `limit.output` = min(member max_output_tokens). + * - Empty members → all-false / limits omitted. + * + * Collision: combos win (matches the dynamic provider hook). + * + * @param rawModels Raw `/v1/models` entries (may be empty). + * @param rawCombos Raw `/api/combos` entries (may be empty). + * @param opts Resolved plugin options (we read `displayName` + `providerId`). + * @param baseURL Fully-qualified `/v1` base URL — written verbatim to + * `options.baseURL`. Caller is responsible for `/v1` + * normalisation; we do NOT touch it here. + * @param apiKey Bearer token — written verbatim to `options.apiKey`. + */ +export function buildStaticProviderEntry( + rawModels: OmniRouteRawModelEntry[], + rawCombos: OmniRouteRawCombo[], + opts: ReturnType, + baseURL: string, + apiKey: string +): OmniRouteStaticProviderEntry { + const models: Record = {}; + + // Raw model entries → stripped per-model shape. + for (const raw of rawModels) { + if (!raw.id) continue; + const caps = raw.capabilities ?? {}; + const entry: OmniRouteStaticModelEntry = { name: raw.id }; + + const attachment = caps.attachment ?? caps.vision; + if (typeof attachment === "boolean") entry.attachment = attachment; + + if (typeof caps.reasoning === "boolean" || typeof caps.thinking === "boolean") { + entry.reasoning = Boolean(caps.reasoning || caps.thinking); + } + + if (typeof caps.temperature === "boolean") { + entry.temperature = caps.temperature; + } + + if (typeof caps.tool_calling === "boolean") { + entry.tool_call = caps.tool_calling; + } + + const limit: OmniRouteStaticModelEntry["limit"] = {} as { context: number }; + let hasLimit = false; + if (typeof raw.context_length === "number" && raw.context_length > 0) { + (limit as { context: number }).context = raw.context_length; + hasLimit = true; + } + if (typeof raw.max_input_tokens === "number" && raw.max_input_tokens > 0) { + (limit as { input?: number }).input = raw.max_input_tokens; + hasLimit = true; + } + if (typeof raw.max_output_tokens === "number" && raw.max_output_tokens > 0) { + (limit as { output?: number }).output = raw.max_output_tokens; + hasLimit = true; + } + if (hasLimit) { + // Static shape requires `context: number` when limit is present — + // fill with 0 when only input/output were declared (matches the + // sibling provider's behaviour for partial limits). + if (typeof (limit as { context?: number }).context !== "number") { + (limit as { context: number }).context = 0; + } + entry.limit = limit as OmniRouteStaticModelEntry["limit"]; + } + + models[raw.id] = entry; + } + + // Combo entries → stripped LCD shape. Combos win on id collision (matches + // the dynamic provider hook's resolution order — see T-05). + const rawModelById = new Map(); + for (const m of rawModels) { + if (m.id) rawModelById.set(m.id, m); + } + + for (const combo of rawCombos) { + if (!combo.id) continue; + if (combo.isHidden === true) continue; + + const memberSteps = Array.isArray(combo.models) ? combo.models : []; + const memberEntries: OmniRouteRawModelEntry[] = []; + for (const step of memberSteps) { + const modelId = (step as unknown as { model?: unknown }).model; + if (typeof modelId !== "string" || modelId.length === 0) continue; + const member = rawModelById.get(modelId); + if (member) memberEntries.push(member); + } + + const hasMembers = memberEntries.length > 0; + const displayName = combo.name && combo.name.trim().length > 0 ? combo.name : combo.id; + const entry: OmniRouteStaticModelEntry = { name: displayName }; + + if (hasMembers) { + // LCD across capabilities — every member must support for the combo + // to support. Mirrors mapComboToModelV2. + entry.attachment = memberEntries.every((m) => + Boolean(m.capabilities?.attachment ?? m.capabilities?.vision ?? false) + ); + entry.reasoning = memberEntries.every((m) => + Boolean(m.capabilities?.reasoning || m.capabilities?.thinking) + ); + entry.temperature = memberEntries.every( + (m) => (m.capabilities?.temperature ?? true) !== false + ); + entry.tool_call = memberEntries.every((m) => Boolean(m.capabilities?.tool_calling ?? false)); + + // LCD across limits — min over declared values, omit `input` unless + // EVERY member declares one (matches mapComboToModelV2). + const contextValues = memberEntries + .map((m) => m.context_length) + .filter((v): v is number => typeof v === "number" && v > 0); + const outputValues = memberEntries + .map((m) => m.max_output_tokens) + .filter((v): v is number => typeof v === "number" && v > 0); + const inputValues = memberEntries + .map((m) => m.max_input_tokens) + .filter((v): v is number => typeof v === "number" && v > 0); + const everyDeclaresInput = inputValues.length === memberEntries.length; + + if (contextValues.length > 0 || outputValues.length > 0 || everyDeclaresInput) { + const limit = {} as { context: number; input?: number; output?: number }; + limit.context = contextValues.length > 0 ? Math.min(...contextValues) : 0; + if (everyDeclaresInput && inputValues.length > 0) { + limit.input = Math.min(...inputValues); + } + if (outputValues.length > 0) { + limit.output = Math.min(...outputValues); + } + entry.limit = limit; + } + } else { + // Empty members → safety posture: all caps false. Caller's OC picker + // will grey out an unroutable combo rather than promise capabilities + // we can't honour. + entry.attachment = false; + entry.reasoning = false; + entry.temperature = false; + entry.tool_call = false; + } + + models[combo.id] = entry; + } + + return { + npm: "@ai-sdk/openai-compatible", + name: opts.displayName, + options: { baseURL, apiKey }, + models, + }; +} + +/** + * Shape we expect inside `auth.json`. The file is keyed by providerId, with + * each entry being a flavor-tagged credential. Today only the `api` flavor + * is consumed by this plugin (OAuth + WellKnown flavors are passed through + * but never decoded into a static block). + */ +interface AuthJsonApiEntry { + type: "api"; + key: string; + baseURL?: string; +} + +type AuthJsonShape = Record; + +/** + * Read & parse `auth.json` from OC's data dir. The path resolution mirrors + * OC core's: + * + * `${OPENCODE_DATA_DIR ?? path.join(os.homedir(), ".local/share/opencode")}/auth.json` + * + * Returns `undefined` when the file is missing (most-common case on a fresh + * install — silent no-op). Returns `null` when the file exists but doesn't + * parse as JSON (logs ONE warn so the operator sees the corruption). + * + * Exported as a dependency-injectable function on `createOmniRouteConfigHook` + * so tests can stub it without monkey-patching `node:fs/promises`. + */ +export type OmniRouteReadAuthJson = () => Promise; + +export const defaultReadAuthJson: OmniRouteReadAuthJson = async () => { + const dir = process.env.OPENCODE_DATA_DIR ?? path.join(os.homedir(), ".local/share/opencode"); + const file = path.join(dir, "auth.json"); + let body: string; + try { + body = await readFile(file, "utf8"); + } catch { + // File missing or unreadable — silent no-op. This is the expected path + // on a fresh install BEFORE `/connect` has been run. + return undefined; + } + try { + const parsed = JSON.parse(body) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as AuthJsonShape; + } + return null; + } catch { + return null; + } +}; + +/** + * Build the config-hook portion of the plugin for a given options bag. + * Exported standalone so the contract is unit-testable without faking the + * full PluginInput / Hooks surface, and so multi-instance setups can each + * own their own (auth.json reader, fetch cache, fetcher) trio. + * + * Behavioural contract: + * - Runs BEFORE `auth.loader` in the OC startup sequence (per the + * @opencode-ai/plugin contract). `getAuth()` is NOT available here, + * so we read `auth.json` directly via the injected reader. + * - No-op when: + * (a) `auth.json` is missing / unreadable (fresh install before + * `/connect`), + * (b) `auth.json[providerId]` is missing or not type-api, + * (c) `apiKey` is empty after extraction, + * (d) `baseURL` is unresolvable (neither opts.baseURL nor + * `auth.json[providerId].baseURL`), + * (e) `input.provider[providerId]` is ALREADY set (operator override + * wins — we never clobber manually-curated catalogs). + * Each no-op path emits ONE debug-level breadcrumb to `console.warn` + * so the operator can diagnose without log spam. Malformed `auth.json` + * warns once and continues as if the file were missing. + * - Fail-open on fetcher errors: a `/v1/models` failure → still publish + * a stub `{models: {}}` provider block (so OC has a complete-shape + * entry to render). A `/api/combos` failure → publish models-only. + * Both paths emit ONE `console.warn`. + * - When the provider hook (T-03/T-05) has ALREADY populated the shared + * cache for this (baseURL, apiKey) tuple, we reuse the raw payloads + * directly — no second fetch. (And vice-versa: the config hook fires + * first on OC ≥1.14.49 cold start, populating the cache for the + * provider hook moments later.) + * - DUAL-PUBLISH SAFE: on OC ≥1.14.49 BOTH this static block and the + * dynamic `provider.models()` result will land in OC's catalog + * reducer. The dynamic block wins by OC's own merge rule — see + * OpenCode core's provider resolution order — so emitting both is a + * correctness-positive: ≤1.14.48 reads static, ≥1.14.49 prefers + * dynamic but the static one keeps things responsive during the + * ~50ms window before the dynamic fetch resolves. + * + * @param opts Plugin options (validated, resolved with defaults). + * @param deps Dependency injection. + * - `readAuthJson` — replaces `defaultReadAuthJson` (test stub). + * - `fetcher` — replaces `defaultOmniRouteModelsFetcher`. + * - `combosFetcher` — replaces `defaultOmniRouteCombosFetcher`. + * - `now` — clock for cache TTL (default `Date.now`). + * - `cache` — shared fetch-result cache (see + * `OmniRouteFetchCache`). Pass the same Map the + * provider hook owns to dedupe round-trips. + * - `logger` — `{warn}` sink for breadcrumb capture in tests. + * Defaults to `console`. + */ +export function createOmniRouteConfigHook( + opts?: OmniRoutePluginOptions, + deps: { + readAuthJson?: OmniRouteReadAuthJson; + fetcher?: OmniRouteModelsFetcher; + combosFetcher?: OmniRouteCombosFetcher; + now?: () => number; + cache?: OmniRouteFetchCache; + logger?: { warn: (...args: unknown[]) => void }; + } = {} +): (input: Config) => Promise { + const resolved = resolveOmniRoutePluginOptions(opts); + const readAuthJson = deps.readAuthJson ?? defaultReadAuthJson; + const fetcher = deps.fetcher ?? defaultOmniRouteModelsFetcher; + const combosFetcher = deps.combosFetcher ?? defaultOmniRouteCombosFetcher; + const now = deps.now ?? Date.now; + const cache: OmniRouteFetchCache = deps.cache ?? new Map(); + const logger = deps.logger ?? console; + + return async (input: Config) => { + // (e) operator override — `input.provider[providerId]` already set → + // leave it alone. Manually curated catalogs ALWAYS win over the plugin's + // generated block. Detect-and-respect before any I/O. + const existingProviders = (input as { provider?: Record }).provider; + if (existingProviders && existingProviders[resolved.providerId] !== undefined) { + logger.warn( + `[omniroute-plugin] config shim skipped: provider.${resolved.providerId} already set by user` + ); + return; + } + + // Read auth.json. `undefined` = missing file (silent path), `null` = + // malformed JSON (warn once and treat as missing). + let authJson: AuthJsonShape | undefined | null; + try { + authJson = await readAuthJson(); + } catch { + // Reader threw — be conservative and treat like a missing file. + authJson = undefined; + } + + if (authJson === null) { + logger.warn("[omniroute-plugin] config shim: auth.json failed to parse; treating as missing"); + authJson = undefined; + } + + const entry = authJson?.[resolved.providerId] as AuthJsonApiEntry | undefined; + const apiKey = entry && entry.type === "api" && typeof entry.key === "string" ? entry.key : ""; + + if (!apiKey) { + // (c) no apiKey — silent no-op (with debug breadcrumb). The operator + // hasn't run `/connect ` yet, OR the stored credential + // isn't api-flavored. OC will handle the `/connect` flow at runtime. + logger.warn( + `[omniroute-plugin] config shim skipped: no apiKey for providerId=${resolved.providerId}` + ); + return; + } + + // baseURL resolution: opts.baseURL wins, then auth.json's stored baseURL. + // No silent localhost default — a misconfigured plugin should surface a + // breadcrumb and skip, not phantom requests. + const storedBaseURL = entry && typeof entry.baseURL === "string" ? entry.baseURL : undefined; + const baseURL = resolved.baseURL ?? storedBaseURL ?? ""; + if (!baseURL) { + logger.warn( + `[omniroute-plugin] config shim skipped: no baseURL for providerId=${resolved.providerId}` + ); + return; + } + + // Try the shared cache first. On OC ≥1.14.49 the provider hook may have + // populated it moments earlier; on OC ≤1.14.48 only this hook runs but + // the cache still works (single producer + consumer through one Map). + const cacheKey = modelsCacheKey(baseURL, apiKey); + const t = now(); + const cached = cache.get(cacheKey); + + let rawModels: OmniRouteRawModelEntry[]; + let rawCombos: OmniRouteRawCombo[]; + + if (cached && cached.expiresAt > t) { + rawModels = cached.rawModels; + rawCombos = cached.rawCombos; + } else { + // Fail-open fetcher errors: on /v1/models throw, fall back to empty + // catalog (still publish a stub block so OC has a complete-shape + // entry); on /api/combos throw, publish models-only. + try { + rawModels = await fetcher(baseURL, apiKey, 10_000); + } catch (err) { + logger.warn( + "[omniroute-plugin] config shim: /v1/models fetch failed; publishing stub provider entry", + err + ); + rawModels = []; + } + + rawCombos = []; + try { + rawCombos = await combosFetcher(baseURL, apiKey, 10_000); + } catch (err) { + logger.warn( + "[omniroute-plugin] config shim: /api/combos fetch failed; publishing models-only static catalog", + err + ); + } + + // Cache even partial results — a subsequent provider-hook call should + // not re-burn the timeout window on the same broken endpoint. + // Config-hook never fetches enrichment/compression directly: the + // static block doesn't surface them today (sibling shape is name+ + // capability only). Provider-hook may fetch them later and write + // back into the same cache key; we seed empty values so the cache + // entry shape remains consistent. + cache.set(cacheKey, { + rawModels, + rawCombos, + rawEnrichment: new Map(), + rawCompressionCombos: [], + expiresAt: t + resolved.modelCacheTtl, + }); + } + + const block = buildStaticProviderEntry(rawModels, rawCombos, resolved, baseURL, apiKey); + + // Mutate the input.provider map. The Config type declares + // `provider?: {[key: string]: ProviderConfig}` — we initialise the + // bag when absent so users who never set `provider` in opencode.json + // still get the static block. + const inputWithProvider = input as { provider?: Record }; + if (!inputWithProvider.provider) { + inputWithProvider.provider = {}; + } + inputWithProvider.provider[resolved.providerId] = block; + + // ───────────────────────────────────────────────────────────────────── + // MCP auto-emit — opt-in via features.mcpAutoEmit. When enabled, writes + // an `input.mcp[]` remote entry pointing at + // `/api/mcp/stream` with the resolved Bearer token. Token + // resolution: features.mcpToken wins if set; otherwise falls back to + // the same apiKey used for chat. Operator overrides win (same posture + // as provider-block emit): if input.mcp[providerId] is already set, + // we leave it alone. + // ───────────────────────────────────────────────────────────────────── + const features = resolved.features ?? {}; + if (features.mcpAutoEmit === true) { + const mcpKey = features.mcpToken ?? apiKey; + if (!mcpKey) { + logger.warn( + `[omniroute-plugin] mcp auto-emit skipped: no Bearer token for providerId=${resolved.providerId}` + ); + } else { + const inputWithMcp = input as { mcp?: Record }; + if (!inputWithMcp.mcp) { + inputWithMcp.mcp = {}; + } + if (inputWithMcp.mcp[resolved.providerId] !== undefined) { + logger.warn( + `[omniroute-plugin] mcp auto-emit skipped: mcp.${resolved.providerId} already set by user` + ); + } else { + // Strip a trailing `/v1` from baseURL when present so we land on + // the MCP transport at /api/mcp/stream, not /v1/api/mcp/stream. + const mcpRoot = baseURL.replace(/\/v1\/?$/, "").replace(/\/$/, ""); + inputWithMcp.mcp[resolved.providerId] = { + type: "remote", + url: `${mcpRoot}/api/mcp/stream`, + enabled: true, + headers: { + Authorization: `Bearer ${mcpKey}`, + }, + }; + } + } + } + }; +} diff --git a/@omniroute/opencode-plugin/tests/auth.test.ts b/@omniroute/opencode-plugin/tests/auth.test.ts new file mode 100644 index 0000000000..ee18a692ac --- /dev/null +++ b/@omniroute/opencode-plugin/tests/auth.test.ts @@ -0,0 +1,112 @@ +/** + * T-02 auth-hook contract tests. + * + * Covers the `createOmniRouteAuthHook(opts)` factory and its loader behaviour + * against every Auth flavor (`api`, `oauth`, null, empty key). Validates the + * multi-instance fix: provider id flows from plugin options, not a module + * constant. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { createOmniRouteAuthHook } from "../src/index.js"; + +test("createOmniRouteAuthHook: default providerId is 'omniroute'", () => { + const hook = createOmniRouteAuthHook(); + assert.equal(hook.provider, "omniroute"); +}); + +test("createOmniRouteAuthHook: custom providerId binds to hook.provider (multi-instance)", () => { + const hook = createOmniRouteAuthHook({ providerId: "omniroute-preprod" }); + assert.equal(hook.provider, "omniroute-preprod"); +}); + +test("createOmniRouteAuthHook: methods[0] is type 'api' with label including displayName", () => { + const hook = createOmniRouteAuthHook(); + assert.equal(Array.isArray(hook.methods), true); + assert.equal(hook.methods.length, 1); + const m = hook.methods[0]; + assert.equal(m.type, "api"); + assert.equal(m.label, "OmniRoute API Key"); + + const custom = createOmniRouteAuthHook({ providerId: "omniroute-preprod" }); + assert.equal(custom.methods[0].label, "OmniRoute (omniroute-preprod) API Key"); +}); + +test("createOmniRouteAuthHook: prompts[0] uses key='apiKey' per @opencode-ai/plugin contract", () => { + // NOTE: spec referenced `name: "apiKey"`; the official + // @opencode-ai/plugin@1.15.6 prompt shape uses `key` + `message` (no + // `name`/`label`/`mask` fields). Asserting against the real type contract. + const hook = createOmniRouteAuthHook(); + const m = hook.methods[0]; + assert.equal(m.type, "api"); + // narrow: api method may carry prompts + const prompts = "prompts" in m ? m.prompts : undefined; + assert.ok(Array.isArray(prompts) && prompts.length === 1, "expected one prompt"); + const p = prompts![0]; + assert.equal(p.type, "text"); + assert.equal((p as { key: string }).key, "apiKey"); + assert.ok( + typeof (p as { message: string }).message === "string" && + (p as { message: string }).message.includes("omniroute"), + "prompt message should mention provider id" + ); +}); + +test("loader: valid api auth → {apiKey} when no baseURL option (T-04: fetch omitted)", async () => { + // T-04 changed the loader return shape: without a resolvable baseURL the + // interceptor cannot gate-keep requests, so the loader falls back to + // apiKey-only and the AI-SDK uses its default fetch. See fetch-interceptor + // tests for the wired-fetch branches. + const hook = createOmniRouteAuthHook(); + assert.ok(hook.loader, "loader must be defined"); + const result = await hook.loader!( + async () => ({ type: "api", key: "sk-test" }) as never, + {} as never + ); + assert.deepEqual(result, { apiKey: "sk-test" }); +}); + +test("loader: valid api auth → {apiKey, baseURL, fetch} when baseURL option set (T-04)", async () => { + const hook = createOmniRouteAuthHook({ baseURL: "https://or.example.com/v1" }); + const result = await hook.loader!( + async () => ({ type: "api", key: "sk-x" }) as never, + {} as never + ); + assert.equal((result as { apiKey: string }).apiKey, "sk-x"); + assert.equal((result as { baseURL: string }).baseURL, "https://or.example.com/v1"); + assert.equal( + typeof (result as { fetch?: unknown }).fetch, + "function", + "T-04: loader must wire fetch interceptor when baseURL resolves" + ); +}); + +test("loader: null/undefined auth → {} (no creds yet, OC surfaces /connect)", async () => { + const hook = createOmniRouteAuthHook(); + const r1 = await hook.loader!(async () => null as never, {} as never); + assert.deepEqual(r1, {}); + const r2 = await hook.loader!(async () => undefined as never, {} as never); + assert.deepEqual(r2, {}); +}); + +test("loader: oauth-flavored auth → {} (wrong method type, ignored)", async () => { + const hook = createOmniRouteAuthHook(); + const result = await hook.loader!( + async () => + ({ + type: "oauth", + refresh: "r", + access: "a", + expires: 0, + }) as never, + {} as never + ); + assert.deepEqual(result, {}); +}); + +test("loader: api auth with empty key → {} (empty creds rejected)", async () => { + const hook = createOmniRouteAuthHook(); + const result = await hook.loader!(async () => ({ type: "api", key: "" }) as never, {} as never); + assert.deepEqual(result, {}); +}); diff --git a/@omniroute/opencode-plugin/tests/combos.test.ts b/@omniroute/opencode-plugin/tests/combos.test.ts new file mode 100644 index 0000000000..8aa6dc8ad8 --- /dev/null +++ b/@omniroute/opencode-plugin/tests/combos.test.ts @@ -0,0 +1,641 @@ +/** + * T-05 combo-discovery contract tests. + * + * Covers: + * - `defaultOmniRouteCombosFetcher(baseURL, apiKey, timeoutMs?)` + * — envelope tolerance (`{combos: [...]}` and bare array), non-2xx errors. + * - `mapComboToModelV2(combo, members, providerId, baseURL)` + * — LCD policy across capabilities, limits, modalities; defensive + * posture on empty members; nice-name preference. + * - `createOmniRouteProviderHook(opts, deps)` extension + * — combos merged into the models map; collision resolution (combo + * wins, warn-once); soft-fail when the combos fetcher throws; + * combos cached + reused under the same TTL key as models. + * + * Mocking strategy mirrors `provider.test.ts`: both fetchers are + * dependency-injected at hook construction, no `fetch` monkey-patch. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { + createOmniRouteProviderHook, + defaultOmniRouteCombosFetcher, + mapComboToModelV2, + type OmniRouteCombosFetcher, + type OmniRouteModelsFetcher, + type OmniRouteRawCombo, + type OmniRouteRawModelEntry, +} from "../src/index.js"; + +// ──────────────────────────────────────────────────────────────────────────── +// Fixtures +// ──────────────────────────────────────────────────────────────────────────── + +const MODEL_PRIMARY: OmniRouteRawModelEntry = { + id: "claude-primary", + capabilities: { + tool_calling: true, + reasoning: true, + vision: true, + thinking: true, + temperature: true, + }, + context_length: 200_000, + max_output_tokens: 64_000, + max_input_tokens: 180_000, + input_modalities: ["text", "image"], + output_modalities: ["text"], +}; + +const MODEL_SECONDARY: OmniRouteRawModelEntry = { + id: "claude-secondary", + capabilities: { + tool_calling: true, + reasoning: false, + vision: true, + thinking: false, + temperature: true, + }, + context_length: 100_000, + max_output_tokens: 32_000, + max_input_tokens: 96_000, + input_modalities: ["text", "image"], + output_modalities: ["text"], +}; + +const MODEL_NO_TOOLS: OmniRouteRawModelEntry = { + id: "gemini-3-flash", + capabilities: { tool_calling: false, reasoning: false, vision: false, thinking: false }, + context_length: 1_000_000, + max_output_tokens: 8_192, + input_modalities: ["text"], + output_modalities: ["text"], +}; + +const COMBO_CLAUDE_TIER: OmniRouteRawCombo = { + id: "combo-claude-tier", + name: "Claude Tier", + strategy: "priority", + models: [ + { id: "s1", kind: "model", model: "claude-primary", weight: 100 }, + { id: "s2", kind: "model", model: "claude-secondary", weight: 80 }, + ], +}; + +// ──────────────────────────────────────────────────────────────────────────── +// Helpers +// ──────────────────────────────────────────────────────────────────────────── + +function stubModelsFetcher( + payload: OmniRouteRawModelEntry[] +): OmniRouteModelsFetcher & { callCount: () => number } { + let n = 0; + const f: OmniRouteModelsFetcher = async () => { + n++; + return payload; + }; + return Object.assign(f, { callCount: () => n }); +} + +function stubCombosFetcher( + payload: OmniRouteRawCombo[] +): OmniRouteCombosFetcher & { callCount: () => number; callsBy: () => Array<[string, string]> } { + let n = 0; + const calls: Array<[string, string]> = []; + const f: OmniRouteCombosFetcher = async (baseURL, apiKey) => { + n++; + calls.push([baseURL, apiKey]); + return payload; + }; + return Object.assign(f, { + callCount: () => n, + callsBy: () => calls, + }); +} + +function failingCombosFetcher( + err = new Error("boom") +): OmniRouteCombosFetcher & { callCount: () => number } { + let n = 0; + const f: OmniRouteCombosFetcher = async () => { + n++; + throw err; + }; + return Object.assign(f, { callCount: () => n }); +} + +const apiAuth = (key: string): unknown => ({ type: "api", key }); + +// Capture console.warn invocations for the duration of a callback, then +// restore the original. Needed because the collision + soft-fail paths +// emit warnings we want to assert on. +async function withWarnCapture( + fn: (warnings: Array<{ args: unknown[] }>) => Promise +): Promise<{ result: T; warnings: Array<{ args: unknown[] }> }> { + const original = console.warn; + const warnings: Array<{ args: unknown[] }> = []; + console.warn = (...args: unknown[]) => { + warnings.push({ args }); + }; + try { + const result = await fn(warnings); + return { result, warnings }; + } finally { + console.warn = original; + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// defaultOmniRouteCombosFetcher — envelope tolerance + error surfacing +// ──────────────────────────────────────────────────────────────────────────── + +test("defaultOmniRouteCombosFetcher: parses {combos:[…]} envelope", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: unknown) => { + const url = typeof input === "string" ? input : (input as { url: string }).url; + assert.equal(url, "https://or.example.com/api/combos"); + return new Response( + JSON.stringify({ + combos: [ + { id: "c1", name: "Combo One", strategy: "priority", models: [] }, + { id: "c2", name: "Combo Two", strategy: "weighted", models: [] }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }) as typeof fetch; + try { + const combos = await defaultOmniRouteCombosFetcher("https://or.example.com", "sk-test"); + assert.equal(combos.length, 2); + assert.equal(combos[0].id, "c1"); + assert.equal(combos[1].id, "c2"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("defaultOmniRouteCombosFetcher: parses bare array envelope", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + return new Response(JSON.stringify([{ id: "c1" }, { id: "c2" }, { not_an_id: 42 }]), { + status: 200, + }); + }) as typeof fetch; + try { + const combos = await defaultOmniRouteCombosFetcher("https://or.example.com/v1", "sk-test"); + // Strip /v1 before /api/combos, AND filter out entries with no string id. + assert.equal(combos.length, 2); + assert.equal(combos[0].id, "c1"); + assert.equal(combos[1].id, "c2"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("defaultOmniRouteCombosFetcher: strips trailing /v1 before /api/combos", async () => { + const originalFetch = globalThis.fetch; + let observedUrl = ""; + globalThis.fetch = (async (input: unknown) => { + observedUrl = typeof input === "string" ? input : (input as { url: string }).url; + return new Response(JSON.stringify({ combos: [] }), { status: 200 }); + }) as typeof fetch; + try { + await defaultOmniRouteCombosFetcher("https://or.example.com/v1/", "sk-test"); + assert.equal(observedUrl, "https://or.example.com/api/combos"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("defaultOmniRouteCombosFetcher: throws on non-2xx with status code in message", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + return new Response(JSON.stringify({ error: "Invalid token" }), { + status: 403, + statusText: "Forbidden", + }); + }) as typeof fetch; + try { + await assert.rejects( + async () => { + await defaultOmniRouteCombosFetcher("https://or.example.com", "sk-bad"); + }, + (err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + assert.match(msg, /403/, "status code must appear in message"); + assert.match(msg, /\/api\/combos/, "url must appear in message"); + return true; + } + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("defaultOmniRouteCombosFetcher: throws when apiKey missing", async () => { + await assert.rejects( + async () => defaultOmniRouteCombosFetcher("https://or.example.com", ""), + /apiKey required/ + ); +}); + +test("defaultOmniRouteCombosFetcher: throws when baseURL missing", async () => { + await assert.rejects( + async () => defaultOmniRouteCombosFetcher("", "sk-test"), + /baseURL required/ + ); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// mapComboToModelV2 — LCD semantics +// ──────────────────────────────────────────────────────────────────────────── + +test("mapComboToModelV2: empty members → capabilities all false (defensive)", () => { + const m = mapComboToModelV2( + { id: "combo-empty", name: "Empty Combo" }, + [], + "omniroute", + "https://or.example.com/v1" + ); + assert.equal(m.id, "combo-empty"); + assert.equal(m.name, "Empty Combo"); + assert.equal(m.capabilities.temperature, false); + assert.equal(m.capabilities.reasoning, false); + assert.equal(m.capabilities.attachment, false); + assert.equal(m.capabilities.toolcall, false); + assert.equal(m.capabilities.input.text, false); + assert.equal(m.capabilities.output.text, false); + assert.equal(m.limit.context, 0); + assert.equal(m.limit.output, 0); + assert.equal(m.limit.input, undefined); + assert.deepEqual(m.cost, { input: 0, output: 0, cache: { read: 0, write: 0 } }); +}); + +test("mapComboToModelV2: all members reasoning=true → combo reasoning=true", () => { + const m = mapComboToModelV2( + { id: "c", models: [] }, + [ + MODEL_PRIMARY, + { + ...MODEL_PRIMARY, + id: "p2", + capabilities: { ...MODEL_PRIMARY.capabilities, thinking: false, reasoning: true }, + }, + ], + "omniroute", + "https://or.example.com/v1" + ); + assert.equal(m.capabilities.reasoning, true); +}); + +test("mapComboToModelV2: any member reasoning=false → combo reasoning=false", () => { + const m = mapComboToModelV2( + { id: "c", models: [] }, + [MODEL_PRIMARY, MODEL_NO_TOOLS], // gemini-3-flash has reasoning:false, thinking:false + "omniroute", + "https://or.example.com/v1" + ); + assert.equal(m.capabilities.reasoning, false); +}); + +test("mapComboToModelV2: limit.context is min of members'", () => { + const m = mapComboToModelV2( + { id: "c", models: [] }, + [MODEL_PRIMARY, MODEL_SECONDARY, MODEL_NO_TOOLS], + "omniroute", + "https://or.example.com/v1" + ); + // min(200_000, 100_000, 1_000_000) = 100_000 + assert.equal(m.limit.context, 100_000); + // min(64_000, 32_000, 8_192) = 8_192 + assert.equal(m.limit.output, 8_192); +}); + +test("mapComboToModelV2: limit.input only emitted when EVERY member declares one", () => { + const m1 = mapComboToModelV2( + { id: "c", models: [] }, + [MODEL_PRIMARY, MODEL_SECONDARY], + "omniroute", + "https://or.example.com/v1" + ); + // Both declare max_input_tokens → limit.input = min(180000, 96000) + assert.equal(m1.limit.input, 96_000); + + const m2 = mapComboToModelV2( + { id: "c", models: [] }, + [MODEL_PRIMARY, MODEL_NO_TOOLS], // gemini-3-flash doesn't declare max_input_tokens + "omniroute", + "https://or.example.com/v1" + ); + assert.equal(m2.limit.input, undefined); +}); + +test("mapComboToModelV2: nice name preferred from combo.name", () => { + const m1 = mapComboToModelV2( + { id: "combo-x", name: "Pretty Name" }, + [MODEL_PRIMARY], + "omniroute", + "https://or.example.com/v1" + ); + assert.equal(m1.name, "Pretty Name"); + + // Falls back to id when name is absent or empty. + const m2 = mapComboToModelV2( + { id: "combo-y" }, + [MODEL_PRIMARY], + "omniroute", + "https://or.example.com/v1" + ); + assert.equal(m2.name, "combo-y"); + + const m3 = mapComboToModelV2( + { id: "combo-z", name: " " }, + [MODEL_PRIMARY], + "omniroute", + "https://or.example.com/v1" + ); + assert.equal(m3.name, "combo-z"); +}); + +test("mapComboToModelV2: attachment AND vision flag both honored across members", () => { + // MODEL_PRIMARY: vision=true; MODEL_SECONDARY: vision=true → combo attachment=true + const yes = mapComboToModelV2( + { id: "c1", models: [] }, + [MODEL_PRIMARY, MODEL_SECONDARY], + "omniroute", + "https://or.example.com/v1" + ); + assert.equal(yes.capabilities.attachment, true); + + // Add a member with no vision/attachment → AND collapses to false + const no = mapComboToModelV2( + { id: "c2", models: [] }, + [MODEL_PRIMARY, MODEL_NO_TOOLS], + "omniroute", + "https://or.example.com/v1" + ); + assert.equal(no.capabilities.attachment, false); +}); + +test("mapComboToModelV2: modalities AND'd across members", () => { + const m = mapComboToModelV2( + { id: "c", models: [] }, + [MODEL_PRIMARY, MODEL_SECONDARY], // both have text+image + "omniroute", + "https://or.example.com/v1" + ); + assert.equal(m.capabilities.input.text, true); + assert.equal(m.capabilities.input.image, true); + assert.equal(m.capabilities.input.audio, false); + + // Add a text-only member → image collapses to false. + const m2 = mapComboToModelV2( + { id: "c", models: [] }, + [MODEL_PRIMARY, MODEL_NO_TOOLS], + "omniroute", + "https://or.example.com/v1" + ); + assert.equal(m2.capabilities.input.text, true); + assert.equal(m2.capabilities.input.image, false); +}); + +test("mapComboToModelV2: api block matches providerId + baseURL", () => { + const m = mapComboToModelV2( + { id: "c" }, + [MODEL_PRIMARY], + "omniroute-preprod", + "https://or4269-preprod.mrmm.xyz/v1" + ); + assert.equal(m.providerID, "omniroute-preprod"); + assert.equal(m.api.id, "openai-compatible"); + assert.equal(m.api.url, "https://or4269-preprod.mrmm.xyz/v1"); + assert.equal(m.api.npm, "@ai-sdk/openai-compatible"); + assert.equal(m.status, "active"); +}); + +test("mapComboToModelV2: explicit member temperature=false drops combo temperature=false", () => { + const tempFalse: OmniRouteRawModelEntry = { + id: "no-temp", + capabilities: { tool_calling: true, temperature: false }, + context_length: 100_000, + max_output_tokens: 8_000, + input_modalities: ["text"], + output_modalities: ["text"], + }; + const m = mapComboToModelV2( + { id: "c" }, + [MODEL_PRIMARY, tempFalse], + "omniroute", + "https://or.example.com/v1" + ); + assert.equal(m.capabilities.temperature, false); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// createOmniRouteProviderHook — combos merge + collision + soft-fail + cache +// ──────────────────────────────────────────────────────────────────────────── + +test("models() returns combo entries merged into the map", async () => { + const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY, MODEL_SECONDARY, MODEL_NO_TOOLS]); + const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]); + const hook = createOmniRouteProviderHook( + { baseURL: "https://or.example.com/v1" }, + { fetcher: modelsFetcher, combosFetcher } + ); + const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never }); + + // 3 raw models + 1 combo = 4 entries + assert.equal(Object.keys(out).length, 4); + assert.ok(out["claude-primary"]); + assert.ok(out["claude-secondary"]); + assert.ok(out["gemini-3-flash"]); + assert.ok(out["combo-claude-tier"]); + + const combo = out["combo-claude-tier"]; + assert.equal(combo.name, "Claude Tier"); + assert.equal(combo.providerID, "omniroute"); + // LCD over claude-primary (200k, reasoning) + claude-secondary (100k, no reasoning) + assert.equal(combo.limit.context, 100_000); + assert.equal(combo.capabilities.reasoning, false); + assert.equal(combo.capabilities.toolcall, true); +}); + +test("models(): combo with unknown member ids degrades to all-false LCD posture", async () => { + const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY]); // catalog only has claude-primary + const combosFetcher = stubCombosFetcher([ + { + id: "phantom", + name: "Phantom Combo", + models: [ + { id: "s1", kind: "model", model: "does-not-exist-1", weight: 50 }, + { id: "s2", kind: "model", model: "does-not-exist-2", weight: 50 }, + ], + }, + ]); + const hook = createOmniRouteProviderHook( + { baseURL: "https://or.example.com/v1" }, + { fetcher: modelsFetcher, combosFetcher } + ); + const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never }); + assert.ok(out["phantom"]); + // With zero resolvable members, LCD = all-false (defensive posture). + assert.equal(out["phantom"].capabilities.toolcall, false); + assert.equal(out["phantom"].capabilities.reasoning, false); + assert.equal(out["phantom"].limit.context, 0); +}); + +test("models(): hidden combos are excluded from the map", async () => { + const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY]); + const combosFetcher = stubCombosFetcher([ + { + id: "visible", + name: "Visible", + models: [{ id: "s1", kind: "model", model: "claude-primary", weight: 100 }], + }, + { + id: "hidden", + name: "Hidden", + isHidden: true, + models: [{ id: "s1", kind: "model", model: "claude-primary", weight: 100 }], + }, + ]); + const hook = createOmniRouteProviderHook( + { baseURL: "https://or.example.com/v1" }, + { fetcher: modelsFetcher, combosFetcher } + ); + const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never }); + assert.ok(out["visible"]); + assert.ok(!out["hidden"], "hidden combo must be omitted"); +}); + +test("models(): combo ID collides with a model ID → combo wins, warn emitted once", async () => { + // The combo shares id with a model in the catalog. + const colliderCombo: OmniRouteRawCombo = { + id: "claude-primary", // SAME id as MODEL_PRIMARY + name: "Claude Primary Combo Override", + models: [{ id: "s1", kind: "model", model: "claude-secondary", weight: 100 }], + }; + const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY, MODEL_SECONDARY]); + const combosFetcher = stubCombosFetcher([colliderCombo]); + const hook = createOmniRouteProviderHook( + { baseURL: "https://or.example.com/v1" }, + { fetcher: modelsFetcher, combosFetcher } + ); + + const { result: out, warnings } = await withWarnCapture(async (_w) => { + return hook.models!({} as never, { auth: apiAuth("sk-z") as never }); + }); + + // Combo wins → the entry at "claude-primary" has the combo's display name, + // not the raw model's id-as-name. + assert.equal(out["claude-primary"].name, "Claude Primary Combo Override"); + // Exactly one collision warning was emitted. + const collisionWarns = warnings.filter((w) => { + const msg = w.args[0]; + return typeof msg === "string" && msg.includes("collides with a model id"); + }); + assert.equal(collisionWarns.length, 1, "collision warning emitted exactly once"); + + // Second call within TTL hits the cache; no additional warnings. + const { warnings: warnings2 } = await withWarnCapture(async (_w) => { + return hook.models!({} as never, { auth: apiAuth("sk-z") as never }); + }); + const collisionWarns2 = warnings2.filter((w) => { + const msg = w.args[0]; + return typeof msg === "string" && msg.includes("collides with a model id"); + }); + assert.equal(collisionWarns2.length, 0, "no re-warn on cached call"); +}); + +test("models(): collision warn is per-comboId — distinct collisions both warn", async () => { + const m1: OmniRouteRawModelEntry = { ...MODEL_PRIMARY, id: "id-a" }; + const m2: OmniRouteRawModelEntry = { ...MODEL_SECONDARY, id: "id-b" }; + const combos: OmniRouteRawCombo[] = [ + { id: "id-a", name: "A", models: [{ id: "s", kind: "model", model: "id-a", weight: 1 }] }, + { id: "id-b", name: "B", models: [{ id: "s", kind: "model", model: "id-b", weight: 1 }] }, + ]; + const hook = createOmniRouteProviderHook( + { baseURL: "https://or.example.com/v1" }, + { fetcher: stubModelsFetcher([m1, m2]), combosFetcher: stubCombosFetcher(combos) } + ); + + const { warnings } = await withWarnCapture(async () => { + return hook.models!({} as never, { auth: apiAuth("sk-z") as never }); + }); + const collisionWarns = warnings.filter((w) => { + const msg = w.args[0]; + return typeof msg === "string" && msg.includes("collides with a model id"); + }); + assert.equal(collisionWarns.length, 2); +}); + +test("models(): combos fetch fails → falls back to models-only, warn emitted, no throw", async () => { + const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY, MODEL_SECONDARY]); + const combosFetcher = failingCombosFetcher(new Error("ECONNRESET")); + const hook = createOmniRouteProviderHook( + { baseURL: "https://or.example.com/v1" }, + { fetcher: modelsFetcher, combosFetcher } + ); + + const { result: out, warnings } = await withWarnCapture(async () => { + return hook.models!({} as never, { auth: apiAuth("sk-z") as never }); + }); + + // Catalog includes the models but NOT any combo entries. + assert.equal(Object.keys(out).length, 2); + assert.ok(out["claude-primary"]); + assert.ok(out["claude-secondary"]); + + // Soft-fail warning surfaced. + const softFail = warnings.find((w) => { + const msg = w.args[0]; + return typeof msg === "string" && msg.includes("combos fetch failed"); + }); + assert.ok(softFail, "soft-fail warning must be emitted on combos fetch error"); + assert.equal(combosFetcher.callCount(), 1); +}); + +test("models(): combos cached + reused within TTL (one combo fetch per TTL window)", async () => { + const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY, MODEL_SECONDARY]); + const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]); + let nowMs = 1_000_000; + const hook = createOmniRouteProviderHook( + { baseURL: "https://or.example.com/v1", modelCacheTtl: 60_000 }, + { fetcher: modelsFetcher, combosFetcher, now: () => nowMs } + ); + + await hook.models!({} as never, { auth: apiAuth("sk-z") as never }); + nowMs += 30_000; // half the TTL + const second = await hook.models!({} as never, { auth: apiAuth("sk-z") as never }); + assert.equal(combosFetcher.callCount(), 1, "combos fetched only once within TTL"); + assert.equal(modelsFetcher.callCount(), 1, "models fetched only once within TTL"); + assert.ok(second["combo-claude-tier"]); +}); + +test("models(): combos refetched after TTL expiry (same key as models)", async () => { + const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY]); + const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]); + let nowMs = 1_000_000; + const hook = createOmniRouteProviderHook( + { baseURL: "https://or.example.com/v1", modelCacheTtl: 60_000 }, + { fetcher: modelsFetcher, combosFetcher, now: () => nowMs } + ); + + await hook.models!({} as never, { auth: apiAuth("sk-z") as never }); + nowMs += 60_001; + await hook.models!({} as never, { auth: apiAuth("sk-z") as never }); + assert.equal(combosFetcher.callCount(), 2, "combos must refetch past TTL"); + assert.equal(modelsFetcher.callCount(), 2, "models must refetch past TTL"); +}); + +test("models(): combos fetcher receives the resolved baseURL + apiKey", async () => { + const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY]); + const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]); + const hook = createOmniRouteProviderHook( + { baseURL: "https://or.example.com/v1" }, + { fetcher: modelsFetcher, combosFetcher } + ); + await hook.models!({} as never, { auth: apiAuth("sk-spy") as never }); + assert.deepEqual(combosFetcher.callsBy()[0], ["https://or.example.com/v1", "sk-spy"]); +}); diff --git a/@omniroute/opencode-plugin/tests/config-shim.test.ts b/@omniroute/opencode-plugin/tests/config-shim.test.ts new file mode 100644 index 0000000000..066f743d4b --- /dev/null +++ b/@omniroute/opencode-plugin/tests/config-shim.test.ts @@ -0,0 +1,715 @@ +/** + * T-07 config-hook backward-compat shim tests. + * + * Covers `createOmniRouteConfigHook(opts, deps)`: + * - happy path: valid auth.json → mutates input.provider[id] with the + * stripped per-model shape (mirroring `@omniroute/opencode-provider`). + * - no-op paths: missing auth.json, malformed JSON, missing apiKey, + * missing baseURL, existing input.provider[id] (manual override). + * - fail-open: /v1/models error → stub `models: {}`; /api/combos error → + * models-only static catalog. + * - baseURL resolution: opts.baseURL → auth.json.baseURL fallback. + * - multi-instance: two plugins with different providerIds publish to + * their own keys without collision. + * - cache sharing: provider hook + config hook on the same Map dedupe + * fetcher invocations. + * - sibling-shape parity: emitted entries carry only + * `{name, attachment?, reasoning?, temperature?, tool_call?, limit?}` + * — never the rich ModelV2 nested capabilities tree. + * + * Mocking strategy mirrors `provider.test.ts` and `combos.test.ts`: every + * dependency (`readAuthJson`, `fetcher`, `combosFetcher`, `now`, `cache`, + * `logger`) is dependency-injected at hook construction. No global + * `fs/promises` or `fetch` monkey-patch needed. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import type { Config } from "@opencode-ai/plugin"; +import { + buildStaticProviderEntry, + createOmniRouteConfigHook, + createOmniRouteProviderHook, + OmniRoutePlugin, + resolveOmniRoutePluginOptions, + type OmniRouteCombosFetcher, + type OmniRouteFetchCache, + type OmniRouteModelsFetcher, + type OmniRouteRawCombo, + type OmniRouteRawModelEntry, + type OmniRouteReadAuthJson, + type OmniRouteStaticProviderEntry, +} from "../src/index.js"; + +// ──────────────────────────────────────────────────────────────────────────── +// Fixtures +// ──────────────────────────────────────────────────────────────────────────── + +const MODEL_CLAUDE: OmniRouteRawModelEntry = { + id: "claude-sonnet-4-6", + capabilities: { + tool_calling: true, + reasoning: true, + vision: true, + thinking: false, + temperature: true, + }, + context_length: 200_000, + max_output_tokens: 64_000, + max_input_tokens: 180_000, + input_modalities: ["text", "image"], + output_modalities: ["text"], +}; + +const MODEL_GEMINI: OmniRouteRawModelEntry = { + id: "gemini-3-flash", + capabilities: { tool_calling: true, reasoning: false, vision: true, thinking: false }, + context_length: 1_000_000, + max_output_tokens: 8_192, + input_modalities: ["text", "image"], + output_modalities: ["text"], +}; + +const COMBO_CLAUDE_TIER: OmniRouteRawCombo = { + id: "combo-claude-tier", + name: "Claude Tier", + models: [ + { id: "s1", kind: "model", model: "claude-sonnet-4-6", weight: 100 }, + { id: "s2", kind: "model", model: "gemini-3-flash", weight: 50 }, + ], +}; + +// ──────────────────────────────────────────────────────────────────────────── +// Helpers (DI stubs — mirrors patterns in provider.test.ts / combos.test.ts) +// ──────────────────────────────────────────────────────────────────────────── + +function stubReadAuthJson( + value: Record | undefined | null +): OmniRouteReadAuthJson & { callCount: () => number } { + let n = 0; + const f: OmniRouteReadAuthJson = async () => { + n++; + return value as never; + }; + return Object.assign(f, { callCount: () => n }); +} + +function throwingReadAuthJson(): OmniRouteReadAuthJson & { callCount: () => number } { + let n = 0; + const f: OmniRouteReadAuthJson = async () => { + n++; + throw new Error("EACCES"); + }; + return Object.assign(f, { callCount: () => n }); +} + +function stubModelsFetcher( + payload: OmniRouteRawModelEntry[] +): OmniRouteModelsFetcher & { callCount: () => number; callsBy: () => Array<[string, string]> } { + let n = 0; + const calls: Array<[string, string]> = []; + const f: OmniRouteModelsFetcher = async (baseURL, apiKey) => { + n++; + calls.push([baseURL, apiKey]); + return payload; + }; + return Object.assign(f, { callCount: () => n, callsBy: () => calls }); +} + +function stubCombosFetcher( + payload: OmniRouteRawCombo[] +): OmniRouteCombosFetcher & { callCount: () => number; callsBy: () => Array<[string, string]> } { + let n = 0; + const calls: Array<[string, string]> = []; + const f: OmniRouteCombosFetcher = async (baseURL, apiKey) => { + n++; + calls.push([baseURL, apiKey]); + return payload; + }; + return Object.assign(f, { callCount: () => n, callsBy: () => calls }); +} + +function throwingModelsFetcher(): OmniRouteModelsFetcher & { callCount: () => number } { + let n = 0; + const f: OmniRouteModelsFetcher = async () => { + n++; + throw new Error("ECONNREFUSED"); + }; + return Object.assign(f, { callCount: () => n }); +} + +function throwingCombosFetcher(): OmniRouteCombosFetcher & { callCount: () => number } { + let n = 0; + const f: OmniRouteCombosFetcher = async () => { + n++; + throw new Error("403 Forbidden"); + }; + return Object.assign(f, { callCount: () => n }); +} + +interface WarnCapture { + warn: (...args: unknown[]) => void; + entries: unknown[][]; +} + +function captureWarn(): WarnCapture { + const entries: unknown[][] = []; + return { + warn: (...args: unknown[]) => { + entries.push(args); + }, + entries, + }; +} + +function makeInput(initialProvider: Record = {}): Config { + // Config = Omit & {plugin?: ...}. We only touch the + // `provider` slot, so a partial cast is acceptable for these tests. + return { provider: initialProvider } as unknown as Config; +} + +// ──────────────────────────────────────────────────────────────────────────── +// 1. Happy path — valid auth.json + apiKey + baseURL → mutates input.provider +// ──────────────────────────────────────────────────────────────────────────── + +test("config: with valid auth.json + apiKey + baseURL → mutates input.provider[id] with stripped models block", async () => { + const readAuthJson = stubReadAuthJson({ + omniroute: { type: "api", key: "sk-test-1", baseURL: "https://or.example.com/v1" }, + }); + const fetcher = stubModelsFetcher([MODEL_CLAUDE, MODEL_GEMINI]); + const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]); + const logger = captureWarn(); + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute" }, + { readAuthJson, fetcher, combosFetcher, logger } + ); + const input = makeInput(); + await hook(input); + + const provider = (input as { provider: Record }).provider; + const entry = provider.omniroute; + assert.ok(entry, "input.provider.omniroute set"); + assert.equal(entry.npm, "@ai-sdk/openai-compatible"); + assert.equal(entry.name, "OmniRoute"); + assert.equal(entry.options.baseURL, "https://or.example.com/v1"); + assert.equal(entry.options.apiKey, "sk-test-1"); + + // Stripped per-model shape: name + cap flags only, NO nested + // capabilities.input.* tree, NO cost block. + const claude = entry.models["claude-sonnet-4-6"]; + assert.ok(claude, "claude model surfaced"); + assert.equal(claude.name, "claude-sonnet-4-6"); + assert.equal(claude.attachment, true); + assert.equal(claude.reasoning, true); + assert.equal(claude.temperature, true); + assert.equal(claude.tool_call, true); + assert.equal(claude.limit?.context, 200_000); + assert.equal(claude.limit?.input, 180_000); + assert.equal(claude.limit?.output, 64_000); + + // Combo present + LCD'd (gemini's reasoning=false → combo reasoning=false). + const combo = entry.models["combo-claude-tier"]; + assert.ok(combo, "combo surfaced"); + assert.equal(combo.name, "Claude Tier"); + assert.equal(combo.reasoning, false, "LCD: any member reasoning=false → combo reasoning=false"); + assert.equal(combo.tool_call, true); + assert.equal(combo.limit?.context, 200_000, "LCD: min(200_000, 1_000_000)"); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 2. Missing auth.json → no-op, no throw, no mutation +// ──────────────────────────────────────────────────────────────────────────── + +test("config: missing auth.json file → no-op, no throw, no input mutation", async () => { + const readAuthJson = stubReadAuthJson(undefined); + const fetcher = stubModelsFetcher([MODEL_CLAUDE]); + const combosFetcher = stubCombosFetcher([]); + const logger = captureWarn(); + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute" }, + { readAuthJson, fetcher, combosFetcher, logger } + ); + const input = makeInput(); + await hook(input); + + assert.deepEqual((input as { provider: Record }).provider, {}); + assert.equal(fetcher.callCount(), 0, "no fetch on missing auth.json"); + assert.equal(combosFetcher.callCount(), 0, "no combos fetch on missing auth.json"); + // One breadcrumb — the missing-apiKey path. + assert.ok( + logger.entries.some((e) => String(e[0]).includes("no apiKey")), + "breadcrumb emitted" + ); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 3. Malformed auth.json → no-op + warn once +// ──────────────────────────────────────────────────────────────────────────── + +test("config: malformed auth.json → no-op + warn once", async () => { + // stubReadAuthJson returns `null` to signal malformed JSON (matches + // defaultReadAuthJson's contract). + const readAuthJson = stubReadAuthJson(null); + const fetcher = stubModelsFetcher([MODEL_CLAUDE]); + const combosFetcher = stubCombosFetcher([]); + const logger = captureWarn(); + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute" }, + { readAuthJson, fetcher, combosFetcher, logger } + ); + const input = makeInput(); + await hook(input); + + assert.deepEqual((input as { provider: Record }).provider, {}); + assert.equal(fetcher.callCount(), 0); + // First warn = "failed to parse"; second warn = "no apiKey". + assert.ok( + logger.entries.some((e) => String(e[0]).includes("failed to parse")), + "parse-failure breadcrumb emitted" + ); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 4. Existing input.provider[id] → no overwrite (respect manual override) +// ──────────────────────────────────────────────────────────────────────────── + +test("config: existing input.provider[id] → no overwrite (respect manual override)", async () => { + const manual = { + npm: "@ai-sdk/openai-compatible", + name: "Manual OmniRoute", + options: { baseURL: "http://manual/v1", apiKey: "manual-key" }, + models: { "manual-model": { name: "manual-model" } }, + }; + const readAuthJson = stubReadAuthJson({ + omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, + }); + const fetcher = stubModelsFetcher([MODEL_CLAUDE]); + const combosFetcher = stubCombosFetcher([]); + const logger = captureWarn(); + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute" }, + { readAuthJson, fetcher, combosFetcher, logger } + ); + const input = makeInput({ omniroute: manual }); + await hook(input); + + const provider = (input as { provider: Record }).provider; + assert.equal(provider.omniroute, manual, "manual override preserved by reference"); + assert.equal(fetcher.callCount(), 0, "no fetch — short-circuited before I/O"); + assert.equal(readAuthJson.callCount(), 0, "no auth.json read either"); + assert.ok( + logger.entries.some((e) => String(e[0]).includes("already set")), + "override breadcrumb emitted" + ); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 5. fetchers throw → warn + emit stub entry with `models: {}` +// ──────────────────────────────────────────────────────────────────────────── + +test("config: fetchers throw → warn + emit stub entry with models: {}", async () => { + const readAuthJson = stubReadAuthJson({ + omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, + }); + const fetcher = throwingModelsFetcher(); + const combosFetcher = throwingCombosFetcher(); + const logger = captureWarn(); + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute" }, + { readAuthJson, fetcher, combosFetcher, logger } + ); + const input = makeInput(); + await hook(input); + + const entry = (input as { provider: Record }).provider + .omniroute; + assert.ok(entry, "stub provider entry published even when fetchers fail"); + assert.equal(entry.npm, "@ai-sdk/openai-compatible"); + assert.deepEqual(entry.models, {}, "models stub is empty object"); + assert.equal(entry.options.baseURL, "https://or.example/v1"); + assert.equal(entry.options.apiKey, "sk-test"); + // Both warns fired. + assert.ok( + logger.entries.some((e) => String(e[0]).includes("/v1/models fetch failed")), + "models-fetch breadcrumb emitted" + ); + assert.ok( + logger.entries.some((e) => String(e[0]).includes("/api/combos fetch failed")), + "combos-fetch breadcrumb emitted" + ); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 6. Combos fetcher throws → models-only catalog (no combos in models block) +// ──────────────────────────────────────────────────────────────────────────── + +test("config: combos fetcher throws → emit models-only catalog (no combos in models block)", async () => { + const readAuthJson = stubReadAuthJson({ + omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, + }); + const fetcher = stubModelsFetcher([MODEL_CLAUDE, MODEL_GEMINI]); + const combosFetcher = throwingCombosFetcher(); + const logger = captureWarn(); + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute" }, + { readAuthJson, fetcher, combosFetcher, logger } + ); + const input = makeInput(); + await hook(input); + + const entry = (input as { provider: Record }).provider + .omniroute; + assert.ok(entry); + const ids = Object.keys(entry.models).sort(); + assert.deepEqual(ids, ["claude-sonnet-4-6", "gemini-3-flash"]); + assert.equal(entry.models["combo-claude-tier"], undefined, "no combo entry"); + assert.ok( + logger.entries.some((e) => String(e[0]).includes("/api/combos fetch failed")), + "combos-fetch breadcrumb emitted" + ); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 7. baseURL from auth.json takes precedence when opts.baseURL absent +// ──────────────────────────────────────────────────────────────────────────── + +test("config: baseURL from auth.json takes precedence when opts.baseURL absent", async () => { + const readAuthJson = stubReadAuthJson({ + omniroute: { type: "api", key: "sk-test", baseURL: "https://creds.example/v1" }, + }); + const fetcher = stubModelsFetcher([MODEL_CLAUDE]); + const combosFetcher = stubCombosFetcher([]); + const logger = captureWarn(); + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute" }, // NO opts.baseURL + { readAuthJson, fetcher, combosFetcher, logger } + ); + const input = makeInput(); + await hook(input); + + assert.equal(fetcher.callsBy()[0][0], "https://creds.example/v1"); + const entry = (input as { provider: Record }).provider + .omniroute; + assert.equal(entry.options.baseURL, "https://creds.example/v1"); +}); + +test("config: opts.baseURL wins over auth.json's stored baseURL", async () => { + const readAuthJson = stubReadAuthJson({ + omniroute: { type: "api", key: "sk-test", baseURL: "https://creds.example/v1" }, + }); + const fetcher = stubModelsFetcher([MODEL_CLAUDE]); + const combosFetcher = stubCombosFetcher([]); + const logger = captureWarn(); + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute", baseURL: "https://opts.example/v1" }, + { readAuthJson, fetcher, combosFetcher, logger } + ); + const input = makeInput(); + await hook(input); + + assert.equal(fetcher.callsBy()[0][0], "https://opts.example/v1"); + const entry = (input as { provider: Record }).provider + .omniroute; + assert.equal(entry.options.baseURL, "https://opts.example/v1"); +}); + +test("config: no baseURL resolvable (no opts, no auth.json baseURL) → no-op", async () => { + const readAuthJson = stubReadAuthJson({ + omniroute: { type: "api", key: "sk-test" }, // NO baseURL on the credential + }); + const fetcher = stubModelsFetcher([MODEL_CLAUDE]); + const combosFetcher = stubCombosFetcher([]); + const logger = captureWarn(); + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute" }, // NO opts.baseURL + { readAuthJson, fetcher, combosFetcher, logger } + ); + const input = makeInput(); + await hook(input); + + assert.deepEqual((input as { provider: Record }).provider, {}); + assert.equal(fetcher.callCount(), 0); + assert.ok( + logger.entries.some((e) => String(e[0]).includes("no baseURL")), + "no-baseURL breadcrumb emitted" + ); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 8. Multi-instance: two plugins with different providerIds publish to +// their own keys without collision. +// ──────────────────────────────────────────────────────────────────────────── + +test("config: multi-instance — two plugins with different providerIds publish to their own keys without collision", async () => { + const readAuthJson = stubReadAuthJson({ + "omniroute-prod": { + type: "api", + key: "sk-prod", + baseURL: "https://prod.example/v1", + }, + "omniroute-preprod": { + type: "api", + key: "sk-preprod", + baseURL: "https://preprod.example/v1", + }, + }); + const fetcher = stubModelsFetcher([MODEL_CLAUDE]); + const combosFetcher = stubCombosFetcher([]); + const logger = captureWarn(); + + const hookA = createOmniRouteConfigHook( + { providerId: "omniroute-prod" }, + { readAuthJson, fetcher, combosFetcher, logger } + ); + const hookB = createOmniRouteConfigHook( + { providerId: "omniroute-preprod" }, + { readAuthJson, fetcher, combosFetcher, logger } + ); + + const input = makeInput(); + await hookA(input); + await hookB(input); + + const provider = (input as { provider: Record }).provider; + assert.ok(provider["omniroute-prod"], "prod block present"); + assert.ok(provider["omniroute-preprod"], "preprod block present"); + assert.equal(provider["omniroute-prod"].options.apiKey, "sk-prod"); + assert.equal(provider["omniroute-preprod"].options.apiKey, "sk-preprod"); + assert.equal(provider["omniroute-prod"].options.baseURL, "https://prod.example/v1"); + assert.equal(provider["omniroute-preprod"].options.baseURL, "https://preprod.example/v1"); + assert.notEqual( + provider["omniroute-prod"], + provider["omniroute-preprod"], + "blocks are distinct references" + ); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 9. Cache sharing: provider hook + config hook on the same Map dedupe +// fetcher invocations. +// ──────────────────────────────────────────────────────────────────────────── + +test("config + provider share cache: second call uses cached fetch result (single fetch per TTL)", async () => { + const readAuthJson = stubReadAuthJson({ + omniroute: { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" }, + }); + const fetcher = stubModelsFetcher([MODEL_CLAUDE]); + const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]); + const sharedCache: OmniRouteFetchCache = new Map(); + const logger = captureWarn(); + + const configHook = createOmniRouteConfigHook( + { providerId: "omniroute", baseURL: "https://or.example/v1", modelCacheTtl: 60_000 }, + { readAuthJson, fetcher, combosFetcher, cache: sharedCache, logger } + ); + const providerHook = createOmniRouteProviderHook( + { providerId: "omniroute", baseURL: "https://or.example/v1", modelCacheTtl: 60_000 }, + { fetcher, combosFetcher, cache: sharedCache } + ); + + // Simulate OC ≥1.14.49 cold start: config fires first, populates cache, + // then provider.models() reuses the cached raw results. + const input = makeInput(); + await configHook(input); + assert.equal(fetcher.callCount(), 1, "config fired the only models fetch"); + assert.equal(combosFetcher.callCount(), 1, "config fired the only combos fetch"); + + // provider hook then runs — should hit the shared cache, NOT refetch. + const apiAuth = { type: "api", key: "sk-shared" }; + await providerHook.models!({} as never, { auth: apiAuth as never }); + assert.equal(fetcher.callCount(), 1, "provider reused cached models"); + assert.equal(combosFetcher.callCount(), 1, "provider reused cached combos"); +}); + +test("provider → config order also dedupes (cache populated by provider, consumed by config)", async () => { + const readAuthJson = stubReadAuthJson({ + omniroute: { type: "api", key: "sk-reverse", baseURL: "https://or.example/v1" }, + }); + const fetcher = stubModelsFetcher([MODEL_CLAUDE]); + const combosFetcher = stubCombosFetcher([]); + const sharedCache: OmniRouteFetchCache = new Map(); + const logger = captureWarn(); + + const configHook = createOmniRouteConfigHook( + { providerId: "omniroute", baseURL: "https://or.example/v1", modelCacheTtl: 60_000 }, + { readAuthJson, fetcher, combosFetcher, cache: sharedCache, logger } + ); + const providerHook = createOmniRouteProviderHook( + { providerId: "omniroute", baseURL: "https://or.example/v1", modelCacheTtl: 60_000 }, + { fetcher, combosFetcher, cache: sharedCache } + ); + + await providerHook.models!({} as never, { + auth: { type: "api", key: "sk-reverse" } as never, + }); + assert.equal(fetcher.callCount(), 1); + + const input = makeInput(); + await configHook(input); + assert.equal(fetcher.callCount(), 1, "config reused cached models"); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 10. Stripped models shape matches sibling provider spec +// (`{name, attachment, reasoning, tool_call, temperature, limit?}`). +// ──────────────────────────────────────────────────────────────────────────── + +test("buildStaticProviderEntry: stripped per-model shape matches sibling @omniroute/opencode-provider", () => { + const resolved = resolveOmniRoutePluginOptions({ + providerId: "omniroute", + displayName: "OmniRoute", + }); + const block = buildStaticProviderEntry( + [MODEL_CLAUDE, MODEL_GEMINI], + [], + resolved, + "https://or.example/v1", + "sk-test" + ); + + // Top-level provider entry shape — ONLY these four keys. + assert.deepEqual(Object.keys(block).sort(), ["models", "name", "npm", "options"]); + assert.equal(block.npm, "@ai-sdk/openai-compatible"); + assert.equal(block.name, "OmniRoute"); + assert.deepEqual(Object.keys(block.options).sort(), ["apiKey", "baseURL"]); + + // Per-model entry shape — STRIPPED (no nested capabilities tree, no + // cost block, no providerID/api/status/headers/release_date that + // ModelV2 carries). Allowed keys: name, attachment, reasoning, + // temperature, tool_call, limit. + const allowedKeys = new Set([ + "name", + "attachment", + "reasoning", + "temperature", + "tool_call", + "limit", + ]); + for (const [id, entry] of Object.entries(block.models)) { + for (const key of Object.keys(entry)) { + assert.ok(allowedKeys.has(key), `${id}.${key} is not in the stripped sibling shape`); + } + // capabilities (ModelV2-only) must NOT leak. + assert.equal( + (entry as Record).capabilities, + undefined, + `${id} must not carry nested capabilities tree` + ); + // cost (ModelV2-only) must NOT leak. + assert.equal( + (entry as Record).cost, + undefined, + `${id} must not carry cost block` + ); + } + + // Sanity: claude entry has all expected stripped fields. + const claude = block.models["claude-sonnet-4-6"]; + assert.equal(typeof claude.name, "string"); + assert.equal(typeof claude.attachment, "boolean"); + assert.equal(typeof claude.reasoning, "boolean"); + assert.equal(typeof claude.temperature, "boolean"); + assert.equal(typeof claude.tool_call, "boolean"); + assert.equal(typeof claude.limit?.context, "number"); +}); + +test("buildStaticProviderEntry: empty fetch results → stub block with models: {}", () => { + const resolved = resolveOmniRoutePluginOptions({ providerId: "omniroute" }); + const block = buildStaticProviderEntry([], [], resolved, "https://or.example/v1", "sk-test"); + assert.deepEqual(block.models, {}); + assert.equal(block.options.apiKey, "sk-test"); +}); + +test("buildStaticProviderEntry: hidden combos are excluded", () => { + const resolved = resolveOmniRoutePluginOptions({ providerId: "omniroute" }); + const block = buildStaticProviderEntry( + [MODEL_CLAUDE], + [{ ...COMBO_CLAUDE_TIER, isHidden: true }], + resolved, + "https://or.example/v1", + "sk-test" + ); + assert.equal(block.models["combo-claude-tier"], undefined); + assert.ok(block.models["claude-sonnet-4-6"]); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Integration: OmniRoutePlugin factory now exposes config hook +// ──────────────────────────────────────────────────────────────────────────── + +test("OmniRoutePlugin factory exposes config hook alongside auth + provider", async () => { + const hooks = await OmniRoutePlugin({} as never, { providerId: "omniroute" }); + assert.equal(typeof hooks.config, "function", "config hook present"); + assert.ok(hooks.auth, "auth hook present"); + assert.ok(hooks.provider, "provider hook present"); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Edge cases / robustness +// ──────────────────────────────────────────────────────────────────────────── + +test("config: auth.json entry of wrong type (oauth) → no-op", async () => { + const readAuthJson = stubReadAuthJson({ + omniroute: { type: "oauth", refresh: "r", access: "a", expires: 0 }, + }); + const fetcher = stubModelsFetcher([MODEL_CLAUDE]); + const combosFetcher = stubCombosFetcher([]); + const logger = captureWarn(); + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute", baseURL: "https://or.example/v1" }, + { readAuthJson, fetcher, combosFetcher, logger } + ); + const input = makeInput(); + await hook(input); + + assert.deepEqual((input as { provider: Record }).provider, {}); + assert.equal(fetcher.callCount(), 0); +}); + +test("config: readAuthJson throws → treat as missing file (silent fallback)", async () => { + const readAuthJson = throwingReadAuthJson(); + const fetcher = stubModelsFetcher([MODEL_CLAUDE]); + const combosFetcher = stubCombosFetcher([]); + const logger = captureWarn(); + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute", baseURL: "https://or.example/v1" }, + { readAuthJson, fetcher, combosFetcher, logger } + ); + const input = makeInput(); + await hook(input); + + assert.deepEqual((input as { provider: Record }).provider, {}); + assert.equal(readAuthJson.callCount(), 1); + assert.equal(fetcher.callCount(), 0); +}); + +test("config: initialises input.provider when undefined", async () => { + const readAuthJson = stubReadAuthJson({ + omniroute: { type: "api", key: "sk", baseURL: "https://or.example/v1" }, + }); + const fetcher = stubModelsFetcher([MODEL_CLAUDE]); + const combosFetcher = stubCombosFetcher([]); + const logger = captureWarn(); + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute" }, + { readAuthJson, fetcher, combosFetcher, logger } + ); + // input with NO provider field at all + const input = {} as Config; + await hook(input); + const provider = (input as { provider?: Record }).provider; + assert.ok(provider, "provider bag initialised"); + assert.ok(provider!.omniroute); +}); diff --git a/@omniroute/opencode-plugin/tests/features.test.ts b/@omniroute/opencode-plugin/tests/features.test.ts new file mode 100644 index 0000000000..825ada6169 --- /dev/null +++ b/@omniroute/opencode-plugin/tests/features.test.ts @@ -0,0 +1,611 @@ +/** + * Features-block tests. + * + * Covers the v0.1.0 `features` toggle block + the enrichment / compression + * metadata fetchers + the MCP auto-emit branch on the config hook. + * + * Surfaces tested: + * - `parseOmniRoutePluginOptions({ features: ... })` → schema accept/reject + * - `applyEnrichment(model, entry)` → mutation semantics + * - `formatCompressionPipeline(steps)` → display formatting + * - `createOmniRouteProviderHook` with mocked + * `enrichmentFetcher` / `compressionMetaFetcher` → overlay applied, + * off-by-default + * gating works. + * - `createOmniRouteConfigHook` with `features.mcpAutoEmit:true` + * → emits mcp entry + * → falls back to + * provider apiKey + * when mcpToken + * is unset + * → respects operator + * override + * → no emit when + * mcpAutoEmit is + * false / unset + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + applyEnrichment, + createOmniRouteConfigHook, + createOmniRouteProviderHook, + defaultOmniRouteEnrichmentFetcher, + defaultOmniRouteCompressionMetaFetcher, + formatCompressionPipeline, + parseOmniRoutePluginOptions, + type OmniRouteEnrichmentMap, + type OmniRouteCompressionCombo, + type OmniRouteRawModelEntry, +} from "../src/index.js"; + +// ───────────────────────────────────────────────────────────────────────── +// Zod schema — features block +// ───────────────────────────────────────────────────────────────────────── + +test("parseOmniRoutePluginOptions: empty features object → preserved", () => { + const r = parseOmniRoutePluginOptions({ features: {} }); + assert.deepEqual(r, { features: {} }); +}); + +test("parseOmniRoutePluginOptions: all boolean features set → preserved", () => { + const r = parseOmniRoutePluginOptions({ + features: { + combos: true, + enrichment: true, + compressionMetadata: true, + geminiSanitization: true, + mcpAutoEmit: true, + fetchInterceptor: true, + }, + }); + assert.equal(r.features?.combos, true); + assert.equal(r.features?.enrichment, true); + assert.equal(r.features?.compressionMetadata, true); + assert.equal(r.features?.mcpAutoEmit, true); +}); + +test("parseOmniRoutePluginOptions: mcpToken string → preserved", () => { + const r = parseOmniRoutePluginOptions({ + features: { mcpAutoEmit: true, mcpToken: "sk-mcp-only-token-12345" }, + }); + assert.equal(r.features?.mcpToken, "sk-mcp-only-token-12345"); +}); + +test("parseOmniRoutePluginOptions: unknown features key → throws (strict)", () => { + assert.throws( + () => + parseOmniRoutePluginOptions({ + features: { combos: true, unknown_field: "oops" }, + }), + /Invalid @omniroute\/opencode-plugin options/ + ); +}); + +test("parseOmniRoutePluginOptions: non-boolean for boolean feature → throws", () => { + assert.throws( + () => + parseOmniRoutePluginOptions({ + features: { combos: "yes" as unknown as boolean }, + }), + /Invalid @omniroute\/opencode-plugin options/ + ); +}); + +test("parseOmniRoutePluginOptions: empty mcpToken → throws (min 1)", () => { + assert.throws( + () => parseOmniRoutePluginOptions({ features: { mcpToken: "" } }), + /Invalid @omniroute\/opencode-plugin options/ + ); +}); + +// ───────────────────────────────────────────────────────────────────────── +// applyEnrichment +// ───────────────────────────────────────────────────────────────────────── + +const baseModel = () => ({ + id: "claude-sonnet-4-6", + name: "claude-sonnet-4-6", + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: false, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 200000, output: 64000 }, + status: "active" as const, + options: {}, + headers: {}, + release_date: "", + providerID: "omniroute", + api: { + id: "openai-compatible" as const, + url: "https://or.example.com/v1", + npm: "@ai-sdk/openai-compatible", + }, +}); + +test("applyEnrichment: undefined entry → no-op", () => { + const m = baseModel(); + const orig = JSON.parse(JSON.stringify(m)); + applyEnrichment(m as never, undefined); + assert.deepEqual(m, orig); +}); + +test("applyEnrichment: name overlay applied", () => { + const m = baseModel(); + applyEnrichment(m as never, { name: "Claude Sonnet 4.6" }); + assert.equal(m.name, "Claude Sonnet 4.6"); +}); + +test("applyEnrichment: empty name string ignored", () => { + const m = baseModel(); + applyEnrichment(m as never, { name: " " }); + assert.equal(m.name, "claude-sonnet-4-6"); // raw id untouched +}); + +test("applyEnrichment: pricing fields applied to cost", () => { + const m = baseModel(); + applyEnrichment(m as never, { + pricing: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + }); + assert.equal(m.cost.input, 3); + assert.equal(m.cost.output, 15); + assert.equal(m.cost.cache.read, 0.3); + assert.equal(m.cost.cache.write, 3.75); +}); + +test("applyEnrichment: partial pricing preserves untouched fields", () => { + const m = baseModel(); + m.cost = { input: 1, output: 2, cache: { read: 0.1, write: 0.2 } }; + applyEnrichment(m as never, { pricing: { input: 99 } }); + assert.equal(m.cost.input, 99); + assert.equal(m.cost.output, 2); + assert.equal(m.cost.cache.read, 0.1); +}); + +// ───────────────────────────────────────────────────────────────────────── +// formatCompressionPipeline +// ───────────────────────────────────────────────────────────────────────── + +test("formatCompressionPipeline: empty pipeline → empty string", () => { + assert.equal(formatCompressionPipeline([]), ""); +}); + +test("formatCompressionPipeline: single step with intensity", () => { + assert.equal( + formatCompressionPipeline([{ engine: "caveman", intensity: "full" }]), + "[caveman:full]" + ); +}); + +test("formatCompressionPipeline: multi-step pipeline", () => { + assert.equal( + formatCompressionPipeline([ + { engine: "rtk", intensity: "standard" }, + { engine: "caveman", intensity: "full" }, + ]), + "[rtk:standard → caveman:full]" + ); +}); + +test("formatCompressionPipeline: step without intensity", () => { + assert.equal(formatCompressionPipeline([{ engine: "rtk" }]), "[rtk]"); +}); + +// ───────────────────────────────────────────────────────────────────────── +// Provider hook — enrichment applied via injected fetcher +// ───────────────────────────────────────────────────────────────────────── + +const SAMPLE_RAW: OmniRouteRawModelEntry[] = [ + { + id: "claude-sonnet-4-6", + object: "model", + created: 0, + owned_by: "anthropic", + permission: [], + root: "claude-sonnet-4-6", + parent: null, + context_length: 200000, + max_output_tokens: 64000, + input_modalities: ["text", "image"], + output_modalities: ["text"], + capabilities: { tool_calling: true, reasoning: true, vision: true, thinking: true }, + }, +]; + +const apiAuth = (key: string) => ({ type: "api" as const, key }); + +test("provider hook: enrichment fetcher called when features.enrichment !== false", async () => { + let called = 0; + const enrichment: OmniRouteEnrichmentMap = new Map([ + ["claude-sonnet-4-6", { name: "Claude Sonnet 4.6", pricing: { input: 3, output: 15 } }], + ]); + const hook = createOmniRouteProviderHook( + { providerId: "omniroute", baseURL: "https://or.example.com/v1" }, + { + fetcher: async () => SAMPLE_RAW, + combosFetcher: async () => [], + enrichmentFetcher: async () => { + called++; + return enrichment; + }, + } + ); + const out = await hook.models!({} as never, { auth: apiAuth("sk") as never }); + assert.equal(called, 1, "enrichment fetcher called once"); + const m = out["claude-sonnet-4-6"]; + assert.equal(m.name, "Claude Sonnet 4.6", "enrichment name overlay applied"); + assert.equal(m.cost.input, 3, "enrichment pricing applied"); + assert.equal(m.cost.output, 15); +}); + +test("provider hook: enrichment fetcher NOT called when features.enrichment:false", async () => { + let called = 0; + const hook = createOmniRouteProviderHook( + { + providerId: "omniroute", + baseURL: "https://or.example.com/v1", + features: { enrichment: false }, + }, + { + fetcher: async () => SAMPLE_RAW, + combosFetcher: async () => [], + enrichmentFetcher: async () => { + called++; + return new Map(); + }, + } + ); + const out = await hook.models!({} as never, { auth: apiAuth("sk") as never }); + assert.equal(called, 0, "enrichment fetcher NOT called when gated off"); + assert.equal(out["claude-sonnet-4-6"].name, "claude-sonnet-4-6", "raw id preserved"); +}); + +test("provider hook: compression metadata fetcher NOT called by default (opt-in)", async () => { + let called = 0; + const hook = createOmniRouteProviderHook( + { providerId: "omniroute", baseURL: "https://or.example.com/v1" }, + { + fetcher: async () => SAMPLE_RAW, + combosFetcher: async () => [], + enrichmentFetcher: async () => new Map(), + compressionMetaFetcher: async () => { + called++; + return []; + }, + } + ); + await hook.models!({} as never, { auth: apiAuth("sk") as never }); + assert.equal(called, 0, "compression metadata is opt-in (features.compressionMetadata:true)"); +}); + +test("provider hook: compression metadata fetcher called when opted in", async () => { + let called = 0; + const compressionCombos: OmniRouteCompressionCombo[] = [ + { + id: "default-caveman", + name: "Standard Savings", + pipeline: [ + { engine: "rtk", intensity: "standard" }, + { engine: "caveman", intensity: "full" }, + ], + isDefault: true, + }, + ]; + const hook = createOmniRouteProviderHook( + { + providerId: "omniroute", + baseURL: "https://or.example.com/v1", + features: { compressionMetadata: true }, + }, + { + fetcher: async () => SAMPLE_RAW, + combosFetcher: async () => [ + { + id: "claude-primary", + name: "Claude Primary", + models: [{ id: "step-1", model: "claude-sonnet-4-6" }], + }, + ], + enrichmentFetcher: async () => new Map(), + compressionMetaFetcher: async () => { + called++; + return compressionCombos; + }, + } + ); + const out = await hook.models!({} as never, { auth: apiAuth("sk") as never }); + assert.equal(called, 1, "compression metadata fetcher called"); + const combo = out["claude-primary"]; + assert.ok(combo, "combo entry present"); + assert.match(combo.name, /\[rtk:standard → caveman:full\]/, "combo name decorated with pipeline"); +}); + +// ───────────────────────────────────────────────────────────────────────── +// Config hook — MCP auto-emit +// ───────────────────────────────────────────────────────────────────────── + +const stubAuthJson = (apiKey: string) => async () => ({ + omniroute: { type: "api" as const, key: apiKey }, +}); + +test("config hook: MCP auto-emit OFF by default (no mcp entry)", async () => { + const hook = createOmniRouteConfigHook( + { providerId: "omniroute", baseURL: "https://or.example.com/v1" }, + { + readAuthJson: stubAuthJson("sk-prod"), + fetcher: async () => SAMPLE_RAW, + combosFetcher: async () => [], + logger: { warn: () => {} }, + } + ); + const input: { provider?: Record; mcp?: Record } = {}; + await hook(input as never); + assert.ok(input.provider?.omniroute, "provider block written"); + assert.equal(input.mcp, undefined, "no mcp block written"); +}); + +test("config hook: features.mcpAutoEmit:true writes mcp entry with provider apiKey", async () => { + const hook = createOmniRouteConfigHook( + { + providerId: "omniroute", + baseURL: "https://or.example.com/v1", + features: { mcpAutoEmit: true }, + }, + { + readAuthJson: stubAuthJson("sk-prod-key"), + fetcher: async () => SAMPLE_RAW, + combosFetcher: async () => [], + logger: { warn: () => {} }, + } + ); + const input: { provider?: Record; mcp?: Record } = {}; + await hook(input as never); + const entry = input.mcp?.omniroute as + | { type: string; url: string; enabled: boolean; headers: Record } + | undefined; + assert.ok(entry, "mcp entry written"); + assert.equal(entry.type, "remote"); + assert.equal( + entry.url, + "https://or.example.com/api/mcp/stream", + "baseURL /v1 stripped to /api/mcp/stream" + ); + assert.equal(entry.enabled, true); + assert.equal(entry.headers.Authorization, "Bearer sk-prod-key"); +}); + +test("config hook: features.mcpToken overrides provider apiKey in mcp Bearer", async () => { + const hook = createOmniRouteConfigHook( + { + providerId: "omniroute", + baseURL: "https://or.example.com/v1", + features: { mcpAutoEmit: true, mcpToken: "sk-mcp-narrower" }, + }, + { + readAuthJson: stubAuthJson("sk-chat"), + fetcher: async () => SAMPLE_RAW, + combosFetcher: async () => [], + logger: { warn: () => {} }, + } + ); + const input: { provider?: Record; mcp?: Record } = {}; + await hook(input as never); + const entry = input.mcp?.omniroute as { headers: Record }; + assert.equal( + entry.headers.Authorization, + "Bearer sk-mcp-narrower", + "mcpToken takes precedence over apiKey" + ); +}); + +test("config hook: existing operator mcp. wins (no overwrite)", async () => { + const hook = createOmniRouteConfigHook( + { + providerId: "omniroute", + baseURL: "https://or.example.com/v1", + features: { mcpAutoEmit: true }, + }, + { + readAuthJson: stubAuthJson("sk-prod"), + fetcher: async () => SAMPLE_RAW, + combosFetcher: async () => [], + logger: { warn: () => {} }, + } + ); + const input: { provider?: Record; mcp?: Record } = { + mcp: { omniroute: { type: "custom-user-entry", url: "https://manual.example/mcp" } }, + }; + await hook(input as never); + assert.deepEqual( + input.mcp?.omniroute, + { type: "custom-user-entry", url: "https://manual.example/mcp" }, + "operator override preserved" + ); +}); + +test("config hook: features.mcpAutoEmit:true with /v1 in baseURL → strips correctly", async () => { + const hook = createOmniRouteConfigHook( + { + providerId: "omniroute-preprod", + baseURL: "https://or-preprod.example.com/v1", + features: { mcpAutoEmit: true }, + }, + { + readAuthJson: async () => ({ + "omniroute-preprod": { type: "api" as const, key: "sk-preprod" }, + }), + fetcher: async () => SAMPLE_RAW, + combosFetcher: async () => [], + logger: { warn: () => {} }, + } + ); + const input: { provider?: Record; mcp?: Record } = {}; + await hook(input as never); + const entry = input.mcp?.["omniroute-preprod"] as { url: string }; + assert.equal( + entry.url, + "https://or-preprod.example.com/api/mcp/stream", + "/v1 stripped, /api/mcp/stream appended" + ); +}); + +// ───────────────────────────────────────────────────────────────────────── +// Default fetchers — soft-fail behavior (no real network) +// ───────────────────────────────────────────────────────────────────────── + +test("defaultOmniRouteEnrichmentFetcher: empty baseURL → empty map", async () => { + const m = await defaultOmniRouteEnrichmentFetcher("", "sk", 100); + assert.equal(m.size, 0); +}); + +test("defaultOmniRouteEnrichmentFetcher: empty apiKey → empty map", async () => { + const m = await defaultOmniRouteEnrichmentFetcher("https://or.example.com", "", 100); + assert.equal(m.size, 0); +}); + +test("defaultOmniRouteCompressionMetaFetcher: empty baseURL → empty array", async () => { + const arr = await defaultOmniRouteCompressionMetaFetcher("", "sk", 100); + assert.equal(arr.length, 0); +}); + +// ───────────────────────────────────────────────────────────────────────── +// Default enrichment fetcher — joins /api/pricing/models (names) with +// /api/pricing (per-model per-million-token pricing). The two endpoints are +// fetched independently; either may soft-fail. Verified via a stub fetch +// installed on globalThis. +// ───────────────────────────────────────────────────────────────────────── + +test("defaultOmniRouteEnrichmentFetcher: merges names from /api/pricing/models and prices from /api/pricing", async () => { + const origFetch = globalThis.fetch; + const calls: string[] = []; + globalThis.fetch = (async (input: unknown) => { + const url = typeof input === "string" ? input : (input as { url: string }).url; + calls.push(url); + if (url.endsWith("/api/pricing/models")) { + return new Response( + JSON.stringify({ + cc: { + id: "cc", + alias: "cc", + name: "Cc", + models: [ + { id: "claude-opus-4-7", name: "Claude Opus 4.7", custom: false }, + { id: "claude-sonnet-4-6", name: "Claude 4.6 Sonnet", custom: false }, + ], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + if (url.endsWith("/api/pricing")) { + return new Response( + JSON.stringify({ + cc: { + "claude-opus-4-7": { + input: 5, + output: 25, + cached: 0.5, + cache_creation: 6.25, + reasoning: 25, + }, + "claude-sonnet-4-6": { + input: 3, + output: 15, + }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; + + try { + const map = await defaultOmniRouteEnrichmentFetcher( + "https://or.example.com/v1", + "sk-test", + 5_000 + ); + assert.ok( + calls.some((u) => u.endsWith("/api/pricing/models")), + "catalog endpoint hit" + ); + assert.ok( + calls.some((u) => u.endsWith("/api/pricing")), + "pricing endpoint hit" + ); + const opus = map.get("cc/claude-opus-4-7"); + assert.ok(opus, "namespaced entry present"); + assert.equal(opus?.name, "Claude Opus 4.7", "name from /api/pricing/models"); + assert.equal(opus?.pricing?.input, 5, "input price merged"); + assert.equal(opus?.pricing?.output, 25, "output price merged"); + assert.equal(opus?.pricing?.cacheRead, 0.5, "cached → cacheRead alias"); + assert.equal(opus?.pricing?.cacheWrite, 6.25, "cache_creation → cacheWrite alias"); + const opusBare = map.get("claude-opus-4-7"); + assert.ok(opusBare, "bare id entry present (collision-avoidance)"); + assert.equal(opusBare?.name, "Claude Opus 4.7"); + assert.equal(opusBare?.pricing?.input, 5); + const sonnet = map.get("cc/claude-sonnet-4-6"); + assert.equal(sonnet?.name, "Claude 4.6 Sonnet"); + assert.equal(sonnet?.pricing?.input, 3); + assert.equal(sonnet?.pricing?.output, 15); + assert.equal(sonnet?.pricing?.cacheRead, undefined, "no cached key → no cacheRead"); + } finally { + globalThis.fetch = origFetch; + } +}); + +test("defaultOmniRouteEnrichmentFetcher: name-only when pricing endpoint 5xxs", async () => { + const origFetch = globalThis.fetch; + globalThis.fetch = (async (input: unknown) => { + const url = typeof input === "string" ? input : (input as { url: string }).url; + if (url.endsWith("/api/pricing/models")) { + return new Response( + JSON.stringify({ + cc: { models: [{ id: "claude-opus-4-7", name: "Claude Opus 4.7", custom: false }] }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + return new Response("boom", { status: 500 }); + }) as typeof fetch; + try { + const map = await defaultOmniRouteEnrichmentFetcher("https://or.example.com", "sk-test", 5_000); + const opus = map.get("cc/claude-opus-4-7"); + assert.equal(opus?.name, "Claude Opus 4.7", "name still present"); + assert.equal(opus?.pricing, undefined, "no pricing when /api/pricing fails"); + } finally { + globalThis.fetch = origFetch; + } +}); + +test("defaultOmniRouteEnrichmentFetcher: pricing-only when catalog endpoint 5xxs", async () => { + const origFetch = globalThis.fetch; + globalThis.fetch = (async (input: unknown) => { + const url = typeof input === "string" ? input : (input as { url: string }).url; + if (url.endsWith("/api/pricing")) { + return new Response(JSON.stringify({ cc: { "claude-opus-4-7": { input: 5, output: 25 } } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("boom", { status: 500 }); + }) as typeof fetch; + try { + const map = await defaultOmniRouteEnrichmentFetcher("https://or.example.com", "sk-test", 5_000); + const opus = map.get("cc/claude-opus-4-7"); + assert.equal(opus?.pricing?.input, 5); + assert.equal(opus?.pricing?.output, 25); + assert.equal(opus?.name, undefined, "no name when catalog endpoint fails"); + } finally { + globalThis.fetch = origFetch; + } +}); diff --git a/@omniroute/opencode-plugin/tests/fetch-interceptor.test.ts b/@omniroute/opencode-plugin/tests/fetch-interceptor.test.ts new file mode 100644 index 0000000000..2787894868 --- /dev/null +++ b/@omniroute/opencode-plugin/tests/fetch-interceptor.test.ts @@ -0,0 +1,269 @@ +/** + * T-04 fetch-interceptor contract tests. + * + * Covers `createOmniRouteFetchInterceptor` (URL-prefix gating, header merge, + * Content-Type defaulting, input-shape polymorphism) plus the loader + * integration that wires it into the AuthHook return shape. + * + * Strategy: replace `globalThis.fetch` with a closure-based recorder for the + * duration of each test (saved-and-restored in try/finally — node:test has + * no built-in spy/restore lifecycle). The recorder captures `(input, init)` + * as observed by the wrapped global call so we can assert on what was + * forwarded after header injection. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createOmniRouteAuthHook, createOmniRouteFetchInterceptor } from "../src/index.js"; + +type FetchCall = { input: Parameters[0]; init?: RequestInit }; + +function installFetchRecorder(response: Response = new Response("ok")) { + const calls: FetchCall[] = []; + const original = globalThis.fetch; + globalThis.fetch = (async (input: any, init?: any) => { + calls.push({ input, init }); + return response; + }) as typeof fetch; + const restore = () => { + globalThis.fetch = original; + }; + return { calls, restore }; +} + +const BASE = "https://or.example.com/v1"; +const KEY = "sk-test-fetch"; + +test("createOmniRouteFetchInterceptor: targets baseURL → Authorization header injected", async () => { + const { calls, restore } = installFetchRecorder(); + try { + const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE }); + await f(`${BASE}/chat/completions`, { + method: "POST", + body: JSON.stringify({ x: 1 }), + }); + assert.equal(calls.length, 1); + const sent = calls[0]!; + const sentHeaders = new Headers((sent.init as RequestInit).headers); + assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`); + } finally { + restore(); + } +}); + +test("createOmniRouteFetchInterceptor: targets baseURL → Authorization OVERRIDES caller-supplied Bearer", async () => { + const { calls, restore } = installFetchRecorder(); + try { + const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE }); + await f(`${BASE}/chat/completions`, { + method: "POST", + body: "{}", + headers: { Authorization: "Bearer attacker-key" }, + }); + const sent = calls[0]!; + const sentHeaders = new Headers((sent.init as RequestInit).headers); + // We own the apiKey for this provider — caller-supplied Bearer must lose. + assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`); + } finally { + restore(); + } +}); + +test("createOmniRouteFetchInterceptor: targets baseURL + body → Content-Type defaults to application/json", async () => { + const { calls, restore } = installFetchRecorder(); + try { + const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE }); + await f(`${BASE}/chat/completions`, { + method: "POST", + body: JSON.stringify({ m: "x" }), + }); + const sent = calls[0]!; + const sentHeaders = new Headers((sent.init as RequestInit).headers); + assert.equal(sentHeaders.get("Content-Type"), "application/json"); + } finally { + restore(); + } +}); + +test("createOmniRouteFetchInterceptor: caller-set Content-Type is NOT overwritten", async () => { + const { calls, restore } = installFetchRecorder(); + try { + const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE }); + await f(`${BASE}/v2/whatever`, { + method: "POST", + body: "raw", + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); + const sent = calls[0]!; + const sentHeaders = new Headers((sent.init as RequestInit).headers); + assert.equal(sentHeaders.get("Content-Type"), "text/plain; charset=utf-8"); + } finally { + restore(); + } +}); + +test("createOmniRouteFetchInterceptor: non-baseURL host → passthrough, no Authorization injected", async () => { + const { calls, restore } = installFetchRecorder(); + try { + const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE }); + await f("https://third-party.example.org/v1/chat", { + method: "POST", + body: "{}", + headers: { "X-Caller": "yes" }, + }); + const sent = calls[0]!; + // Init forwarded verbatim — no header injection. + const sentHeaders = new Headers((sent.init as RequestInit | undefined)?.headers); + assert.equal(sentHeaders.get("Authorization"), null, "MUST NOT leak apiKey"); + assert.equal(sentHeaders.get("X-Caller"), "yes"); + } finally { + restore(); + } +}); + +test("createOmniRouteFetchInterceptor: refuses suffix-spoof — `${base}-attacker.evil` does NOT match baseURL", async () => { + const { calls, restore } = installFetchRecorder(); + try { + const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE }); + // baseURL is `https://or.example.com/v1`. A spoofed + // `https://or.example.com/v1-attacker.evil/chat` shares the literal prefix + // but is NOT under our origin path — must be treated as passthrough. + await f("https://or.example.com/v1-attacker.evil/chat", { + method: "POST", + body: "{}", + }); + const sent = calls[0]!; + const sentHeaders = new Headers((sent.init as RequestInit | undefined)?.headers); + assert.equal(sentHeaders.get("Authorization"), null); + } finally { + restore(); + } +}); + +test("createOmniRouteFetchInterceptor: URL object input is handled", async () => { + const { calls, restore } = installFetchRecorder(); + try { + const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE }); + await f(new URL(`${BASE}/models`), {}); + const sent = calls[0]!; + const sentHeaders = new Headers((sent.init as RequestInit).headers); + assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`); + } finally { + restore(); + } +}); + +test("createOmniRouteFetchInterceptor: Request input is handled (reads .url)", async () => { + const { calls, restore } = installFetchRecorder(); + try { + const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE }); + const req = new Request(`${BASE}/chat/completions`, { + method: "POST", + body: JSON.stringify({ a: 1 }), + headers: { "X-Caller": "preserved" }, + }); + await f(req); + const sent = calls[0]!; + // The interceptor forwards the original Request as `input` but layers our + // headers into the `init`. We assert against the init view since fetch() + // resolves headers from init first when both are present. + const sentHeaders = new Headers((sent.init as RequestInit).headers); + assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`); + assert.equal( + sentHeaders.get("X-Caller"), + "preserved", + "Request-attached headers must survive the merge" + ); + } finally { + restore(); + } +}); + +test("createOmniRouteFetchInterceptor: trailing slash in baseURL is normalized", async () => { + const { calls, restore } = installFetchRecorder(); + try { + const f = createOmniRouteFetchInterceptor({ + apiKey: KEY, + baseURL: `${BASE}////`, + }); + await f(`${BASE}/models`, {}); + const sent = calls[0]!; + const sentHeaders = new Headers((sent.init as RequestInit).headers); + assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`); + } finally { + restore(); + } +}); + +test("createOmniRouteFetchInterceptor: GET without body does NOT set Content-Type", async () => { + const { calls, restore } = installFetchRecorder(); + try { + const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE }); + await f(`${BASE}/models`); // no init at all + const sent = calls[0]!; + const sentHeaders = new Headers((sent.init as RequestInit).headers); + assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`); + assert.equal( + sentHeaders.get("Content-Type"), + null, + "Content-Type should only default when a body exists" + ); + } finally { + restore(); + } +}); + +// ---------------------------------------------------------------------------- +// loader integration +// ---------------------------------------------------------------------------- + +test("loader: returns fetch fn when apiKey + baseURL both present (via opts)", async () => { + const hook = createOmniRouteAuthHook({ baseURL: BASE }); + const result = await hook.loader!(async () => ({ type: "api", key: KEY }) as never, {} as never); + assert.equal((result as { apiKey: string }).apiKey, KEY); + assert.equal((result as { baseURL: string }).baseURL, BASE); + assert.equal( + typeof (result as { fetch?: unknown }).fetch, + "function", + "loader must wire fetch interceptor when baseURL resolves" + ); +}); + +test("loader: returns fetch fn when baseURL is stashed on the auth credential", async () => { + // Some auth backends attach baseURL alongside the key (post-/connect flow). + // The loader should pick it up even when plugin opts.baseURL is unset. + const hook = createOmniRouteAuthHook(); + const result = await hook.loader!( + async () => ({ type: "api", key: KEY, baseURL: BASE }) as never, + {} as never + ); + assert.equal((result as { baseURL?: string }).baseURL, BASE); + assert.equal(typeof (result as { fetch?: unknown }).fetch, "function"); +}); + +test("loader: omits fetch fn when baseURL missing (apiKey-only return)", async () => { + const hook = createOmniRouteAuthHook(); // no baseURL opt + const result = await hook.loader!(async () => ({ type: "api", key: KEY }) as never, {} as never); + // Interceptor needs a baseURL to gate-keep; without one, fall back to + // apiKey-only and let the SDK use its default fetch. + assert.deepEqual(result, { apiKey: KEY }); +}); + +test("loader integration: wired interceptor actually injects Bearer when invoked", async () => { + // End-to-end: pull the fetch fn out of the loader return and exercise it, + // proving the wiring matches the standalone interceptor's contract. + const { calls, restore } = installFetchRecorder(); + try { + const hook = createOmniRouteAuthHook({ baseURL: BASE }); + const result = await hook.loader!( + async () => ({ type: "api", key: KEY }) as never, + {} as never + ); + const wiredFetch = (result as { fetch: typeof fetch }).fetch; + await wiredFetch(`${BASE}/v1/models`, {}); + assert.equal(calls.length, 1); + const sentHeaders = new Headers((calls[0]!.init as RequestInit).headers); + assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`); + } finally { + restore(); + } +}); diff --git a/@omniroute/opencode-plugin/tests/gemini-sanitize.test.ts b/@omniroute/opencode-plugin/tests/gemini-sanitize.test.ts new file mode 100644 index 0000000000..effd66eae9 --- /dev/null +++ b/@omniroute/opencode-plugin/tests/gemini-sanitize.test.ts @@ -0,0 +1,410 @@ +/** + * T-06 Gemini tool-schema sanitisation contract tests. + * + * Three layers under test: + * 1. `sanitizeGeminiToolSchemas` — pure function; key stripping + clone + * semantics on chat-completion + Responses-API shapes. + * 2. `shouldSanitizeForGemini` — model-string detection (liberal). + * 3. `createGeminiSanitizingFetch` — wrapper composition; URL gating, + * body-shape polymorphism, streaming-body bypass, fail-open behaviour, + * composition with the T-04 Bearer interceptor. + * + * Strategy: same posture as fetch-interceptor.test.ts — install a + * closure-based fetch recorder; assert on the `(input, init)` observed by + * the inner fetch after the sanitising wrapper has had its say. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + __resetGeminiStreamingWarning, + createGeminiSanitizingFetch, + createOmniRouteFetchInterceptor, + sanitizeGeminiToolSchemas, + shouldSanitizeForGemini, +} from "../src/index.js"; + +// ──────────────────────────────────────────────────────────────────────────── +// Helpers +// ──────────────────────────────────────────────────────────────────────────── + +type FetchCall = { input: Parameters[0]; init?: RequestInit }; + +function recorder(response: Response = new Response("ok")): { + fn: typeof fetch; + calls: FetchCall[]; +} { + const calls: FetchCall[] = []; + const fn = (async (input: any, init?: any) => { + calls.push({ input, init }); + return response; + }) as typeof fetch; + return { fn, calls }; +} + +function bodyAsRecord(init: RequestInit | undefined): Record { + const b = init?.body; + if (typeof b !== "string") { + throw new Error(`expected string body, got ${typeof b}`); + } + return JSON.parse(b) as Record; +} + +// Sample tool payloads — small enough to inline, big enough to cover +// chat-completion + Responses-API + nested properties. + +function chatCompletionsWithDollarSchema(): Record { + return { + model: "gemini-2.5-pro", + tools: [ + { + type: "function", + function: { + name: "search", + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + additionalProperties: false, + properties: { + q: { type: "string" }, + }, + required: ["q"], + }, + }, + }, + ], + }; +} + +function responsesApiWithRef(): Record { + return { + model: "gemini-2.5-flash", + tools: [ + { + type: "function", + name: "lookup", + input_schema: { + type: "object", + $ref: "#/definitions/Lookup", + properties: { + id: { type: "string", ref: "Id" }, + }, + }, + }, + ], + }; +} + +function nestedPropertiesPayload(): Record { + return { + model: "gemini-pro", + tools: [ + { + type: "function", + function: { + name: "deep", + parameters: { + type: "object", + properties: { + outer: { + type: "object", + $schema: "http://json-schema.org/draft-07/schema#", + properties: { + inner: { + type: "object", + additionalProperties: true, + $ref: "#/inner", + properties: { + leaf: { type: "string" }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// sanitizeGeminiToolSchemas — pure function +// ──────────────────────────────────────────────────────────────────────────── + +test("sanitizeGeminiToolSchemas: strips $schema from top-level", () => { + const input = { + model: "gemini-2.5-pro", + $schema: "http://json-schema.org/draft-07/schema#", + tools: [], + }; + const out = sanitizeGeminiToolSchemas(input) as Record; + assert.equal(out.$schema, undefined); + assert.equal(out.model, "gemini-2.5-pro"); +}); + +test("sanitizeGeminiToolSchemas: strips $ref + additionalProperties from tools[].function.parameters", () => { + const input = chatCompletionsWithDollarSchema(); + const out = sanitizeGeminiToolSchemas(input) as Record; + const params = (out.tools as Array<{ function: { parameters: Record } }>)[0]! + .function.parameters; + assert.equal(params.$schema, undefined); + assert.equal(params.additionalProperties, undefined); + // Untouched keys survive. + assert.equal(params.type, "object"); + assert.deepEqual(params.required, ["q"]); +}); + +test("sanitizeGeminiToolSchemas: strips nested $schema from properties.x.properties.y", () => { + const input = nestedPropertiesPayload(); + const out = sanitizeGeminiToolSchemas(input) as Record; + const params = (out.tools as Array<{ function: { parameters: Record } }>)[0]! + .function.parameters; + const outer = (params.properties as Record>).outer!; + const inner = (outer.properties as Record>).inner!; + assert.equal(outer.$schema, undefined); + assert.equal(inner.$ref, undefined); + assert.equal(inner.additionalProperties, undefined); + // Leaf still intact. + assert.deepEqual(inner.properties, { leaf: { type: "string" } }); +}); + +test("sanitizeGeminiToolSchemas: handles Responses-API tools[].input_schema shape", () => { + const input = responsesApiWithRef(); + const out = sanitizeGeminiToolSchemas(input) as Record; + const inputSchema = (out.tools as Array<{ input_schema: Record }>)[0]! + .input_schema; + assert.equal(inputSchema.$ref, undefined); + // Nested `ref` (lowercase) also stripped. + const props = inputSchema.properties as Record>; + assert.equal(props.id!.ref, undefined); + assert.equal(props.id!.type, "string"); +}); + +test("sanitizeGeminiToolSchemas: leaves payload without tools untouched", () => { + const input = { model: "gemini-2.5-pro", messages: [{ role: "user", content: "hi" }] }; + const out = sanitizeGeminiToolSchemas(input) as Record; + assert.deepEqual(out, input); +}); + +test("sanitizeGeminiToolSchemas: does not mutate input (returned object is distinct)", () => { + const input = chatCompletionsWithDollarSchema(); + const beforeJson = JSON.stringify(input); + const out = sanitizeGeminiToolSchemas(input); + // Input bit-identical to its pre-sanitise serialisation. + assert.equal(JSON.stringify(input), beforeJson); + // Output is a different reference. + assert.notEqual(out, input); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// shouldSanitizeForGemini — detection +// ──────────────────────────────────────────────────────────────────────────── + +test("shouldSanitizeForGemini: gemini-2.5-pro → true", () => { + assert.equal(shouldSanitizeForGemini({ model: "gemini-2.5-pro" }), true); +}); + +test("shouldSanitizeForGemini: models/gemini-pro → true", () => { + assert.equal(shouldSanitizeForGemini({ model: "models/gemini-pro" }), true); +}); + +test("shouldSanitizeForGemini: google-vertex/gemini-1.5-flash → true", () => { + assert.equal(shouldSanitizeForGemini({ model: "google-vertex/gemini-1.5-flash" }), true); +}); + +test("shouldSanitizeForGemini: gemini-cli/gemini-2.5-pro → true (real OmniRoute alias)", () => { + assert.equal(shouldSanitizeForGemini({ model: "gemini-cli/gemini-2.5-pro" }), true); +}); + +test("shouldSanitizeForGemini: claude-sonnet-4 → false", () => { + assert.equal(shouldSanitizeForGemini({ model: "claude-sonnet-4" }), false); +}); + +test("shouldSanitizeForGemini: payload.model missing → false", () => { + assert.equal(shouldSanitizeForGemini({ messages: [] }), false); +}); + +test("shouldSanitizeForGemini: payload is null → false", () => { + assert.equal(shouldSanitizeForGemini(null), false); +}); + +test("shouldSanitizeForGemini: payload.model is non-string → false", () => { + assert.equal(shouldSanitizeForGemini({ model: 42 }), false); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// createGeminiSanitizingFetch — wrapper +// ──────────────────────────────────────────────────────────────────────────── + +const URL_CHAT = "https://or.example.com/v1/chat/completions"; +const URL_RESPONSES = "https://or.example.com/v1/responses"; +const URL_MODELS = "https://or.example.com/v1/models"; + +test("createGeminiSanitizingFetch: gemini model + chat/completions → tool schemas stripped before forward", async () => { + const rec = recorder(); + const wrapped = createGeminiSanitizingFetch(rec.fn); + await wrapped(URL_CHAT, { + method: "POST", + body: JSON.stringify(chatCompletionsWithDollarSchema()), + }); + assert.equal(rec.calls.length, 1); + const forwarded = bodyAsRecord(rec.calls[0]!.init); + const params = ( + forwarded.tools as Array<{ function: { parameters: Record } }> + )[0]!.function.parameters; + assert.equal(params.$schema, undefined); + assert.equal(params.additionalProperties, undefined); +}); + +test("createGeminiSanitizingFetch: non-gemini model + chat/completions → body passed through unchanged", async () => { + const rec = recorder(); + const wrapped = createGeminiSanitizingFetch(rec.fn); + const originalBody = JSON.stringify({ + model: "claude-sonnet-4", + tools: [ + { + type: "function", + function: { + name: "x", + parameters: { $schema: "keep-me", type: "object" }, + }, + }, + ], + }); + await wrapped(URL_CHAT, { method: "POST", body: originalBody }); + // Identity check on body — wrapper must NOT mutate non-Gemini payloads. + assert.equal(rec.calls[0]!.init!.body, originalBody); +}); + +test("createGeminiSanitizingFetch: gemini model + /v1/models (non-completion endpoint) → body passed through unchanged", async () => { + const rec = recorder(); + const wrapped = createGeminiSanitizingFetch(rec.fn); + // GET /v1/models has no body in production; assert that even if a caller + // attached a Gemini-shaped body to a non-completion URL, the wrapper + // doesn't touch it. + const body = JSON.stringify(chatCompletionsWithDollarSchema()); + await wrapped(URL_MODELS, { method: "POST", body }); + assert.equal(rec.calls[0]!.init!.body, body); +}); + +test("createGeminiSanitizingFetch: gemini model + /responses endpoint → input_schema stripped", async () => { + const rec = recorder(); + const wrapped = createGeminiSanitizingFetch(rec.fn); + await wrapped(URL_RESPONSES, { + method: "POST", + body: JSON.stringify(responsesApiWithRef()), + }); + const forwarded = bodyAsRecord(rec.calls[0]!.init); + const schema = (forwarded.tools as Array<{ input_schema: Record }>)[0]! + .input_schema; + assert.equal(schema.$ref, undefined); +}); + +test("createGeminiSanitizingFetch: gemini model + Request input with body → tool schemas stripped", async () => { + const rec = recorder(); + const wrapped = createGeminiSanitizingFetch(rec.fn); + const req = new Request(URL_CHAT, { + method: "POST", + body: JSON.stringify(chatCompletionsWithDollarSchema()), + headers: { "Content-Type": "application/json" }, + }); + await wrapped(req); + const forwarded = bodyAsRecord(rec.calls[0]!.init); + const params = ( + forwarded.tools as Array<{ function: { parameters: Record } }> + )[0]!.function.parameters; + assert.equal(params.$schema, undefined); +}); + +test("createGeminiSanitizingFetch: gemini model + ReadableStream body → skipped + warn emitted once", async () => { + __resetGeminiStreamingWarning(); + const rec = recorder(); + const wrapped = createGeminiSanitizingFetch(rec.fn); + + // Capture console.warn for the duration of this test. + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args.map(String).join(" ")); + }; + + try { + const stream1 = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("{}")); + controller.close(); + }, + }); + const stream2 = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("{}")); + controller.close(); + }, + }); + // Two streaming calls — only one warn expected. + await wrapped(URL_CHAT, { method: "POST", body: stream1 }); + await wrapped(URL_CHAT, { method: "POST", body: stream2 }); + } finally { + console.warn = originalWarn; + } + + // Both calls forwarded to inner fetch with their streams intact. + assert.equal(rec.calls.length, 2); + // ONE warning total — one-shot latch held. + assert.equal(warnings.length, 1); + assert.match(warnings[0]!, /streaming Request body, skipping schema strip/); +}); + +test("createGeminiSanitizingFetch: invalid JSON body → pass through, no throw", async () => { + const rec = recorder(); + const wrapped = createGeminiSanitizingFetch(rec.fn); + // Garbage body must not crash the wrapper. + await wrapped(URL_CHAT, { method: "POST", body: "this is not json{{" }); + assert.equal(rec.calls.length, 1); + assert.equal(rec.calls[0]!.init!.body, "this is not json{{"); +}); + +test("createGeminiSanitizingFetch: empty body → pass through unchanged", async () => { + const rec = recorder(); + const wrapped = createGeminiSanitizingFetch(rec.fn); + await wrapped(URL_CHAT, { method: "POST" }); + assert.equal(rec.calls.length, 1); +}); + +test("createGeminiSanitizingFetch: composes correctly with createOmniRouteFetchInterceptor (Bearer + sanitization)", async () => { + // Save and replace globalThis.fetch — the Bearer interceptor calls global + // fetch when the URL targets its baseURL. + const originalFetch = globalThis.fetch; + const observed: FetchCall[] = []; + globalThis.fetch = (async (input: any, init?: any) => { + observed.push({ input, init }); + return new Response("ok"); + }) as typeof fetch; + + try { + const composed = createGeminiSanitizingFetch( + createOmniRouteFetchInterceptor({ + apiKey: "sk-test", + baseURL: "https://or.example.com/v1", + }) + ); + await composed(URL_CHAT, { + method: "POST", + body: JSON.stringify(chatCompletionsWithDollarSchema()), + }); + + assert.equal(observed.length, 1); + // Bearer injected (header concern). + const sentHeaders = new Headers((observed[0]!.init as RequestInit).headers); + assert.equal(sentHeaders.get("Authorization"), "Bearer sk-test"); + // Schema sanitised (body concern). + const forwarded = bodyAsRecord(observed[0]!.init); + const params = ( + forwarded.tools as Array<{ function: { parameters: Record } }> + )[0]!.function.parameters; + assert.equal(params.$schema, undefined); + assert.equal(params.additionalProperties, undefined); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/@omniroute/opencode-plugin/tests/multi-instance.test.ts b/@omniroute/opencode-plugin/tests/multi-instance.test.ts new file mode 100644 index 0000000000..a7852a95b9 --- /dev/null +++ b/@omniroute/opencode-plugin/tests/multi-instance.test.ts @@ -0,0 +1,136 @@ +/** + * T-08 multi-instance smoke. + * + * Validates that two `OmniRoutePlugin(input, opts)` invocations with + * different `providerId` values coexist without sharing mutable state. + * This is the contract that lets opencode.json declare prod + preprod + * side by side: + * + * "plugin": [ + * ["@omniroute/opencode-plugin", {"providerId": "omniroute-prod", "baseURL": "https://or.example/v1"}], + * ["@omniroute/opencode-plugin", {"providerId": "omniroute-preprod", "baseURL": "https://or-preprod.example/v1"}] + * ] + * + * Assertions: + * - Each invocation returns its own hooks object (no identity reuse). + * - Each `auth` hook carries its own `provider` matching opts.providerId. + * - Each `auth.methods` array is its own array (not the same reference). + * - Calling the factory twice with IDENTICAL opts still yields two + * independent objects (no instance reuse / no shared closure cache). + * - Mutating one instance's auth hook does NOT bleed into the other. + * - Each instance's loader closure captures its OWN baseURL — no + * last-write-wins module-scope state. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { OmniRoutePlugin } from "../src/index.js"; + +const fakeInput = {} as Parameters[0]; + +test("multi-instance: two plugin invocations bind to their own providerId", async () => { + const a = await OmniRoutePlugin(fakeInput, { + providerId: "omniroute-prod", + baseURL: "https://a.example/v1", + }); + const b = await OmniRoutePlugin(fakeInput, { + providerId: "omniroute-preprod", + baseURL: "https://b.example/v1", + }); + + assert.equal(a.auth?.provider, "omniroute-prod"); + assert.equal(b.auth?.provider, "omniroute-preprod"); +}); + +test("multi-instance: hook objects + nested arrays are independent references", async () => { + const a = await OmniRoutePlugin(fakeInput, { + providerId: "alpha", + baseURL: "https://a.example/v1", + }); + const b = await OmniRoutePlugin(fakeInput, { + providerId: "bravo", + baseURL: "https://b.example/v1", + }); + + assert.notEqual(a, b, "top-level hooks objects must not be the same reference"); + assert.notEqual(a.auth, b.auth, "auth hooks must not be the same reference"); + assert.notEqual( + a.auth?.methods, + b.auth?.methods, + "methods arrays must not be the same reference" + ); +}); + +test("multi-instance: identical opts twice still yield independent objects", async () => { + const opts = { providerId: "twin", baseURL: "https://twin.example/v1" }; + const first = await OmniRoutePlugin(fakeInput, { ...opts }); + const second = await OmniRoutePlugin(fakeInput, { ...opts }); + + assert.notEqual(first, second); + assert.notEqual(first.auth, second.auth); + assert.notEqual(first.auth?.methods, second.auth?.methods); + // Same provider id is fine — what matters is no shared mutable state. + assert.equal(first.auth?.provider, "twin"); + assert.equal(second.auth?.provider, "twin"); +}); + +test("multi-instance: mutating instance A's auth.methods does not affect instance B", async () => { + const a = await OmniRoutePlugin(fakeInput, { + providerId: "iso-a", + baseURL: "https://a.example/v1", + }); + const b = await OmniRoutePlugin(fakeInput, { + providerId: "iso-b", + baseURL: "https://b.example/v1", + }); + + const beforeLen = b.auth?.methods?.length ?? 0; + // Mutate a's methods array — extend it; b's must be untouched. + // We don't know the concrete method shape so push a sentinel cast. + a.auth?.methods?.push({ type: "api", label: "sentinel" } as never); + assert.equal(b.auth?.methods?.length, beforeLen, "instance B leaked from instance A mutation"); +}); + +test("multi-instance: loader closures see their own opts (not last-write-wins)", async () => { + // Each plugin's loader builds its loader payload from the providerId/baseURL + // captured at invocation time. If the factory accidentally shared a closure + // (e.g. a module-scope let that the last invocation overwrites), both + // loaders would emit the same baseURL. Verify they don't. + const a = await OmniRoutePlugin(fakeInput, { + providerId: "omniroute-prod", + baseURL: "https://prod.example/v1", + }); + const b = await OmniRoutePlugin(fakeInput, { + providerId: "omniroute-preprod", + baseURL: "https://preprod.example/v1", + }); + + assert.ok(a.auth?.loader, "instance A must have a loader"); + assert.ok(b.auth?.loader, "instance B must have a loader"); + + const getAuthA = async () => ({ type: "api", key: "sk-prod" }) as never; + const getAuthB = async () => ({ type: "api", key: "sk-preprod" }) as never; + + const rA = (await a.auth!.loader!(getAuthA, {} as never)) as Record; + const rB = (await b.auth!.loader!(getAuthB, {} as never)) as Record; + + assert.equal(rA.apiKey, "sk-prod"); + assert.equal(rA.baseURL, "https://prod.example/v1"); + assert.equal(rB.apiKey, "sk-preprod"); + assert.equal(rB.baseURL, "https://preprod.example/v1"); +}); + +test("multi-instance: invalid opts on one instance does not poison the other", async () => { + // Sequencing: bad opts → good opts. The bad call must throw cleanly; the + // good call must still produce a working hooks object. Confirms no + // half-built module-level state survives a failed parse. + await assert.rejects( + () => OmniRoutePlugin(fakeInput, { providerId: "bad id!" } as never), + /providerId/ + ); + const ok = await OmniRoutePlugin(fakeInput, { + providerId: "recovered", + baseURL: "https://ok.example/v1", + }); + assert.equal(ok.auth?.provider, "recovered"); +}); diff --git a/@omniroute/opencode-plugin/tests/options-schema.test.ts b/@omniroute/opencode-plugin/tests/options-schema.test.ts new file mode 100644 index 0000000000..435363946c --- /dev/null +++ b/@omniroute/opencode-plugin/tests/options-schema.test.ts @@ -0,0 +1,104 @@ +/** + * T-08 options-schema tests. + * + * Covers `parseOmniRoutePluginOptions(opts)` — the strict Zod gate that + * validates the second-arg `PluginOptions` bag from opencode.json before + * any hook is wired. Anti-pattern checklist mirrored here: + * + * - `null` / `undefined` must collapse to `{}` (defaults apply downstream). + * - Unknown keys must THROW (`.strict()` catches opencode.json typos). + * - Validation runs at parse time, not import time (module loads cleanly). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { parseOmniRoutePluginOptions } from "../src/index.js"; + +test("parseOmniRoutePluginOptions: undefined → {}", () => { + assert.deepEqual(parseOmniRoutePluginOptions(undefined), {}); +}); + +test("parseOmniRoutePluginOptions: null → {}", () => { + assert.deepEqual(parseOmniRoutePluginOptions(null), {}); +}); + +test("parseOmniRoutePluginOptions: empty object → {}", () => { + assert.deepEqual(parseOmniRoutePluginOptions({}), {}); +}); + +test("parseOmniRoutePluginOptions: valid providerId → returns it", () => { + const r = parseOmniRoutePluginOptions({ providerId: "omniroute-preprod" }); + assert.equal(r.providerId, "omniroute-preprod"); +}); + +test("parseOmniRoutePluginOptions: invalid providerId (special chars) → throws", () => { + assert.throws( + () => parseOmniRoutePluginOptions({ providerId: "omniroute prod!" }), + /providerId.*slug/i + ); +}); + +test("parseOmniRoutePluginOptions: empty providerId → throws", () => { + assert.throws(() => parseOmniRoutePluginOptions({ providerId: "" }), /providerId/i); +}); + +test("parseOmniRoutePluginOptions: valid modelCacheTtl → returns it", () => { + const r = parseOmniRoutePluginOptions({ modelCacheTtl: 60_000 }); + assert.equal(r.modelCacheTtl, 60_000); +}); + +test("parseOmniRoutePluginOptions: negative modelCacheTtl → throws", () => { + assert.throws(() => parseOmniRoutePluginOptions({ modelCacheTtl: -1 }), /modelCacheTtl/i); +}); + +test("parseOmniRoutePluginOptions: zero modelCacheTtl → throws (positive required)", () => { + assert.throws(() => parseOmniRoutePluginOptions({ modelCacheTtl: 0 }), /modelCacheTtl/i); +}); + +test("parseOmniRoutePluginOptions: invalid baseURL (not a URL) → throws", () => { + assert.throws(() => parseOmniRoutePluginOptions({ baseURL: "not-a-url" }), /baseURL/i); +}); + +test("parseOmniRoutePluginOptions: unknown key → throws (strict mode catches typos)", () => { + assert.throws( + () => + parseOmniRoutePluginOptions({ + providerId: "omniroute", + provider_id: "typo-here", + }), + /provider_id|unrecognized/i + ); +}); + +test("parseOmniRoutePluginOptions: all four fields populated correctly → returns them", () => { + const opts = { + providerId: "omniroute-prod", + displayName: "OmniRoute Production", + modelCacheTtl: 120_000, + baseURL: "https://or.example.com/v1", + }; + const r = parseOmniRoutePluginOptions(opts); + assert.deepEqual(r, opts); +}); + +test("parseOmniRoutePluginOptions: error message lists every issue path", () => { + // Two bad fields at once → error string should mention BOTH. + try { + parseOmniRoutePluginOptions({ + providerId: "", + baseURL: "garbage", + }); + assert.fail("expected throw"); + } catch (err) { + const msg = (err as Error).message; + assert.match(msg, /providerId/); + assert.match(msg, /baseURL/); + } +}); + +test("parseOmniRoutePluginOptions: module import alone does NOT throw", async () => { + // Re-importing the entry must not trigger validation; validation only fires + // on explicit parseOmniRoutePluginOptions / OmniRoutePlugin invocation. + const mod = await import("../src/index.js"); + assert.equal(typeof mod.parseOmniRoutePluginOptions, "function"); +}); diff --git a/@omniroute/opencode-plugin/tests/provider.test.ts b/@omniroute/opencode-plugin/tests/provider.test.ts new file mode 100644 index 0000000000..f0b3a16fd4 --- /dev/null +++ b/@omniroute/opencode-plugin/tests/provider.test.ts @@ -0,0 +1,269 @@ +/** + * T-03 provider-hook contract tests. + * + * Covers `createOmniRouteProviderHook(opts, deps)`: + * - hook.id binds to resolved providerId (single + multi-instance) + * - models() narrows ctx.auth, fetches via injected fetcher, caches per + * (baseURL, apiKey) tuple, refetches after TTL + * - mapRawModelToModelV2 emits a v2 Model shape matching the + * @opencode-ai/sdk/v2 type + * + * Mocking strategy: the fetcher is dependency-injected at hook construction + * (`deps.fetcher`). No global fetch monkey-patch needed. `deps.now` lets us + * fast-forward time deterministically for TTL assertions. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { + createOmniRouteProviderHook, + mapRawModelToModelV2, + type OmniRouteRawModelEntry, + type OmniRouteModelsFetcher, +} from "../src/index.js"; + +const FIXTURE: OmniRouteRawModelEntry[] = [ + { + id: "claude-primary", + object: "model", + owned_by: "combo", + capabilities: { tool_calling: true, reasoning: true, vision: true, thinking: true }, + context_length: 200000, + max_output_tokens: 64000, + input_modalities: ["text", "image"], + output_modalities: ["text"], + }, + { + id: "claude-low", + object: "model", + owned_by: "combo", + capabilities: { tool_calling: true, reasoning: true, vision: true, thinking: false }, + context_length: 200000, + max_output_tokens: 64000, + input_modalities: ["text", "image"], + output_modalities: ["text"], + }, + { + id: "gemini-3-flash", + object: "model", + owned_by: "google", + capabilities: { tool_calling: true, reasoning: false, vision: true, thinking: false }, + context_length: 1000000, + max_output_tokens: 8192, + input_modalities: ["text", "image"], + output_modalities: ["text"], + }, +]; + +function stubFetcher(payload: OmniRouteRawModelEntry[]): OmniRouteModelsFetcher & { + callCount: () => number; + callsBy: () => Array<[string, string]>; +} { + let calls: Array<[string, string]> = []; + const f: OmniRouteModelsFetcher = async (baseURL, apiKey) => { + calls.push([baseURL, apiKey]); + return payload; + }; + return Object.assign(f, { + callCount: () => calls.length, + callsBy: () => calls, + }); +} + +const apiAuth = (key: string, baseURL?: string): unknown => + baseURL ? { type: "api", key, baseURL } : { type: "api", key }; + +test("createOmniRouteProviderHook: default providerId is 'omniroute'", () => { + const hook = createOmniRouteProviderHook(undefined, { combosFetcher: async () => [] }); + assert.equal(hook.id, "omniroute"); +}); + +test("createOmniRouteProviderHook: custom providerId binds to hook.id (multi-instance)", () => { + const a = createOmniRouteProviderHook( + { providerId: "omniroute-preprod" }, + { combosFetcher: async () => [] } + ); + const b = createOmniRouteProviderHook( + { providerId: "omniroute-local" }, + { combosFetcher: async () => [] } + ); + assert.equal(a.id, "omniroute-preprod"); + assert.equal(b.id, "omniroute-local"); +}); + +test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it", async () => { + const fetcher = stubFetcher(FIXTURE); + const hook = createOmniRouteProviderHook( + { baseURL: "https://or.example.com/v1" }, + { fetcher, combosFetcher: async () => [] } + ); + const out = await hook.models!({} as never, { auth: apiAuth("sk-abc") as never }); + assert.equal(fetcher.callCount(), 1); + assert.deepEqual(fetcher.callsBy()[0], ["https://or.example.com/v1", "sk-abc"]); + assert.equal(Object.keys(out).length, 3); + assert.ok(out["claude-primary"]); +}); + +test("models: returns {} when ctx.auth is null/undefined/wrong-type/empty-key", async () => { + const fetcher = stubFetcher(FIXTURE); + const hook = createOmniRouteProviderHook( + { baseURL: "https://or.example.com/v1" }, + { fetcher, combosFetcher: async () => [] } + ); + + assert.deepEqual(await hook.models!({} as never, {} as never), {}); + assert.deepEqual(await hook.models!({} as never, { auth: undefined } as never), {}); + assert.deepEqual( + await hook.models!({} as never, { + auth: { type: "oauth", refresh: "r", access: "a", expires: 0 } as never, + }), + {} + ); + assert.deepEqual( + await hook.models!({} as never, { auth: { type: "api", key: "" } as never }), + {} + ); + assert.equal(fetcher.callCount(), 0, "fetcher must not be called on auth rejection"); +}); + +test("models: returns {} when no baseURL resolvable (no opts.baseURL and no auth.baseURL)", async () => { + const fetcher = stubFetcher(FIXTURE); + const hook = createOmniRouteProviderHook({}, { fetcher, combosFetcher: async () => [] }); + // valid api auth but neither opts nor auth carries a baseURL + assert.deepEqual(await hook.models!({} as never, { auth: apiAuth("sk-x") as never }), {}); + assert.equal(fetcher.callCount(), 0); +}); + +test("models: baseURL falls back to auth.baseURL when opts.baseURL absent", async () => { + const fetcher = stubFetcher(FIXTURE); + const hook = createOmniRouteProviderHook({}, { fetcher, combosFetcher: async () => [] }); + const out = await hook.models!({} as never, { + auth: apiAuth("sk-y", "https://or.creds-attached.example/v1") as never, + }); + assert.equal(fetcher.callCount(), 1); + assert.equal(fetcher.callsBy()[0][0], "https://or.creds-attached.example/v1"); + assert.equal(Object.keys(out).length, 3); +}); + +test("models: maps a sample /v1/models entry to ModelV2 (sanity)", async () => { + const fetcher = stubFetcher(FIXTURE); + const hook = createOmniRouteProviderHook( + { providerId: "omniroute", baseURL: "https://or.example.com/v1" }, + { fetcher, combosFetcher: async () => [] } + ); + const out = await hook.models!({} as never, { auth: apiAuth("sk-abc") as never }); + const claude = out["claude-primary"]; + assert.ok(claude, "claude-primary present"); + assert.equal(claude.id, "claude-primary"); + assert.equal(claude.name, "claude-primary"); + assert.equal(claude.providerID, "omniroute"); + assert.equal(claude.api.id, "openai-compatible"); + assert.equal(claude.api.url, "https://or.example.com/v1"); + assert.equal(claude.api.npm, "@ai-sdk/openai-compatible"); + // capabilities: toolcall (one word), reasoning OR thinking, attachment = vision + assert.equal(claude.capabilities.toolcall, true); + assert.equal(claude.capabilities.reasoning, true); + assert.equal(claude.capabilities.attachment, true); + assert.equal(claude.capabilities.temperature, true); + // modalities mapped from arrays + assert.equal(claude.capabilities.input.text, true); + assert.equal(claude.capabilities.input.image, true); + assert.equal(claude.capabilities.input.audio, false); + assert.equal(claude.capabilities.output.text, true); + assert.equal(claude.capabilities.output.image, false); + // cost is zeroed (OmniRoute /v1/models has no pricing) + assert.deepEqual(claude.cost, { input: 0, output: 0, cache: { read: 0, write: 0 } }); + // limits + assert.equal(claude.limit.context, 200000); + assert.equal(claude.limit.output, 64000); + assert.equal(claude.status, "active"); +}); + +test("mapRawModelToModelV2: thinking-only model still surfaces reasoning=true", () => { + const m = mapRawModelToModelV2( + { + id: "thinking-only", + capabilities: { thinking: true, reasoning: false }, + context_length: 100000, + max_output_tokens: 8192, + }, + { providerId: "omniroute", baseURL: "https://or.example.com/v1" } + ); + assert.equal(m.capabilities.reasoning, true); +}); + +test("mapRawModelToModelV2: missing capabilities defaults to all-false (except temperature)", () => { + const m = mapRawModelToModelV2( + { id: "minimal" }, + { providerId: "omniroute", baseURL: "https://or.example.com/v1" } + ); + assert.equal(m.capabilities.temperature, true); + assert.equal(m.capabilities.reasoning, false); + assert.equal(m.capabilities.attachment, false); + assert.equal(m.capabilities.toolcall, false); + // default modalities = text only + assert.equal(m.capabilities.input.text, true); + assert.equal(m.capabilities.output.text, true); + // missing context / output tokens → 0 fallback (ModelV2.limit.{context,output} required) + assert.equal(m.limit.context, 0); + assert.equal(m.limit.output, 0); +}); + +test("models: caches result for second call within TTL (fetcher called once)", async () => { + const fetcher = stubFetcher(FIXTURE); + let nowMs = 1_000_000; + const hook = createOmniRouteProviderHook( + { baseURL: "https://or.example.com/v1", modelCacheTtl: 60_000 }, + { fetcher, now: () => nowMs, combosFetcher: async () => [] } + ); + + const a = await hook.models!({} as never, { auth: apiAuth("sk-z") as never }); + nowMs += 30_000; // half the TTL + const b = await hook.models!({} as never, { auth: apiAuth("sk-z") as never }); + assert.equal(fetcher.callCount(), 1, "second call within TTL must hit the cache"); + assert.equal(Object.keys(a).length, 3); + assert.equal(Object.keys(b).length, 3); +}); + +test("models: refetches after TTL expires", async () => { + const fetcher = stubFetcher(FIXTURE); + let nowMs = 1_000_000; + const hook = createOmniRouteProviderHook( + { baseURL: "https://or.example.com/v1", modelCacheTtl: 60_000 }, + { fetcher, now: () => nowMs, combosFetcher: async () => [] } + ); + + await hook.models!({} as never, { auth: apiAuth("sk-z") as never }); + nowMs += 60_001; // just past the TTL + await hook.models!({} as never, { auth: apiAuth("sk-z") as never }); + assert.equal(fetcher.callCount(), 2, "call past TTL must refetch"); +}); + +test("models: caches per (baseURL, apiKey) tuple (different keys → independent fetches)", async () => { + const fetcher = stubFetcher(FIXTURE); + const hook = createOmniRouteProviderHook( + { baseURL: "https://or.example.com/v1", modelCacheTtl: 300_000 }, + { fetcher, combosFetcher: async () => [] } + ); + + await hook.models!({} as never, { auth: apiAuth("sk-A") as never }); + await hook.models!({} as never, { auth: apiAuth("sk-B") as never }); + await hook.models!({} as never, { auth: apiAuth("sk-A") as never }); // cached + await hook.models!({} as never, { auth: apiAuth("sk-B") as never }); // cached + assert.equal(fetcher.callCount(), 2, "one fetch per distinct apiKey, then cache hits"); +}); + +test("models: caches per (baseURL, apiKey) tuple (different baseURL → independent fetches)", async () => { + const fetcher = stubFetcher(FIXTURE); + const hook = createOmniRouteProviderHook( + { modelCacheTtl: 300_000 }, // no opts.baseURL → falls back to auth.baseURL + { fetcher, combosFetcher: async () => [] } + ); + + await hook.models!({} as never, { auth: apiAuth("sk-same", "https://prod.example/v1") as never }); + await hook.models!({} as never, { + auth: apiAuth("sk-same", "https://preprod.example/v1") as never, + }); + await hook.models!({} as never, { auth: apiAuth("sk-same", "https://prod.example/v1") as never }); // cached + assert.equal(fetcher.callCount(), 2, "distinct baseURLs share apiKey but not cache"); +}); diff --git a/@omniroute/opencode-plugin/tests/scaffold.test.ts b/@omniroute/opencode-plugin/tests/scaffold.test.ts new file mode 100644 index 0000000000..36aa4c7e46 --- /dev/null +++ b/@omniroute/opencode-plugin/tests/scaffold.test.ts @@ -0,0 +1,73 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { + OmniRoutePlugin, + OMNIROUTE_PROVIDER_KEY, + DEFAULT_MODEL_CACHE_TTL_MS, + resolveOmniRoutePluginOptions, +} from "../src/index.js"; + +test("scaffold: exports public surface", () => { + assert.equal( + typeof OmniRoutePlugin, + "function", + "OmniRoutePlugin must be a function (Plugin factory)" + ); + assert.equal(OMNIROUTE_PROVIDER_KEY, "omniroute"); + assert.equal(DEFAULT_MODEL_CACHE_TTL_MS, 300_000); +}); + +test("scaffold: default export is v1 plugin shape { id, server: OmniRoutePlugin }", async () => { + const mod = await import("../src/index.js"); + assert.equal(typeof mod.default, "object"); + assert.equal(mod.default.id, "@omniroute/opencode-plugin"); + assert.equal(mod.default.server, mod.OmniRoutePlugin); +}); + +test("resolveOmniRoutePluginOptions: defaults", () => { + const r = resolveOmniRoutePluginOptions(); + assert.equal(r.providerId, "omniroute"); + assert.equal(r.displayName, "OmniRoute"); + assert.equal(r.modelCacheTtl, 300_000); + assert.equal(r.baseURL, undefined); +}); + +test("resolveOmniRoutePluginOptions: custom providerId derives displayName", () => { + const r = resolveOmniRoutePluginOptions({ providerId: "omniroute-preprod" }); + assert.equal(r.providerId, "omniroute-preprod"); + assert.equal(r.displayName, "OmniRoute (omniroute-preprod)"); +}); + +test("resolveOmniRoutePluginOptions: explicit displayName wins", () => { + const r = resolveOmniRoutePluginOptions({ + providerId: "omniroute-x", + displayName: "Custom Label", + }); + assert.equal(r.displayName, "Custom Label"); +}); + +test("resolveOmniRoutePluginOptions: invalid TTL falls back to default", () => { + assert.equal(resolveOmniRoutePluginOptions({ modelCacheTtl: 0 }).modelCacheTtl, 300_000); + assert.equal(resolveOmniRoutePluginOptions({ modelCacheTtl: -1 }).modelCacheTtl, 300_000); +}); + +test("resolveOmniRoutePluginOptions: positive TTL respected", () => { + assert.equal(resolveOmniRoutePluginOptions({ modelCacheTtl: 60_000 }).modelCacheTtl, 60_000); +}); + +test("OmniRoutePlugin: returns an empty hooks object (scaffold)", async () => { + const fakeCtx = {} as Parameters[0]; + const hooks = await OmniRoutePlugin(fakeCtx); + assert.equal(typeof hooks, "object"); + assert.notEqual(hooks, null); +}); + +test("scaffold: CJS default export resolves via require() with v1 shape", () => { + const require_ = createRequire(import.meta.url); + const cjs = require_("../dist/index.cjs"); + // after cjsInterop:true, default export is on cjs.default + assert.strictEqual(typeof cjs.default, "object"); + assert.strictEqual(cjs.default.id, "@omniroute/opencode-plugin"); + assert.strictEqual(typeof cjs.default.server, "function"); +}); diff --git a/@omniroute/opencode-plugin/tsconfig.json b/@omniroute/opencode-plugin/tsconfig.json new file mode 100644 index 0000000000..05ee599fbc --- /dev/null +++ b/@omniroute/opencode-plugin/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "noUncheckedIndexedAccess": false, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "tests"] +} diff --git a/@omniroute/opencode-plugin/tsup.config.ts b/@omniroute/opencode-plugin/tsup.config.ts new file mode 100644 index 0000000000..97d4437dd8 --- /dev/null +++ b/@omniroute/opencode-plugin/tsup.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm", "cjs"], + dts: true, + clean: true, + sourcemap: false, + splitting: false, + treeshake: false, + target: "node22", + outDir: "dist", + minify: false, + cjsInterop: true, + // Bundle runtime deps so the .tgz / npm install is self-contained. + // `zod` is required at runtime by the options schema and would otherwise + // need a peer install when the plugin is loaded directly from a file path + // in opencode.jsonc. + noExternal: ["zod"], +});