From b345c7f6cd4e1590d1177540813302375a75e332 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Sun, 6 Sep 2026 23:36:27 +0200 Subject: [PATCH] feat(opencode): opencode v2 plugin publishing the OmniRoute catalog (#12870) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit opencode v2 loads plugins through a contract the existing @omniroute/opencode-plugin cannot satisfy: v1 exports plugin factories with an auth/provider/config/tool hook object, v2 expects a default define({id, setup}) carrying catalog and integration domains. One package would have to satisfy both loaders from a single entrypoint. An opencode v2 install therefore has no route to an OmniRoute gateway at all: no model discovery, no combos, no enrichment. This adds @omniroute/opencode-plugin-v2, a self-contained package. The v1 plugin is untouched, so v1 users see no move, no migration and no breaking version. The two packages deliberately share no code and no release: the mapping logic here began as a port of v1's and now lives in this package, which keeps either one free to change without a coordinated publish. The plugin publishes models, combos and auto-combos into the host catalog, refreshes them lazily behind a 300s TTL, and keeps serving the last known catalog from an on-disk snapshot when the gateway is unreachable. Publishing is staged: models and combos are what a catalog is, so they go out as soon as they are known, while auto-combos, the provider list and the enrichment overlay fold into the snapshot when they land. Gating the publish on all of them made the catalog hostage to the slowest source — a gateway that accepts the connection and never answers /api/combos/auto left everything unpublished until that fetch timed out, which is longer than a short-lived host stays alive. Display names carry what the gateway knows about a model: the upstream provider it routes to, whether it is free, and the budget that comes with it. Those parts were already fetched and then dropped, so two connections selling the same model looked identical in the picker. The provider prefix can be turned off with `providerTag: false`. The on-disk snapshot carries that overlay too, under a size cap, so a cold start opens on named models rather than raw ids. The host is asked to reload only when the catalog or the overlay actually moved, never once per refresh window. The gateway key comes from the host credential store when one is connected, so connecting the integration from opencode is enough and no secret needs to sit in opencode.json; a plugin option and an environment variable remain as fallbacks, and a host too old to expose a credential store still loads. Nothing is silent when a key is missing or refused: an absent key is named once at startup with the three ways to supply one, and an enrichment source the gateway rejects is reported per endpoint with what the catalog loses. Those three failures used to be empty catch blocks, which turned a management token the gateway refuses into a catalog of raw model ids with no explanation. Tool calling to Gemini keeps working. Gemini answers 400 INVALID_ARGUMENT for an entire request whose tool declarations carry $schema, $ref or additionalProperties. The v1 plugin handled it by wrapping fetch and rewriting the JSON body; v2 does it on the language model, where the tools are still structured data, and only for Gemini models of this provider. It can be turned off with geminiSanitization: false, and a host exposing no aisdk domain loads without it. The catalog contract itself is a moving target, so the plugin adapts to the host instead of assuming one shape. The released CLI keeps the aisdk package, the endpoint (as settings.baseURL), the request headers and the variant options directly on the model and provider; the current SDK types keep the same information inside an api block. Writing only the api block yields a catalog the released CLI lists but cannot route. Rather than key off a version list that goes stale on the next release, the plugin reads the shape the host seeds into the catalog draft and publishes accordingly: a seed with a top-level package and no api block gets both field sets, a seed with an api block gets that block alone, and an undisclosed seed gets both. None of the legacy keys collide with a key of the current types, so the two shapes coexist on one object, variants included. Four v1 behaviours are deliberately not carried over, because v2 either owns them or no longer needs them: the plugin-side debug log (the host has its own logging), the compression-metadata suffix on combo names, the MCP auto-emit (the v2 host owns MCP), and the omni-sync command plus its background timer (the TTL and a content fingerprint drive catalog.reload instead). A refresh never downgrades what is already published: the previous overlay is carried forward until the new one lands, so names, pricing and the usable filter no longer drop out for the length of every TTL window. The disk snapshot is read after the credential is resolved, because it is keyed by that credential — reading it earlier looked up the identity the options carry rather than the one in use, and rejected a perfectly good catalog exactly when the gateway was down. The tool-schema cleaner now knows where a schema ends and a property name begins. Stripping keywords by name anywhere in the tree deleted a tool parameter called `ref` while leaving it in `required`, handing the model a schema it could not satisfy; a `$ref` it cannot resolve now forwards the tool untouched instead of widening it to accept anything. Gemini detection is anchored on the model family, so `gemini-compatible-proxy` is no longer treated as a Gemini model. A source the gateway refuses is reported on the library entry point as well, not only through the plugin, so the usable-provider filter can no longer disable itself in silence. `providerId` is bounded to a safe character set because it reaches a filesystem path, `hiddenModels` covers combos as it already covered models, the Anthropic block gets the gateway root rather than a doubled `/v1`, an unparseable tool schema forwards the tool instead of failing the request, and the package typechecks under the same settings as the v1 plugin. CI mirrors the existing plugin workflow: install, build and test on Node 22 and 24, for both packages. The plugin SDK stays pinned, and the host-shape assertions carry the risk of a contract move rather than a check against a rolling upstream tag. Co-authored-by: Max --- .github/workflows/npm-publish.yml | 89 + .github/workflows/opencode-plugin-ci.yml | 38 +- @omniroute/opencode-plugin-v2/.gitignore | 4 + @omniroute/opencode-plugin-v2/LICENSE | 21 + @omniroute/opencode-plugin-v2/README.md | 107 + @omniroute/opencode-plugin-v2/RELEASE.md | 9 + .../opencode-plugin-v2/package-lock.json | 2364 +++++++++++++++++ @omniroute/opencode-plugin-v2/package.json | 67 + @omniroute/opencode-plugin-v2/src/cache.ts | 211 ++ @omniroute/opencode-plugin-v2/src/catalog.ts | 798 ++++++ @omniroute/opencode-plugin-v2/src/compat.ts | 66 + .../opencode-plugin-v2/src/credentials.ts | 101 + .../src/enrichment-report.ts | 41 + .../opencode-plugin-v2/src/gemini-language.ts | 43 + @omniroute/opencode-plugin-v2/src/index.ts | 539 ++++ @omniroute/opencode-plugin-v2/src/options.ts | 117 + .../src/shared/auto-combos.ts | 219 ++ .../src/shared/combos-map.ts | 254 ++ .../opencode-plugin-v2/src/shared/enrich.ts | 606 +++++ .../src/shared/fingerprint.ts | 127 + .../opencode-plugin-v2/src/shared/gemini.ts | 166 ++ .../opencode-plugin-v2/src/shared/index.ts | 9 + .../opencode-plugin-v2/src/shared/logger.ts | 81 + .../src/shared/models-map.ts | 323 +++ .../opencode-plugin-v2/src/shared/naming.ts | 295 ++ .../opencode-plugin-v2/src/shared/usable.ts | 171 ++ .../tests/api-package.test.ts | 93 + .../tests/auto-combos.test.ts | 196 ++ .../tests/cache-ttl-snapshot.test.ts | 378 +++ .../opencode-plugin-v2/tests/catalog.test.ts | 330 +++ .../opencode-plugin-v2/tests/compat.test.ts | 34 + .../tests/credentials.test.ts | 128 + .../tests/enrichment-attribution.test.ts | 59 + .../tests/enrichment-render.test.ts | 64 + .../tests/enrichment-report.test.ts | 106 + .../tests/enrichment.test.ts | 124 + .../tests/fixtures/catalog.json | 68 + .../tests/fixtures/v1-parity.json | 301 +++ .../tests/gemini-language.test.ts | 226 ++ .../tests/host-contract.test.ts | 164 ++ .../opencode-plugin-v2/tests/index.test.ts | 244 ++ .../tests/management-token.test.ts | 233 ++ .../tests/nested-combos.test.ts | 236 ++ .../opencode-plugin-v2/tests/options.test.ts | 129 + .../opencode-plugin-v2/tests/parity.test.ts | 224 ++ .../tests/publish-guard.test.ts | 137 + .../tests/refresh-failopen.test.ts | 214 ++ .../tests/shared-anthropic-prefixes.test.ts | 165 ++ .../tests/shared-auto-combos.test.ts | 196 ++ .../tests/shared-combos-map.test.ts | 77 + .../tests/shared-enrich.test.ts | 47 + .../tests/shared-enrichment-fetcher.test.ts | 125 + .../shared-enrichment-source-errors.test.ts | 56 + .../tests/shared-fetch-timeout.test.ts | 49 + .../tests/shared-fingerprint.test.ts | 62 + .../tests/shared-gemini.test.ts | 144 + .../tests/shared-logger.test.ts | 130 + .../tests/shared-models-map.test.ts | 94 + .../tests/shared-naming.test.ts | 75 + .../tests/shared-usable.test.ts | 248 ++ .../tests/smoke-types.test.ts | 93 + .../tests/snapshot-stale-entries.test.ts | 219 ++ .../tests/staged-refresh.test.ts | 452 ++++ .../tests/timeouts-logger.test.ts | 161 ++ .../tests/usable-only.test.ts | 222 ++ .../tests/warm-snapshot-identity.test.ts | 88 + @omniroute/opencode-plugin-v2/tsconfig.json | 24 + @omniroute/opencode-plugin-v2/tsup.config.ts | 16 + .../features/12870-opencode-plugin-v2.md | 1 + docs/README.md | 1 + docs/guides/CLI-INTEGRATIONS.md | 10 + docs/guides/OPENCODE-V2-PLUGIN.md | 133 + docs/guides/REMOTE-MODE.md | 5 + 73 files changed, 13440 insertions(+), 7 deletions(-) create mode 100644 @omniroute/opencode-plugin-v2/.gitignore create mode 100644 @omniroute/opencode-plugin-v2/LICENSE create mode 100644 @omniroute/opencode-plugin-v2/README.md create mode 100644 @omniroute/opencode-plugin-v2/RELEASE.md create mode 100644 @omniroute/opencode-plugin-v2/package-lock.json create mode 100644 @omniroute/opencode-plugin-v2/package.json create mode 100644 @omniroute/opencode-plugin-v2/src/cache.ts create mode 100644 @omniroute/opencode-plugin-v2/src/catalog.ts create mode 100644 @omniroute/opencode-plugin-v2/src/compat.ts create mode 100644 @omniroute/opencode-plugin-v2/src/credentials.ts create mode 100644 @omniroute/opencode-plugin-v2/src/enrichment-report.ts create mode 100644 @omniroute/opencode-plugin-v2/src/gemini-language.ts create mode 100644 @omniroute/opencode-plugin-v2/src/index.ts create mode 100644 @omniroute/opencode-plugin-v2/src/options.ts create mode 100644 @omniroute/opencode-plugin-v2/src/shared/auto-combos.ts create mode 100644 @omniroute/opencode-plugin-v2/src/shared/combos-map.ts create mode 100644 @omniroute/opencode-plugin-v2/src/shared/enrich.ts create mode 100644 @omniroute/opencode-plugin-v2/src/shared/fingerprint.ts create mode 100644 @omniroute/opencode-plugin-v2/src/shared/gemini.ts create mode 100644 @omniroute/opencode-plugin-v2/src/shared/index.ts create mode 100644 @omniroute/opencode-plugin-v2/src/shared/logger.ts create mode 100644 @omniroute/opencode-plugin-v2/src/shared/models-map.ts create mode 100644 @omniroute/opencode-plugin-v2/src/shared/naming.ts create mode 100644 @omniroute/opencode-plugin-v2/src/shared/usable.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/api-package.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/auto-combos.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/cache-ttl-snapshot.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/catalog.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/compat.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/credentials.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/enrichment-attribution.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/enrichment-render.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/enrichment-report.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/enrichment.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/fixtures/catalog.json create mode 100644 @omniroute/opencode-plugin-v2/tests/fixtures/v1-parity.json create mode 100644 @omniroute/opencode-plugin-v2/tests/gemini-language.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/host-contract.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/index.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/management-token.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/nested-combos.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/options.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/parity.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/publish-guard.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/refresh-failopen.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/shared-anthropic-prefixes.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/shared-auto-combos.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/shared-combos-map.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/shared-enrich.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/shared-enrichment-fetcher.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/shared-enrichment-source-errors.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/shared-fetch-timeout.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/shared-fingerprint.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/shared-gemini.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/shared-logger.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/shared-models-map.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/shared-naming.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/shared-usable.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/smoke-types.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/snapshot-stale-entries.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/staged-refresh.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/timeouts-logger.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/usable-only.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tests/warm-snapshot-identity.test.ts create mode 100644 @omniroute/opencode-plugin-v2/tsconfig.json create mode 100644 @omniroute/opencode-plugin-v2/tsup.config.ts create mode 100644 changelog.d/features/12870-opencode-plugin-v2.md create mode 100644 docs/guides/OPENCODE-V2-PLUGIN.md diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 5ba76f069f..fddcff49cc 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -573,3 +573,92 @@ jobs: fi npm publish --provenance --access public --ignore-scripts echo "✅ Published ${PKG_NAME}@${PKG_VERSION}" + + publish-opencode-plugin-v2: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # npm provenance + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + fetch-depth: 0 + # Full history needed for auto-bump: git diff against previous release tag + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }} + registry-url: https://registry.npmjs.org + + - name: Auto-bump plugin-v2 version if plugin-v2 changed since last release + id: bump + working-directory: "@omniroute/opencode-plugin-v2" + env: + CURRENT_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + + PKG_VERSION=$(node -p "require('./package.json').version") + PKG_NAME=$(node -p "require('./package.json').name") + + # 1) Skip if current version is not yet published (no bump needed) + PUBLISHED="$(npm view "${PKG_NAME}@${PKG_VERSION}" version 2>/dev/null || true)" + if [ "$PUBLISHED" != "$PKG_VERSION" ]; then + echo "✅ ${PKG_NAME}@${PKG_VERSION} is new — no bump needed." + echo "bumped=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # 2) Find the previous release tag (exclude the current one) + PREV_TAG=$(git tag -l 'v*' --sort=-version:refname \ + | grep -v "^${CURRENT_TAG}$" | head -1 || echo "") + if [ -z "$PREV_TAG" ]; then + echo "No previous tag to compare — skipping bump." + echo "bumped=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # 3) Check if plugin-v2 dir actually changed since that tag + if git diff --quiet "$PREV_TAG" -- "@omniroute/opencode-plugin-v2/"; then + echo "⏭️ No plugin-v2 changes since $PREV_TAG — nothing to publish." + echo "bumped=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # 4) Auto-bump patch version + npm version patch --no-git-tag-version --allow-same-version + NEW_VERSION=$(node -p "require('./package.json').version") + echo "bumped=true" >> "$GITHUB_OUTPUT" + echo "📦 Auto-bumped ${PKG_NAME} from ${PKG_VERSION} to ${NEW_VERSION}" + + - name: Install plugin-v2 dependencies + working-directory: "@omniroute/opencode-plugin-v2" + run: npm install --no-audit --no-fund + + - name: Build plugin-v2 + working-directory: "@omniroute/opencode-plugin-v2" + run: npm run clean && npm run build + + - name: Test plugin-v2 + working-directory: "@omniroute/opencode-plugin-v2" + run: npm test + + - name: Publish @omniroute/opencode-plugin-v2 to npm + working-directory: "@omniroute/opencode-plugin-v2" + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -euo pipefail + PKG_VERSION=$(node -p "require('./package.json').version") + PKG_NAME=$(node -p "require('./package.json').name") + # Same hardened skip-check as the main job (no --silent flag). + PUBLISHED="$(npm view "${PKG_NAME}@${PKG_VERSION}" version 2>/dev/null || true)" + if [ "$PUBLISHED" = "$PKG_VERSION" ]; then + echo "⚠️ ${PKG_NAME}@${PKG_VERSION} is already published on npm — skipping." + exit 0 + fi + npm publish --provenance --access public --ignore-scripts + echo "✅ Published ${PKG_NAME}@${PKG_VERSION}" diff --git a/.github/workflows/opencode-plugin-ci.yml b/.github/workflows/opencode-plugin-ci.yml index 0e26c0e608..9b94b688b6 100644 --- a/.github/workflows/opencode-plugin-ci.yml +++ b/.github/workflows/opencode-plugin-ci.yml @@ -5,10 +5,12 @@ on: branches: [main, "release/**"] paths: - "@omniroute/opencode-plugin/**" + - "@omniroute/opencode-plugin-v2/**" pull_request: branches: [main, "release/**"] paths: - "@omniroute/opencode-plugin/**" + - "@omniroute/opencode-plugin-v2/**" types: [opened, synchronize, reopened, ready_for_review] workflow_dispatch: @@ -44,10 +46,33 @@ jobs: - run: npm run build - run: npm test + test-v2: + name: Test v2 (Node ${{ matrix.node }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ["22", "24"] + defaults: + run: + working-directory: "@omniroute/opencode-plugin-v2" + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node }} + cache: npm + cache-dependency-path: "@omniroute/opencode-plugin-v2/package-lock.json" + - run: npm ci --no-audit --no-fund + - run: npm run build + - run: npm test + build: name: Build runs-on: ubuntu-latest - needs: test + needs: [test, test-v2] steps: - uses: actions/checkout@v7 with: @@ -55,12 +80,11 @@ jobs: - uses: actions/setup-node@v7 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 + - name: Build plugin-v2 artifact + working-directory: "@omniroute/opencode-plugin-v2" + run: npm ci --no-audit --no-fund && npm run build - uses: actions/upload-artifact@v7 with: - name: opencode-plugin-dist - path: "@omniroute/opencode-plugin/dist" + name: opencode-plugin-v2-dist + path: "@omniroute/opencode-plugin-v2/dist" retention-days: 7 diff --git a/@omniroute/opencode-plugin-v2/.gitignore b/@omniroute/opencode-plugin-v2/.gitignore new file mode 100644 index 0000000000..7535211682 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +*.log +.DS_Store diff --git a/@omniroute/opencode-plugin-v2/LICENSE b/@omniroute/opencode-plugin-v2/LICENSE new file mode 100644 index 0000000000..e50b22c855 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/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-v2/README.md b/@omniroute/opencode-plugin-v2/README.md new file mode 100644 index 0000000000..873290a9d1 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/README.md @@ -0,0 +1,107 @@ +# @omniroute/opencode-plugin-v2 + +OpenCode v2 plugin (`define({ id, setup })`, Promise API) that publishes the live OmniRoute catalog — models from `/v1/models`, combos from `/api/combos` (least-common-denominator join), auto-combos from `/api/combos/auto`, enrichment (names + pricing), and usable-provider filtering — into the v2 `catalog.transform`, with `key` + `env` auth via `integration.transform`. + +Companion to `@omniroute/opencode-plugin` (OpenCode v1, same repo). The two packages are independent: this one carries its own catalog-mapping logic and the v1 plugin is left untouched. + +## Install + +```sh +npm install @omniroute/opencode-plugin-v2 +``` + +`opencode.json`: + +```json +{ + "plugins": [ + { + "package": "@omniroute/opencode-plugin-v2", + "options": { + "providerId": "omniroute", + "baseURL": "http://localhost:20128" + } + } + ] +} +``` + +## Credentials + +The plugin needs a gateway key to read the catalog, and looks for one in this +order: + +1. **The credential you connected in OpenCode.** The plugin registers an + integration, so `opencode auth` (or the Connect action in the model picker) + can store a key for it. Nothing is written to `opencode.json` — this is the + recommended route. +2. **`apiKey` in the plugin options**, when you want a per-project override. + Remember that this puts the key in a config file you may be committing. +3. **`OMNIROUTE_API_KEY` in the environment.** + +If none of the three yields a key, the catalog is empty and the plugin says so +once at startup rather than leaving you with a silent empty model list. + +### The management token is a different key + +Combos, provider health and enrichment (display names, pricing, free-tier +budgets) come from the gateway's `/api/*` endpoints, which most deployments +gate behind a **management** token rather than the inference key. Set it +explicitly: + +```json +"options": { + "baseURL": "http://localhost:20128", + "managementReadToken": "" +} +``` + +Left unset, `managementReadToken` falls back to `apiKey` for backwards +compatibility. When a gateway rejects that fallback, the catalog still +publishes — but with raw model ids instead of display names, no canonical +alias dedupe, no pricing and no combos. The plugin warns once per endpoint +when this happens, naming the endpoint and the consequence, so the degraded +catalog is never a mystery. + +## Options + +| Key | Default | Notes | +| -------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `providerId` | `"omniroute"` | Provider id and integration id; models publish under `/…` | +| `baseURL` | required | OmniRoute gateway root (no `/v1` suffix needed) | +| `apiKey` | connected credential, then `OMNIROUTE_API_KEY` | Chat key for `/v1/*` — see [Credentials](#credentials) | +| `managementReadToken` | falls back to `apiKey` | Management key for `/api/*` (combos, providers, enrichment) — usually **not** the same key | +| `displayName` | `"OmniRoute"` | Provider display name | +| `timeoutMs` | `10000` | Per-endpoint fetch timeout (auto-combos use 5s) | +| `modelCacheTtlMs` | `300000` | Catalog cache TTL; disk snapshot warms cold starts | +| `timeouts` | per-endpoint override | `{ models, combos, autoCombos, enrichment }` in ms; falls back to `timeoutMs` | +| `enrichment` | `true` | Fetch names + pricing (`/api/pricing*`, `/api/free-tier/summary`) | +| `providerTag` | `true` | Prefix a display name with the upstream provider it routes to | +| `geminiSanitization` | `true` | Strip `$schema`/`additionalProperties` from tool schemas sent to Gemini models (`$ref` tools are forwarded untouched) | +| `usableOnly` | `false` | Filter to healthy provisioned providers (`/api/providers`) | +| `visibleModels` / `hiddenModels` | `[]` | Exact-or-suffix allowlists, deny wins | +| `apiFormat.allowAnthropic` | `false` | Route allowlisted ids to the Anthropic API block | +| `apiFormat.anthropicModels` | `[]` | Full model ids routed to Anthropic | +| `apiFormat.anthropicPrefixes` | v1 defaults | Deprecated, warns once — prefer `anthropicModels` | +| `logLevel` / `startupDebug` | `warn` / `false` | Logger verbosity | + +## Tool calling on Gemini models + +Gemini answers `400 INVALID_ARGUMENT` — for the whole request, not just the +offending tool — when a tool declaration carries `$schema` or +`additionalProperties`. Anything that emits standard JSON Schema therefore +breaks tool calling as soon as the chain routes to Gemini. + +The plugin strips those keywords from tool schemas bound for a Gemini model of +this provider, and leaves every other request untouched. A tool carrying a +`$ref` is forwarded untouched instead of stripped: removing the reference +would widen the schema to "accept anything". Set +`"geminiSanitization": false` to turn it off. + +## Migrating from the v1 plugin + +The v2 plugin publishes provider id `X` bare. The v1 plugin published `opencode-X` (native-adapter gate). Sessions pinned to `opencode-X/...` must re-select the model under `X/...`. + +## License + +MIT diff --git a/@omniroute/opencode-plugin-v2/RELEASE.md b/@omniroute/opencode-plugin-v2/RELEASE.md new file mode 100644 index 0000000000..78d76c6ae3 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/RELEASE.md @@ -0,0 +1,9 @@ +# Release process — `@omniroute/opencode-plugin-v2` + +## Publishing + +One package, no ordering: bump `@omniroute/opencode-plugin-v2` (`npm version patch`) and publish it. The plugin carries its own copy of the mapping logic, so a release never has to be coordinated with another package. + +## Migration note (`opencode-X` → `X`) + +The v1 plugin published provider id `opencode-X` (native-adapter gate). The v2 plugin publishes `X` bare. Sessions pinned to `opencode-X/...` resolve `ModelUnavailableError` — users must re-select the model under `X/...`. diff --git a/@omniroute/opencode-plugin-v2/package-lock.json b/@omniroute/opencode-plugin-v2/package-lock.json new file mode 100644 index 0000000000..d709cd6779 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/package-lock.json @@ -0,0 +1,2364 @@ +{ + "name": "@omniroute/opencode-plugin-v2", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@omniroute/opencode-plugin-v2", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "zod": "^4.4.3" + }, + "devDependencies": { + "@opencode-ai/plugin": "1.18.29", + "@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/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "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", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "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.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.18.29", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.29.tgz", + "integrity": "sha512-IhF83EU4I/ASgWwvm0FIh1O3a8ZVuCLqPrCbmSHdSYq7GHxIYe773i6dHqcbrzCzvlG/lP2H+dyTQ+xPAwFpbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@opencode-ai/sdk": "1.18.29", + "effect": "4.0.0-beta.83", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.4.5", + "@opentui/keymap": ">=0.4.5", + "@opentui/solid": ">=0.4.5" + }, + "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", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.18.29", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.29.tgz", + "integrity": "sha512-4CS+FoLPkymTlcga8jxivGDDb2AbWMIIl3b8+myoe2wtv/1ANYCErslgz1xy5hTVHymWE6CtVNKzRuPU0ED57A==", + "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, + "libc": [ + "glibc" + ], + "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, + "libc": [ + "musl" + ], + "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, + "libc": [ + "glibc" + ], + "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, + "libc": [ + "musl" + ], + "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, + "libc": [ + "glibc" + ], + "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, + "libc": [ + "musl" + ], + "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, + "libc": [ + "glibc" + ], + "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, + "libc": [ + "musl" + ], + "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, + "libc": [ + "glibc" + ], + "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, + "libc": [ + "musl" + ], + "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, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "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", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "dev": true, + "license": "MIT" + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "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", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "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", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "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", + "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.83", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.83.tgz", + "integrity": "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.8.0", + "find-my-way-ts": "^0.1.6", + "ini": "^7.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^2.0.1", + "multipasta": "^0.2.7", + "toml": "^4.1.1", + "uuid": "^14.0.0", + "yaml": "^2.9.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fast-check": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", + "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", + "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", + "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": "7.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", + "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.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", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true, + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "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", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "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", + "dev": true, + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz", + "integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "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.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/multipasta": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", + "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "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", + "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", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "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", + "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.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "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", + "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", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "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", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "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", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "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.3.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz", + "integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "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/tsup/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/tsup/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/tsup/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/tsup/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/tsup/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/tsup/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/tsup/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/tsup/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/tsup/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/tsup/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/tsup/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/tsup/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/tsup/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/tsup/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/tsup/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/tsup/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/tsup/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/tsup/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/tsup/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/tsup/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/tsup/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/tsup/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/tsup/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/tsup/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/tsup/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/tsup/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/tsup/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/tsx": { + "version": "4.22.3", + "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/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", + "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", + "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", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/@omniroute/opencode-plugin-v2/package.json b/@omniroute/opencode-plugin-v2/package.json new file mode 100644 index 0000000000..da8bc134d6 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/package.json @@ -0,0 +1,67 @@ +{ + "name": "@omniroute/opencode-plugin-v2", + "version": "0.1.0", + "description": "OmniRoute OpenCode plugin (v2 Promise API): catalog transform with models, combos, enrichment, and naming.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsup", + "clean": "rm -rf dist", + "test": "node --import tsx/esm --test tests/*.test.ts", + "prepublishOnly": "npm run clean && npm run build && npm test" + }, + "dependencies": { + "zod": "^4.4.3" + }, + "devDependencies": { + "@opencode-ai/plugin": "1.18.29", + "@types/node": "^22.19.19", + "tsup": "^8.5.1", + "tsx": "^4.22.3", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.22.3" + }, + "license": "MIT", + "author": "OmniRoute contributors", + "repository": { + "type": "git", + "url": "https://github.com/diegosouzapw/OmniRoute.git", + "directory": "@omniroute/opencode-plugin-v2" + }, + "homepage": "https://github.com/diegosouzapw/OmniRoute/tree/main/%40omniroute/opencode-plugin-v2#readme", + "bugs": { + "url": "https://github.com/diegosouzapw/OmniRoute/issues" + }, + "keywords": [ + "omniroute", + "opencode", + "opencode-plugin", + "opencode-v2", + "ai-sdk", + "openai-compatible", + "provider", + "catalog", + "combos", + "gemini" + ], + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "@opencode-ai/plugin": ">=1.18.29 <2" + } +} diff --git a/@omniroute/opencode-plugin-v2/src/cache.ts b/@omniroute/opencode-plugin-v2/src/cache.ts new file mode 100644 index 0000000000..58aca429e4 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/cache.ts @@ -0,0 +1,211 @@ +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; +import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import type { + OmniRouteEnrichmentEntry, + OmniRouteEnrichmentMap, + OmniRouteProviderConnection, + OmniRouteRawAutoCombo, + OmniRouteRawCombo, + OmniRouteRawModelEntry, +} from "./shared/index.js"; + +export const DEFAULT_MODEL_CACHE_TTL_MS = 300_000 as const; + +/** + * Breather after a refresh whose models fetch came back empty (gateway down + * or refusing). Transforms inside the window serve last-known-good without + * re-firing the fetch suite. Short on purpose: it only guards the + * pathological case, normal TTL expiry still refetches every window. + */ +export const UNREACHABLE_COOLDOWN_MS = 15_000 as const; + +export interface CatalogSnapshot { + models: OmniRouteRawModelEntry[]; + combos: OmniRouteRawCombo[]; + autoCombos: OmniRouteRawAutoCombo[]; + providers?: OmniRouteProviderConnection[]; + enrichment?: OmniRouteEnrichmentMap; + fetchedAt: number; +} + +export const SNAPSHOT_FORMAT_VERSION = 2 as const; + +/** + * A raw snapshot entry is stale when it cannot be mapped to a publishable + * model: no string `id` (unroutable) or a pre-mapped `api` block without a + * valid `npm` package (the runner would reject it as `Unsupported package`). + * Plain `/v1/models` entries carry no `api` block -- it is synthesized at + * publish time -- so only a present-but-invalid block drops the entry. + */ +export function isStaleSnapshotModel(entry: unknown): boolean { + if (!entry || typeof entry !== "object") return true; + const id = (entry as { id?: unknown }).id; + if (typeof id !== "string" || id.length === 0) return true; + const api = (entry as { api?: unknown }).api; + if (api === undefined) return false; + if (!api || typeof api !== "object") return true; + const npm = (api as { npm?: unknown }).npm; + return typeof npm !== "string" || npm.length === 0; +} + +interface DiskSnapshotV2 { + v: 2; + identityFingerprint: string; + models: OmniRouteRawModelEntry[]; + combos: OmniRouteRawCombo[]; + autoCombos?: OmniRouteRawAutoCombo[]; + providers?: OmniRouteProviderConnection[]; + /** + * Display names, provider labels, pricing and free-tier budgets, as + * `[key, entry]` pairs (a Map does not survive JSON). Persisted because a + * cold start otherwise publishes raw model ids until the first refresh + * completes — which is the moment the snapshot exists to cover. + */ + enrichment?: [string, OmniRouteEnrichmentEntry][]; + writtenAt: number; +} + +/** + * Ceiling on what one snapshot may occupy on disk. A gateway with thousands of + * models makes this file grow without bound otherwise; past the cap the + * enrichment overlay is dropped first (it is rebuilt on the next refresh) + * rather than losing the catalog itself. + */ +const MAX_SNAPSHOT_BYTES = 32 * 1024 * 1024; + +function trimTrailingSlashes(value: string): string { + let i = value.length; + while (i > 0 && value.charCodeAt(i - 1) === 0x2f) i -= 1; + return i === value.length ? value : value.slice(0, i); +} + +function normalizeBaseURL(baseURL: string): string { + try { + const parsed = new URL(baseURL); + parsed.hash = ""; + parsed.pathname = trimTrailingSlashes(parsed.pathname) || "/"; + return parsed.toString(); + } catch { + return trimTrailingSlashes(baseURL); + } +} + +export function memoryCacheKey(baseURL: string, credentialId: string): string { + return `${baseURL}::${createHash("sha256").update(credentialId).digest("hex")}`; +} + +export function snapshotIdentityFingerprint( + baseURL: string, + apiKey: string, + managementReadToken: string +): string { + return createHash("sha256") + .update(JSON.stringify([normalizeBaseURL(baseURL), apiKey, managementReadToken])) + .digest("hex"); +} + +export function diskSnapshotPath(providerId: string): string { + // OPENCODE_DATA_DIR is honoured verbatim when set: whoever controls the + // process environment already chooses where the process writes, so + // resolving it further would only surprise. The providerId segment stays + // bounded by the options schema (letters, digits, '.', '_' and '-'; never + // "." or ".."), keeping the file inside /plugins/. + const dir = process.env.OPENCODE_DATA_DIR ?? join(homedir(), ".local", "share", "opencode"); + return join(dir, "plugins", `omniroute-${providerId}.json`); +} + +export async function readDiskSnapshot( + providerId: string, + identityFingerprint: string, + logger?: { warn: (message: string) => void } +): Promise { + try { + const body = await readFile(diskSnapshotPath(providerId), "utf8"); + const parsed = JSON.parse(body) as Partial; + if ( + !parsed || + typeof parsed.v !== "number" || + parsed.v < SNAPSHOT_FORMAT_VERSION || + typeof parsed.identityFingerprint !== "string" || + parsed.identityFingerprint !== identityFingerprint + ) { + return undefined; + } + if ( + !Array.isArray(parsed.models) || + parsed.models.length === 0 || + !Array.isArray(parsed.combos) + ) { + return undefined; + } + const stale = (parsed.models as unknown[]).filter(isStaleSnapshotModel).length; + const models = (parsed.models as OmniRouteRawModelEntry[]).filter( + (entry) => !isStaleSnapshotModel(entry) + ); + if (stale > 0) { + logger?.warn(`[omniroute-v2] dropping ${stale} stale snapshot entries without api block`); + } + if (models.length === 0) return undefined; + return { + models, + combos: parsed.combos as OmniRouteRawCombo[], + autoCombos: Array.isArray(parsed.autoCombos) + ? (parsed.autoCombos as OmniRouteRawAutoCombo[]) + : [], + providers: Array.isArray(parsed.providers) + ? (parsed.providers as OmniRouteProviderConnection[]) + : [], + // A snapshot written before this field existed, or one whose overlay was + // dropped for size, simply starts unenriched and recovers on the first + // refresh — the same state as before it was persisted at all. + enrichment: Array.isArray(parsed.enrichment) + ? new Map(parsed.enrichment as [string, OmniRouteEnrichmentEntry][]) + : undefined, + fetchedAt: typeof parsed.writtenAt === "number" ? parsed.writtenAt : Date.now(), + }; + } catch { + return undefined; + } +} + +export async function writeDiskSnapshot( + providerId: string, + snapshot: CatalogSnapshot, + identityFingerprint: string +): Promise { + try { + if (snapshot.models.length === 0) return; + const file = diskSnapshotPath(providerId); + await mkdir(dirname(file), { recursive: true, mode: 0o700 }); + const envelope: DiskSnapshotV2 = { + v: 2, + identityFingerprint, + models: snapshot.models, + combos: snapshot.combos, + autoCombos: snapshot.autoCombos, + providers: snapshot.providers ?? [], + enrichment: snapshot.enrichment ? [...snapshot.enrichment.entries()] : undefined, + writtenAt: Date.now(), + }; + let payload = JSON.stringify(envelope); + if (payload.length > MAX_SNAPSHOT_BYTES && envelope.enrichment !== undefined) { + delete envelope.enrichment; + payload = JSON.stringify(envelope); + } + if (payload.length > MAX_SNAPSHOT_BYTES) return; + await writeFile(file, payload, { encoding: "utf8", mode: 0o600 }); + } catch { + // Best-effort: callers already hold the in-memory entry. + } +} + +export async function clearDiskSnapshot(providerId: string): Promise { + try { + await unlink(diskSnapshotPath(providerId)); + return true; + } catch { + return false; + } +} diff --git a/@omniroute/opencode-plugin-v2/src/catalog.ts b/@omniroute/opencode-plugin-v2/src/catalog.ts new file mode 100644 index 0000000000..73c3f4ab70 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/catalog.ts @@ -0,0 +1,798 @@ +import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise"; +import { type HostContract, detectHostContract, emitsLegacyFields } from "./compat.js"; +import type { Model as LegacyModelV2 } from "@opencode-ai/sdk/v2"; +import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types"; +import { + type ApiFormatV2, + type LogLevel, + type Logger, + type OmniRouteAutoCombosFetcher, + type OmniRouteCombosFetcher, + type OmniRouteEnrichmentFetcher, + type OmniRouteEnrichmentMap, + type OmniRouteModelsFetcher, + type OmniRouteProviderConnection, + type OmniRouteProvidersFetcher, + type OmniRouteRawAutoCombo, + type OmniRouteRawCombo, + type OmniRouteRawModelEntry, + applyEnrichment, + buildCanonicalToAliasMap, + canonicalDedupSet, + createLogger, + defaultOmniRouteEnrichmentFetcher, + defaultOmniRouteProvidersFetcher, + ensureV1Suffix, + isUsableCombo, + isUsableRawModelId, + lookupEnrichment, + mapAutoComboToModelV2, + mapComboToModelV2, + mapRawModelToModelV2, + usableProviderAliasSet, +} from "./shared/index.js"; + +export type ModelsFetcher = OmniRouteModelsFetcher; +export type CombosFetcher = OmniRouteCombosFetcher; +export type AutoCombosFetcher = OmniRouteAutoCombosFetcher; +export type ProvidersFetcher = OmniRouteProvidersFetcher; +export type EnrichmentFetcher = OmniRouteEnrichmentFetcher; + +export interface EndpointTimeouts { + models?: number; + combos?: number; + autoCombos?: number; + enrichment?: number; +} + +export interface ResolvedOptions { + providerId: string; + baseURL: string; + apiKey: string; + managementReadToken?: string; + timeoutMs: number; + timeouts?: EndpointTimeouts; + logger?: Logger; + logLevel?: LogLevel; + startupDebug?: boolean; + modelCacheTtlMs: number; + /** v1 parity: prefix the display name with the upstream provider label. */ + providerTag?: boolean; + displayName?: string; + apiFormat?: ApiFormatV2; + visibleModels?: string[]; + hiddenModels?: string[]; + usableOnly: boolean; + enrichment?: OmniRouteEnrichmentMap | boolean; + /** + * Shared collision-warning dedupe set keyed `cacheKey::comboKey`. When + * omitted a fresh per-publish set is used. index.ts passes one setup-wide + * set so a repeated publish (stale replay + refresh) warns once per key. + */ + collisionWarned?: Set; +} + +export interface CatalogFetchers { + fetcher?: ModelsFetcher; + combosFetcher?: CombosFetcher; + autoCombosFetcher?: AutoCombosFetcher; + providersFetcher?: ProvidersFetcher; + enrichmentFetcher?: EnrichmentFetcher; + models?: ModelsFetcher; + combos?: CombosFetcher; + autoCombos?: AutoCombosFetcher; + providers?: ProvidersFetcher; + enrichment?: EnrichmentFetcher; + /** + * Called when a gateway source cannot be read. Without it this function + * degrades silently — the catalog publishes with raw ids and no combos and + * nothing says why, which is the failure the plugin path reports. + */ + onSourceError?: (endpoint: string, reason: string) => void; +} + +// The shared mappers speak the legacy (`Provider.models[id]`) `Model` shape +// (imported from `@opencode-ai/sdk/v2`, also re-exported by the plugin root +// as `ModelV2`); the real v2 `CatalogDraft` carries `ModelV2Info` instead. +// Convert the fields 1:1 at the draft boundary -- NEVER `as unknown as` the +// whole model. +// +// Binary-compat note: the prod binary (beta-17823) reads a top-level +// `package` field on both Model and Provider structs (`package:a.Package`, +// gated by `isAISDK = startsWith("aisdk:")`), with a model-to-provider +// fallback (`package: u.package ?? s.package`). The pinned SDK types +// (1.18.29) only know the `api` block, so the binary field is published via +// the typed extensions below (spread/Object.assign, never `any`). +export const BINARY_AISDK_PREFIX = "aisdk:"; + +/** Top-level `package` as the legacy contract expects it (`aisdk:`). */ +export interface BinaryCompatPackage { + package: string; +} + +/** + * The legacy contract keeps on the model/provider itself what the `api` block + * carries in the pinned types: the aisdk package, the endpoint (as + * `settings.baseURL`) and the per-request headers. None of these keys collide + * with a key of `ModelV2Info`/`ProviderV2Info`, so both field sets can be + * published on the same object. + */ +export interface BinaryCompatFields extends BinaryCompatPackage { + settings: Record; + headers: Record; +} + +/** Legacy variants read their options from `settings`, not `headers`/`body`. */ +export type BinaryCompatVariant = ModelV2Info["variants"][number] & { + settings: Record; +}; + +export type BinaryCompatModel = ModelV2Info & BinaryCompatFields; +export type BinaryCompatProvider = ProviderV2Info & + BinaryCompatPackage & { + settings: Record; + }; + +export function toBinaryPackage(npm: string): string { + return npm.startsWith(BINARY_AISDK_PREFIX) ? npm : `${BINARY_AISDK_PREFIX}${npm}`; +} +export function legacyApiToInfoApi(api: LegacyModelV2["api"]): ModelV2Info["api"] { + if (!api || typeof api.npm !== "string" || api.npm.length === 0) { + throw new Error( + "[omniroute-v2] refusing to publish a model without an api block (missing api.npm)" + ); + } + return { id: api.id, type: "aisdk", package: api.npm, url: api.url }; +} + +function legacyCostToInfoCost(cost: LegacyModelV2["cost"]): ModelV2Info["cost"] { + return [{ input: cost.input, output: cost.output, cache: cost.cache }]; +} + +function legacyCapabilitiesToInfoCapabilities( + caps: LegacyModelV2["capabilities"] +): ModelV2Info["capabilities"] { + const input: string[] = []; + if (caps.input.text) input.push("text"); + if (caps.input.audio) input.push("audio"); + if (caps.input.image) input.push("image"); + if (caps.input.video) input.push("video"); + if (caps.input.pdf) input.push("pdf"); + const output: string[] = []; + if (caps.output.text) output.push("text"); + if (caps.output.audio) output.push("audio"); + if (caps.output.image) output.push("image"); + if (caps.output.video) output.push("video"); + if (caps.output.pdf) output.push("pdf"); + return { tools: caps.toolcall, input, output }; +} + +function legacyToInfo(providerID: string, modelID: string, m: LegacyModelV2): ModelV2Info { + const variants = Object.entries(m.variants ?? {}).map(([id, body]) => ({ + id, + headers: {}, + body: body as Record, + })); + const parsed = Date.parse(m.release_date); + return { + id: modelID, + providerID, + ...(m.family !== undefined ? { family: m.family } : {}), + name: m.name, + api: legacyApiToInfoApi(m.api), + capabilities: legacyCapabilitiesToInfoCapabilities(m.capabilities), + request: { headers: { ...m.headers }, body: { ...m.options } }, + variants, + time: { released: Number.isNaN(parsed) ? 0 : parsed }, + cost: legacyCostToInfoCost(m.cost), + status: m.status, + enabled: true, + limit: { ...m.limit }, + }; +} + +export interface PublishCounts { + models: number; + combos: number; + autoCombos: number; +} + +export interface ModelListFilter { + exact: Set; + suffixes: Set; +} + +export function compileModelListFilter(list?: string[]): ModelListFilter | undefined { + if (!list || list.length === 0) return undefined; + const exact = new Set(); + const suffixes = new Set(); + for (const id of list) { + if (id.includes("/")) { + exact.add(id); + } else { + suffixes.add(id); + } + } + if (exact.size === 0 && suffixes.size === 0) return undefined; + return { exact, suffixes }; +} + +function matchesSuffix(id: string, suffixes: Set): boolean { + if (suffixes.size === 0) return false; + const slash = id.indexOf("/"); + const suffix = slash > 0 ? id.slice(slash + 1) : id; + return suffixes.has(suffix); +} + +export function passesModelAllowlist( + id: string, + visible?: ModelListFilter, + hidden?: ModelListFilter +): boolean { + if (hidden) { + if (hidden.exact.has(id) || matchesSuffix(id, hidden.suffixes)) return false; + } + if (visible) { + if (!visible.exact.has(id) && !matchesSuffix(id, visible.suffixes)) return false; + } + return true; +} + +export function passesComboAllowlist(combo: OmniRouteRawCombo, visible?: ModelListFilter): boolean { + if (!visible) return true; + const steps = Array.isArray(combo.models) ? combo.models : []; + if (steps.length === 0) return true; + let sawResolvableMember = false; + for (const step of steps) { + if (step?.kind === "combo-ref") continue; + const modelId = typeof step?.model === "string" ? step.model : ""; + if (modelId.length === 0) continue; + sawResolvableMember = true; + if (visible.exact.has(modelId) || matchesSuffix(modelId, visible.suffixes)) return true; + } + if (!sawResolvableMember) return true; + return false; +} + +/** + * Project the `api` block onto the legacy top-level fields. Only the `aisdk` + * variant of `ModelApi`/`ProviderApi` carries a package, so the caller narrows + * before calling; a `native` api has no legacy equivalent and publishes + * nothing (the legacy contract has no native models). + */ +function legacyModelFields(info: ModelV2Info): BinaryCompatFields | undefined { + if (info.api.type !== "aisdk") return undefined; + const settings: Record = { + ...(info.api.settings ?? {}), + ...info.request.body, + }; + if (info.api.url !== undefined) settings.baseURL = info.api.url; + return { + package: toBinaryPackage(info.api.package), + settings, + headers: { ...info.request.headers }, + }; +} + +/** `{id, headers, body}` (pinned types) plus `{settings}` (legacy contract). */ +function legacyVariants(variants: ModelV2Info["variants"]): BinaryCompatVariant[] { + return variants.map((variant) => ({ ...variant, settings: { ...variant.body } })); +} + +function assignModelFields( + target: ModelV2Info, + source: LegacyModelV2, + contract: HostContract +): void { + const info = legacyToInfo(target.providerID || source.providerID, target.id || source.id, source); + target.name = info.name; + target.api = info.api; + target.capabilities = info.capabilities; + target.request = info.request; + target.variants = info.variants; + target.time = info.time; + target.cost = info.cost; + target.status = info.status; + target.enabled = info.enabled; + target.limit = info.limit; + if (info.family !== undefined) { + target.family = info.family; + } + if (!emitsLegacyFields(contract)) return; + const legacy = legacyModelFields(info); + if (legacy !== undefined) { + Object.assign(target, legacy); + target.variants = legacyVariants(info.variants); + } +} + +function assignProviderFields( + target: ProviderV2Info, + source: { name: string; api: ProviderV2Info["api"]; integrationID: string }, + contract: HostContract +): void { + target.name = source.name; + target.api = source.api; + target.integrationID = source.integrationID; + if (!emitsLegacyFields(contract)) return; + // The legacy contract defaults `Provider.Info.package` to `""` and model + // resolution falls back to it (`package: model.package ?? provider.package`), + // so the provider carries the same `aisdk:` value as its models, and + // the endpoint as `settings.baseURL`. + if (source.api.type !== "aisdk") return; + const settings: Record = { ...(source.api.settings ?? {}) }; + if (source.api.url !== undefined) settings.baseURL = source.api.url; + Object.assign(target, { package: toBinaryPackage(source.api.package), settings }); +} + +/** A widened capability flag (`boolean | { field }`) read back as a plain flag. */ +function isCapabilityEnabled(value: boolean | { field: string }): boolean { + return value !== false; +} + +/** + * Combo steps reach us from the gateway with a shape the SDK types do not + * describe (`kind`, `comboName`, `model` appear per step kind). One reader + * keeps that single untyped boundary in one place instead of scattering casts. + */ +function readStepField(step: unknown, key: "kind" | "comboName" | "model"): unknown { + return (step as Record | null | undefined)?.[key]; +} + +/** + * Resolve the display-name + pricing overlay. A caller may hand over a + * ready-made map (tests, pre-resolved overlays) or turn the fetch off; a + * failed fetch soft-fails to an empty map so the catalog still publishes, + * with mapper-default names and zeroed pricing rather than nothing at all. + */ +async function resolveEnrichmentOverlay( + opts: ResolvedOptions, + fetchers: CatalogFetchers | undefined, + log: Logger +): Promise { + if (opts.enrichment instanceof Map) return opts.enrichment; + if (opts.enrichment === false) return new Map(); + const fetchEnrichment = + fetchers?.enrichmentFetcher ?? fetchers?.enrichment ?? defaultOmniRouteEnrichmentFetcher; + try { + return await fetchEnrichment( + opts.baseURL, + opts.managementReadToken ?? opts.apiKey, + opts.timeouts?.enrichment ?? opts.timeoutMs, + fetchers?.onSourceError + ); + } catch (err) { + log.warn( + `[omniroute-v2] enrichment fetch failed, continuing without names/pricing: ${err instanceof Error ? err.message : String(err)}` + ); + return new Map(); + } +} + +/** + * Resolve the provider aliases worth publishing when `usableOnly` is on. + * Gated on the flag, so the default configuration issues no request at all. + * The filter subtracts: a failed or empty connections fetch yields + * `undefined` and keeps the whole catalog, because only a prefix proven not + * provisioned may be dropped. + */ +async function resolveUsableAliases( + opts: ResolvedOptions, + providersFetcher: OmniRouteProvidersFetcher | undefined, + onSourceError: ((endpoint: string, reason: string) => void) | undefined, + enrichment: OmniRouteEnrichmentMap, + timeoutMs: number, + log: Logger +): Promise | undefined> { + if (!opts.usableOnly) return undefined; + let rawConnections: OmniRouteProviderConnection[]; + try { + const fetchProviders = providersFetcher ?? defaultOmniRouteProvidersFetcher; + rawConnections = await fetchProviders( + opts.baseURL, + opts.managementReadToken ?? opts.apiKey, + timeoutMs, + onSourceError + ); + } catch (err) { + log.warn( + `[omniroute-v2] providers fetch failed, usableOnly filter disabled for this refresh: ${err instanceof Error ? err.message : String(err)}` + ); + rawConnections = []; + } + return rawConnections.length > 0 ? usableProviderAliasSet(rawConnections, enrichment) : undefined; +} + +/** Everything the combo publishing pass reads, passed as one value. */ +interface PublishContext { + draft: CatalogDraft; + opts: ResolvedOptions; + log: Logger; + providerId: string; + hostContract: HostContract; + enrichment: OmniRouteEnrichmentMap; + rawModelById: Map; + publishedKeys: Set; + publishedModelIds: Map; + visibleFilter: ReturnType; + hiddenFilter: ReturnType; + usable: ReturnType | undefined; + canonicalToAlias: ReturnType; + combosFetcher: CatalogFetchers["combos"] | undefined; + combosTimeout: number; + /** Shared with the auto-combos pass: one collision warning per key, per run. */ + warnedCombos: Set; + cacheKey: string; +} + +/** + * Fetch the gateway's combos and publish them, resolving nested combo-refs to + * a fixpoint first: a combo whose members are themselves combos only knows its + * lowest common denominator once those are known. Combos that never resolve + * are dropped rather than published with a fabricated capability set, and + * reported once. + * + * Returns the number published, or `undefined` when the combos fetch failed — + * the caller then publishes a models-only catalog instead of an empty one. + */ +async function publishCombos(ctx: PublishContext): Promise { + const { + draft, + opts, + log, + providerId: X, + hostContract, + enrichment, + rawModelById, + publishedKeys, + publishedModelIds, + visibleFilter, + hiddenFilter, + usable, + canonicalToAlias, + combosFetcher, + combosTimeout, + warnedCombos, + cacheKey, + } = ctx; + let rawCombos: OmniRouteRawCombo[]; + try { + rawCombos = combosFetcher + ? await combosFetcher(opts.baseURL, opts.managementReadToken ?? opts.apiKey, combosTimeout) + : []; + } catch (err) { + log.warn( + `[omniroute-v2] combos fetch failed, falling back to models-only catalog: ${err instanceof Error ? err.message : String(err)}` + ); + return undefined; + } + + let comboCount = 0; + // Ported from v1 (fixpoint 8 passes + warn once per (cacheKey, comboKey) + // + intentional-dedup exception). Nested combo-refs resolve against the + // friendly combo name; unresolvable combos are dropped (never published + // with a fabricated empty LCD) and reported once. + const MAX_COMBO_PASSES = 8; + const pending = rawCombos.filter((combo) => { + if (!combo || !combo.id) return false; + if (combo.isHidden === true) return false; + if (usable && !isUsableCombo(combo, usable)) return false; + if (visibleFilter && !passesComboAllowlist(combo, visibleFilter)) return false; + // Deny wins for combos too: a user who hides an id expects it gone from + // the picker whether it is a model or a combo built on it. + if (hiddenFilter && passesComboAllowlist(combo, hiddenFilter)) return false; + return true; + }); + const resolvedByName = new Map(); + let unresolved: typeof pending = []; + + for (let pass = 0; pass < MAX_COMBO_PASSES && pending.length > 0; pass++) { + const stillPending: typeof pending = []; + for (const combo of pending) { + const memberSteps = Array.isArray(combo.models) ? combo.models : []; + const memberEntries: OmniRouteRawModelEntry[] = []; + let deferred = false; + for (const step of memberSteps) { + const kind = readStepField(step, "kind"); + if (kind === "combo-ref") { + const comboName = readStepField(step, "comboName"); + if (typeof comboName !== "string" || comboName.length === 0) continue; + const nested = resolvedByName.get(comboName); + if (!nested) { + deferred = true; + break; + } + memberEntries.push(synthesizeNestedMember(comboName, nested)); + continue; + } + const modelId = readStepField(step, "model"); + if (typeof modelId !== "string" || modelId.length === 0) continue; + const member = rawModelById.get(modelId); + if (member) memberEntries.push(member); + } + if (deferred) { + stillPending.push(combo); + continue; + } + const mapped = mapComboToModelV2(combo, memberEntries, X, opts.baseURL, opts.apiFormat); + applyEnrichment(mapped, lookupEnrichment(combo.id, enrichment, canonicalToAlias), { + isCombo: true, + }); + const mid = mapped.id.startsWith(X + "/") ? mapped.id.slice(X.length + 1) : mapped.id; + const key = X + "/" + mid; + if (publishedKeys.has(key)) { + // Intentional dedup (v1 parity): `/v1/models` pre-mirrors combos as + // raw entries, so the combo's friendly NAME matches the overwritten + // entry's model id (bare or provider-prefixed, endsWith to cover + // both). Only warn on a genuine accidental collision (name differs + // from the entry it overwrites). + const existingId = publishedModelIds.get(key) ?? ""; + const friendly = + typeof combo.name === "string" && combo.name.trim().length > 0 + ? combo.name.trim() + : combo.id; + const isIntentionalDedup = + existingId === friendly || + existingId === X + "/" + friendly || + existingId.endsWith("/" + friendly); + if (!isIntentionalDedup) { + const dedupeKey = `${cacheKey}::${key}`; + if (!warnedCombos.has(dedupeKey)) { + warnedCombos.add(dedupeKey); + log.warn(`[omniroute-v2] combo key "${key}" collides with a model id; combo wins.`); + } + } + } + draft.model.update(X, mid, (m) => { + assignModelFields(m, mapped, hostContract); + }); + publishedKeys.add(key); + publishedModelIds.set(key, mapped.id); + comboCount += 1; + const lookupName = + typeof combo.name === "string" && combo.name.trim().length > 0 + ? combo.name.trim() + : combo.id; + if (!resolvedByName.has(lookupName)) resolvedByName.set(lookupName, mapped); + } + if (stillPending.length === pending.length) { + unresolved = stillPending; + break; + } + unresolved = stillPending; + pending.length = 0; + pending.push(...stillPending); + } + + if (unresolved.length > 0) { + log.warn( + `[omniroute-v2] ${unresolved.length} combo(s) could not resolve all nested combo-refs after ${MAX_COMBO_PASSES} passes; dropped to avoid over-claiming.` + ); + } + return comboCount; +} + +/** + * Synthesize a raw-model entry from an already-resolved nested combo so a + * parent combo's LCD folds the whole nested capability vector (context, + * output, modalities, capabilities) instead of only direct raw members. + * v1 parity (combo member synthesis at nested resolution time). + */ +function synthesizeNestedMember(name: string, nested: LegacyModelV2): OmniRouteRawModelEntry { + const inputModalities: string[] = []; + if (nested.capabilities.input.text) inputModalities.push("text"); + if (nested.capabilities.input.audio) inputModalities.push("audio"); + if (nested.capabilities.input.image) inputModalities.push("image"); + if (nested.capabilities.input.video) inputModalities.push("video"); + if (nested.capabilities.input.pdf) inputModalities.push("pdf"); + const outputModalities: string[] = []; + if (nested.capabilities.output.text) outputModalities.push("text"); + if (nested.capabilities.output.audio) outputModalities.push("audio"); + if (nested.capabilities.output.image) outputModalities.push("image"); + if (nested.capabilities.output.video) outputModalities.push("video"); + if (nested.capabilities.output.pdf) outputModalities.push("pdf"); + return { + id: `combo-ref:${name}`, + context_length: nested.limit.context, + max_output_tokens: nested.limit.output, + ...(nested.limit.input !== undefined ? { max_input_tokens: nested.limit.input } : {}), + owned_by: "combo", + input_modalities: inputModalities, + output_modalities: outputModalities, + capabilities: { + temperature: nested.capabilities.temperature, + // A raw entry carries plain flags; the mapped model widens them to + // `boolean | { field }` (custom reasoning/thinking field). Every + // non-false form means the capability is present, which is all the + // LCD fold reads. + reasoning: isCapabilityEnabled(nested.capabilities.reasoning), + thinking: isCapabilityEnabled(nested.capabilities.interleaved), + attachment: nested.capabilities.attachment, + tool_calling: nested.capabilities.toolcall, + }, + }; +} + +export async function publishCatalog( + draft: CatalogDraft, + opts: ResolvedOptions, + fetchers?: CatalogFetchers +): Promise { + const X = opts.providerId; + const log = opts.logger ?? createLogger(opts.startupDebug ? "debug" : (opts.logLevel ?? "warn")); + const modelsTimeout = opts.timeouts?.models ?? opts.timeoutMs; + const combosTimeout = opts.timeouts?.combos ?? opts.timeoutMs; + // v1 parity keeps the 5s auto-combos budget when no per-endpoint value is + // set (P2 resolves it in index.ts; direct publishCatalog callers may only + // pass timeoutMs). + const autoCombosTimeout = opts.timeouts?.autoCombos ?? 5_000; + // The contract is discovered from the object the host seeds into the + // provider draft, which the host fills before any model is published. The + // verdict is then reused for every model: the model seed carries no + // discriminating key, and a single provider/model pair always speaks one + // contract. + let hostContract: HostContract = "unknown"; + draft.provider.update(X, (p) => { + hostContract = detectHostContract(p); + assignProviderFields( + p, + { + name: opts.displayName ?? "OmniRoute", + api: { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: ensureV1Suffix(opts.baseURL), + }, + integrationID: X, + }, + hostContract + ); + }); + log.debug(`[omniroute-v2] host catalog contract detected: ${hostContract}`); + + const modelsFetcher = fetchers?.fetcher ?? fetchers?.models; + const combosFetcher = fetchers?.combosFetcher ?? fetchers?.combos; + const autoCombosFetcher = fetchers?.autoCombosFetcher ?? fetchers?.autoCombos; + const providersFetcher = fetchers?.providersFetcher ?? fetchers?.providers; + + let rawModels: OmniRouteRawModelEntry[]; + try { + rawModels = modelsFetcher ? await modelsFetcher(opts.baseURL, opts.apiKey, modelsTimeout) : []; + } catch (err) { + log.warn( + `[omniroute-v2] models fetch failed, publishing empty catalog: ${err instanceof Error ? err.message : String(err)}` + ); + return { models: 0, combos: 0, autoCombos: 0 }; + } + + const visibleFilter = compileModelListFilter(opts.visibleModels); + const hiddenFilter = compileModelListFilter(opts.hiddenModels); + + const enrichment = await resolveEnrichmentOverlay(opts, fetchers, log); + const canonicalToAlias = buildCanonicalToAliasMap(enrichment); + const canonicalDedup = canonicalDedupSet(rawModels, canonicalToAlias); + + const usable = await resolveUsableAliases( + opts, + providersFetcher, + fetchers?.onSourceError, + enrichment, + modelsTimeout, + log + ); + + const rawModelById = new Map(); + for (const entry of rawModels) { + if (entry.id) rawModelById.set(entry.id, entry); + } + + const publishedKeys = new Set(); + // Mapped model id per published key (models and combos alike). Mirrors + // v1's `models[comboKey]` lookup so the intentional-dedup check sees the + // overwritten entry's id, not just key presence. + const publishedModelIds = new Map(); + let modelCount = 0; + for (const entry of rawModels) { + if (!entry.id) continue; + if (canonicalDedup.has(entry.id)) continue; + if (usable && !isUsableRawModelId(entry.id, usable)) continue; + if (!passesModelAllowlist(entry.id, visibleFilter, hiddenFilter)) continue; + const mapped = mapRawModelToModelV2(entry, { + providerId: X, + baseURL: opts.baseURL, + apiFormat: opts.apiFormat, + }); + applyEnrichment(mapped, lookupEnrichment(entry.id, enrichment, canonicalToAlias), { + providerTag: opts.providerTag !== false, + }); + const mid = mapped.id.startsWith(X + "/") ? mapped.id.slice(X.length + 1) : mapped.id; + draft.model.update(X, mid, (m) => { + assignModelFields(m, mapped, hostContract); + }); + publishedKeys.add(X + "/" + mid); + publishedModelIds.set(X + "/" + mid, mapped.id); + modelCount += 1; + } + + const warnedCombos = opts.collisionWarned ?? new Set(); + const cacheKey = `${opts.baseURL}::${opts.providerId}`; + const comboCount = await publishCombos({ + draft, + opts, + log, + providerId: X, + hostContract, + enrichment, + rawModelById, + publishedKeys, + publishedModelIds, + visibleFilter, + hiddenFilter, + usable, + canonicalToAlias, + combosFetcher, + combosTimeout, + warnedCombos, + cacheKey, + }); + if (comboCount === undefined) return { models: modelCount, combos: 0, autoCombos: 0 }; + + // Migration: v1 published opencode-X; v2 publishes X bare. Sessions pinned + // opencode-X resolve ModelUnavailableError -- see RELEASE.md migration note. + // Re-publishing under "opencode-"+X here is FORBIDDEN: a double + // publish would double chat entries in the picker. + + // Auto combos: virtual server-side entries from /api/combos/auto, keyed + // "auto" / "auto/" (v1 parity). Fail-open: a fetcher throw keeps + // models + combos and only warns - old gateways may not serve the + // endpoint at all (the default fetcher maps 404 to [] itself). + let rawAutoCombos: OmniRouteRawAutoCombo[]; + try { + rawAutoCombos = autoCombosFetcher + ? await autoCombosFetcher( + opts.baseURL, + opts.managementReadToken ?? opts.apiKey, + autoCombosTimeout + ) + : []; + } catch (err) { + log.warn( + `[omniroute-v2] auto combos fetch failed, falling back to models+combos catalog: ${err instanceof Error ? err.message : String(err)}` + ); + return { models: modelCount, combos: comboCount, autoCombos: 0 }; + } + + let autoComboCount = 0; + for (const autoCombo of rawAutoCombos) { + if (!autoCombo || !autoCombo.id) continue; + if (autoCombo.isHidden === true) continue; + // Auto combos are catalog entries like any other: an id a user asked to + // hide must stay hidden, and an allowlist that excludes it must exclude + // it. They used to skip both filters entirely. + if (!passesModelAllowlist(autoCombo.id, visibleFilter, hiddenFilter)) continue; + if (usable && !isUsableRawModelId(autoCombo.id, usable)) continue; + const mapped = mapAutoComboToModelV2(autoCombo, X, opts.baseURL, opts.apiFormat); + applyEnrichment(mapped, lookupEnrichment(autoCombo.id, enrichment, canonicalToAlias), { + isCombo: true, + isAutoCombo: true, + }); + const key = X + "/" + mapped.id; + if (publishedKeys.has(key)) { + const dedupeKey = `${cacheKey}::${key}`; + if (!warnedCombos.has(dedupeKey)) { + warnedCombos.add(dedupeKey); + log.warn( + `[omniroute-v2] auto combo key "${key}" collides with a model id; auto combo wins.` + ); + } + } + draft.model.update(X, mapped.id, (m) => { + assignModelFields(m, mapped, hostContract); + }); + publishedKeys.add(key); + publishedModelIds.set(key, mapped.id); + autoComboCount += 1; + } + + return { models: modelCount, combos: comboCount, autoCombos: autoComboCount }; +} diff --git a/@omniroute/opencode-plugin-v2/src/compat.ts b/@omniroute/opencode-plugin-v2/src/compat.ts new file mode 100644 index 0000000000..1c4aa9fe14 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/compat.ts @@ -0,0 +1,66 @@ +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isTransformHolder(value: unknown): value is { transform: unknown } { + return isObject(value) && "transform" in value; +} + +/** + * The catalog domain is the one this plugin cannot work without. The + * integration domain carries the credential flow and the `aisdk` domain the + * tool-schema cleaning: a host missing either still gets its catalog, so + * neither is asserted here — each is probed where it is used. + */ +export function assertContext(ctx: unknown): void { + if (!isObject(ctx)) { + throw new Error("[omniroute-v2] contract breach: ctx must be an object"); + } + if (!isTransformHolder(ctx.catalog) || typeof ctx.catalog.transform !== "function") { + throw new Error("[omniroute-v2] contract breach: ctx.catalog.transform must be a function"); + } + if (!isObject(ctx.options)) { + throw new Error("[omniroute-v2] contract breach: ctx.options must be an object"); + } +} + +/** + * Catalog contract spoken by the running host. + * + * opencode v2 is a moving target: the catalog contract changed between the + * binary that ships today and the SDK types this package pins. Rather than + * keying off a version list (which goes stale on the next release), the + * contract is discovered at runtime from the object the host seeds into the + * draft. + * + * - `legacy-package` — the seed carries a top-level `package` and no `api` + * block. Observed on `@opencode-ai/cli` 0.0.0-beta-17823, whose + * `Provider.Info.empty` is `{id, name, activation, package}`. + * - `sdk-api` — the seed carries an `api` block. This is the contract of the + * pinned `@opencode-ai/plugin`/`@opencode-ai/sdk` types. + * - `unknown` — neither or both. The caller publishes the superset. + */ +export type HostContract = "legacy-package" | "sdk-api" | "unknown"; + +export function detectHostContract(seed: unknown): HostContract { + if (!isObject(seed)) return "unknown"; + const hasApi = "api" in seed; + const hasPackage = "package" in seed; + if (hasApi && !hasPackage) return "sdk-api"; + if (hasPackage && !hasApi) return "legacy-package"; + return "unknown"; +} + +/** + * Whether to publish the legacy top-level fields (`package`, `settings`, + * `headers`, `variants[].settings`) next to the `api`-block fields. + * + * A host proven to speak the legacy contract gets them because it needs them; + * an unrecognised host gets them because the superset is the safer default + * (both field sets have been observed to survive an unknown-key write). A host + * that speaks the `api` contract does not, so a future strict schema cannot + * reject the write on an excess property. + */ +export function emitsLegacyFields(contract: HostContract): boolean { + return contract !== "sdk-api"; +} diff --git a/@omniroute/opencode-plugin-v2/src/credentials.ts b/@omniroute/opencode-plugin-v2/src/credentials.ts new file mode 100644 index 0000000000..9cf2b8536b --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/credentials.ts @@ -0,0 +1,101 @@ +import type { PluginContext } from "@opencode-ai/plugin/v2/promise"; +import type { Logger } from "./shared/index.js"; + +/** Where a resolved key came from, so the failure message can name the fix. */ +export type ApiKeyOrigin = "connection" | "option" | "env" | "missing"; + +export interface ResolvedApiKey { + key: string; + origin: ApiKeyOrigin; +} + +const ENV_VAR = "OMNIROUTE_API_KEY"; + +/** + * `ctx.integration.connection` is newer than the `key`/`env` methods this + * plugin registers, so a host that predates it exposes `integration` without + * it. Probing the shape keeps the plugin loadable on both. + */ +function connectionApi(ctx: PluginContext): PluginContext["integration"]["connection"] | undefined { + const connection = (ctx.integration as Partial).connection; + if ( + connection === undefined || + typeof connection.active !== "function" || + typeof connection.resolve !== "function" + ) { + return undefined; + } + return connection; +} + +/** + * Read the credential the user stored through the host's own auth flow. + * + * The plugin advertises `key` and `env` methods on its integration, so a user + * can connect it from the UI; without this lookup that connection would only + * feed inference and the catalog fetches would still need a key pasted into + * the config file. + * + * Returns `undefined` (never throws) when there is no connection, when the + * host is too old to expose one, or when the stored credential is an OAuth + * grant — this plugin authenticates the gateway with a bearer key, and an + * access token from an unrelated grant is not one. + */ +async function keyFromConnection( + ctx: PluginContext, + integrationID: string, + log: Logger +): Promise { + const connection = connectionApi(ctx); + if (connection === undefined) return undefined; + try { + const active = await connection.active(integrationID); + if (active === undefined) return undefined; + const credential = await connection.resolve(active); + if (credential === undefined) return undefined; + if (credential.type !== "key") { + log.warn( + `[omniroute-v2] ignoring the stored ${credential.type} credential: this plugin authenticates with an API key` + ); + return undefined; + } + return credential.key.length > 0 ? credential.key : undefined; + } catch (err) { + log.warn( + `[omniroute-v2] could not read the stored credential: ${err instanceof Error ? err.message : String(err)}` + ); + return undefined; + } +} + +/** + * Resolve the gateway key, preferring the credential the host holds over one + * written in config. A key in `opencode.json` still wins over the environment + * so an explicit per-project override keeps working. + */ +export async function resolveApiKey( + ctx: PluginContext, + integrationID: string, + optionKey: string | undefined, + log: Logger +): Promise { + const stored = await keyFromConnection(ctx, integrationID, log); + if (stored !== undefined) return { key: stored, origin: "connection" }; + if (optionKey !== undefined && optionKey.length > 0) return { key: optionKey, origin: "option" }; + const fromEnv = process.env[ENV_VAR]; + if (fromEnv !== undefined && fromEnv.length > 0) return { key: fromEnv, origin: "env" }; + return { key: "", origin: "missing" }; +} + +/** + * A missing key produces an empty catalog and no error the user can see, so + * say it once, and name the three ways to supply one. + */ +export function warnIfMissing(resolved: ResolvedApiKey, integrationID: string, log: Logger): void { + if (resolved.origin !== "missing") return; + log.warn( + `[omniroute-v2] no API key for "${integrationID}": the catalog will be empty. ` + + `Connect the integration from opencode, set "apiKey" in the plugin options, ` + + `or export ${ENV_VAR}.` + ); +} diff --git a/@omniroute/opencode-plugin-v2/src/enrichment-report.ts b/@omniroute/opencode-plugin-v2/src/enrichment-report.ts new file mode 100644 index 0000000000..d2ab275ae8 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/enrichment-report.ts @@ -0,0 +1,41 @@ +import type { Logger } from "./shared/index.js"; + +/** What the catalog loses when a given gateway source cannot be read. */ +function consequenceOf(endpoint: string): string { + if (endpoint.includes("/api/providers")) { + return "the usable-provider filter is disabled for this refresh, so unprovisioned providers stay listed"; + } + return "model names, provider tags, canonical dedupe and pricing are degraded"; +} + +/** + * A source the gateway refuses is not fatal — the catalog still publishes — + * but staying quiet about it is: the picker then shows raw ids, or lists + * providers that cannot serve, with nothing telling the user why. Say it once + * per endpoint so a refresh loop cannot spam the log. + * + * `usingFallbackToken` is true when no `managementReadToken` was configured and + * the inference key stands in for it, which is the usual reason a gateway + * answers 401/403 on `/api/*` — the advice differs from a token that was set + * and still got rejected. + */ +export function createSourceErrorReporter( + log: Logger, + usingFallbackToken: boolean +): (endpoint: string, reason: string) => void { + const warned = new Set(); + return (endpoint, reason) => { + if (warned.has(endpoint)) return; + warned.add(endpoint); + const unauthorized = reason.includes("401") || reason.includes("403"); + const hint = !unauthorized + ? "" + : usingFallbackToken + ? ` These endpoints need a management token: set "managementReadToken" in the plugin options ` + + `(it currently falls back to "apiKey", which a gateway usually rejects here).` + : ` The configured "managementReadToken" was rejected — check it grants read access to /api/*.`; + log.warn( + `[omniroute-v2] gateway source ${endpoint} unavailable (${reason}): ${consequenceOf(endpoint)}.${hint}` + ); + }; +} diff --git a/@omniroute/opencode-plugin-v2/src/gemini-language.ts b/@omniroute/opencode-plugin-v2/src/gemini-language.ts new file mode 100644 index 0000000000..c571367166 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/gemini-language.ts @@ -0,0 +1,43 @@ +import type { LanguageModelV3 } from "@ai-sdk/provider"; +import { type Logger, isGeminiModelId, sanitizeToolInputSchemas } from "./shared/index.js"; + +type CallOptions = Parameters[0]; + +/** + * Gemini answers `400 INVALID_ARGUMENT` — for the entire request, not just the + * offending tool — when a tool declaration carries `$schema` or + * `additionalProperties`. Anything upstream that emits standard JSON Schema + * therefore breaks tool calling as soon as the chain routes to Gemini. A + * `$ref` is forwarded untouched instead: stripping it would widen the schema + * to "accept anything", which is worse than letting the gateway answer. The + * v1 plugin dealt with this by wrapping `fetch` and rewriting the JSON body; the + * v2 home for it is the language model, where the tools are still structured + * data and no re-parsing is needed. + * + * Returns the model untouched when it is not bound for Gemini, so the wrapper + * costs nothing on every other chain. + */ +export function sanitizeToolSchemasFor( + language: T, + modelId: string, + log: Logger +): T { + if (language === undefined) return language; + if (!isGeminiModelId(modelId)) return language; + + const clean = (options: CallOptions): CallOptions => { + const tools = sanitizeToolInputSchemas(options.tools); + if (tools === undefined) return options; + log.debug( + `[omniroute-v2] stripped Gemini-incompatible schema keywords from ${tools.length} tool declaration(s) for ${modelId}` + ); + return { ...options, tools } as CallOptions; + }; + + // Prototype-linked so every other member of the model — including accessors + // and anything a future SDK version adds — keeps working untouched. + const wrapped: LanguageModelV3 = Object.create(language as object) as LanguageModelV3; + wrapped.doGenerate = (options) => language.doGenerate(clean(options)); + wrapped.doStream = (options) => language.doStream(clean(options)); + return wrapped as T; +} diff --git a/@omniroute/opencode-plugin-v2/src/index.ts b/@omniroute/opencode-plugin-v2/src/index.ts new file mode 100644 index 0000000000..f6c0471baf --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/index.ts @@ -0,0 +1,539 @@ +import { define, type PluginContext } from "@opencode-ai/plugin/v2/promise"; +import { + optionalTierFingerprint, + catalogContentFingerprint, + createLogger, + defaultOmniRouteAutoCombosFetcher, + defaultOmniRouteCombosFetcher, + defaultOmniRouteEnrichmentFetcher, + defaultOmniRouteModelsFetcher, + defaultOmniRouteProvidersFetcher, + type OmniRouteEnrichmentMap, + type OmniRouteProviderConnection, +} from "./shared/index.js"; +import type { + OmniRouteRawAutoCombo, + OmniRouteRawCombo, + OmniRouteRawModelEntry, +} from "./shared/index.js"; +import type { ResolvedOptions } from "./catalog.js"; +import { publishCatalog } from "./catalog.js"; +import { + DEFAULT_MODEL_CACHE_TTL_MS, + UNREACHABLE_COOLDOWN_MS, + memoryCacheKey, + readDiskSnapshot, + snapshotIdentityFingerprint, + writeDiskSnapshot, + type CatalogSnapshot, +} from "./cache.js"; +import { assertContext } from "./compat.js"; +import { type ApiKeyOrigin, resolveApiKey, warnIfMissing } from "./credentials.js"; +import { createSourceErrorReporter } from "./enrichment-report.js"; +import { sanitizeToolSchemasFor } from "./gemini-language.js"; +import { PLUGIN_ID, parsePluginOptions, resolveTimeouts, type PluginOptions } from "./options.js"; + +/** + * A fetch result that says whether it succeeded. Returning a bare `[]` on + * failure makes an outage indistinguishable from a gateway that legitimately + * has no combos — and the difference decides whether the last known value + * should be kept or dropped. + */ +type SourceResult = { ok: true; value: T } | { ok: false }; + +interface RefreshState { + entries: Map; + inFlight: Map>; + fingerprint: string | undefined; + /** Digest of the optional tier, so a reload only follows a real change. */ + optionalFingerprint: string | undefined; + /** + * When the last refresh found the gateway unreachable, skip the network + * until this timestamp and serve last-known-good instead. Without it every + * transform past TTL re-fires the full fetch suite against a gateway that + * just proved it cannot answer — a self-inflicted retry storm. + */ + unreachableUntil: number; +} + +function toResolvedOptions(parsed: PluginOptions): ResolvedOptions { + return { + providerId: parsed.providerId, + baseURL: parsed.baseURL, + apiKey: parsed.apiKey ?? process.env.OMNIROUTE_API_KEY ?? "", + managementReadToken: parsed.managementReadToken, + timeoutMs: parsed.timeoutMs, + timeouts: parsed.timeouts, + logLevel: parsed.logLevel, + startupDebug: parsed.startupDebug, + providerTag: parsed.providerTag, + modelCacheTtlMs: + typeof parsed.modelCacheTtlMs === "number" && parsed.modelCacheTtlMs > 0 + ? parsed.modelCacheTtlMs + : DEFAULT_MODEL_CACHE_TTL_MS, + displayName: parsed.displayName, + apiFormat: parsed.apiFormat, + visibleModels: parsed.visibleModels, + hiddenModels: parsed.hiddenModels, + usableOnly: parsed.usableOnly, + enrichment: parsed.enrichment, + }; +} + +export default define({ + id: PLUGIN_ID, + setup: async (ctx: PluginContext) => { + assertContext(ctx); + const parsed = parsePluginOptions(ctx.options); + const X = parsed.providerId; + const resolved = toResolvedOptions(parsed); + const timeouts = resolveTimeouts(parsed); + const log = createLogger(parsed.startupDebug ? "debug" : (parsed.logLevel ?? "warn")); + resolved.logger = log; + resolved.logLevel = parsed.logLevel; + resolved.startupDebug = parsed.startupDebug; + log.info(`[omniroute-v2] init providerId=${X}`); + + // v1 parity port: in-memory TTL + disk snapshot. The memory key + // `baseURL::sha256(creds)` isolates credential tuples (prod vs + // staging); the TTL is checked in the transform before any fetch; + // concurrent calls share the refresh promise in the setup closure keyed + // by (providerId, baseURL); the disk snapshot feeds warm-startup and + // the offline fallback. The existing in-memory keep-last-good is kept. + const state: RefreshState = { + entries: new Map(), + inFlight: new Map(), + fingerprint: undefined, + optionalFingerprint: undefined, + unreachableUntil: 0, + }; + + // The credential the host holds wins over one written in config, so a + // user who connected the integration from the UI never has to paste a + // key into `opencode.json`. Reading it is async and the transforms must + // register synchronously, so the lookup happens on the first publish; + // until then the option/env key resolved above stands in. + const credentialsOf = (): { cacheKey: string; identityFingerprint: string } => ({ + cacheKey: memoryCacheKey( + resolved.baseURL, + `${resolved.apiKey}\0${resolved.managementReadToken ?? resolved.apiKey}` + ), + identityFingerprint: snapshotIdentityFingerprint( + resolved.baseURL, + resolved.apiKey, + resolved.managementReadToken ?? resolved.apiKey + ), + }); + let { cacheKey, identityFingerprint } = credentialsOf(); + + // Both keys are derived from the credential: two credentials must never + // share a snapshot, so they are recomputed whenever the key moves. + let credentialChecked = false; + let apiKeyOrigin: ApiKeyOrigin = resolved.apiKey.length > 0 ? "option" : "missing"; + const ensureCredential = async (): Promise => { + // Settled once a key is in hand: re-reading on every refresh would let + // a mid-session change silently repoint the snapshot keys. + if (credentialChecked && apiKeyOrigin !== "missing") return; + const next = await resolveApiKey(ctx, X, parsed.apiKey, log); + const moved = next.key !== resolved.apiKey; + resolved.apiKey = next.key; + apiKeyOrigin = next.origin; + if (moved) ({ cacheKey, identityFingerprint } = credentialsOf()); + if (!credentialChecked) warnIfMissing(next, X, log); + else if (moved) log.info(`[omniroute-v2] API key picked up from the ${next.origin} source`); + credentialChecked = true; + }; + + const fetchModelsSafe = async (): Promise => { + try { + return await defaultOmniRouteModelsFetcher( + resolved.baseURL, + resolved.apiKey, + timeouts.models + ); + } catch (err) { + log.warn( + `[omniroute-v2] models fetch failed, publishing empty catalog: ${err instanceof Error ? err.message : String(err)}` + ); + return []; + } + }; + // Failures are reported once per endpoint (with the management-token hint + // when the inference key stands in), so a gated `/api/*` degrades loudly + // rather than silently. Declared before the wrappers that use it. + const reportSourceError = createSourceErrorReporter( + log, + resolved.managementReadToken === undefined + ); + const fetchCombosSafe = async (): Promise> => { + try { + return { + ok: true, + value: await defaultOmniRouteCombosFetcher( + resolved.baseURL, + resolved.managementReadToken ?? resolved.apiKey, + timeouts.combos + ), + }; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + reportSourceError("/api/combos", reason); + log.warn(`[omniroute-v2] combos fetch failed, keeping the last known combos: ${reason}`); + return { ok: false }; + } + }; + // Providers connections follow the same rule: gated on usableOnly (no + // request when false, v1 parity), soft-fail to [] so the filter degrades + // to keep-all instead of hiding the catalog. + const fetchProvidersSafe = async (): Promise> => { + if (!resolved.usableOnly) return { ok: true, value: [] }; + try { + return { + ok: true, + value: await defaultOmniRouteProvidersFetcher( + resolved.baseURL, + resolved.managementReadToken ?? resolved.apiKey, + timeouts.models, + reportSourceError + ), + }; + } catch (err) { + log.warn( + `[omniroute-v2] providers fetch failed, keeping the last known provider list: ${err instanceof Error ? err.message : String(err)}` + ); + return { ok: false }; + } + }; + // Enrichment follows the same rule: gated on the option (default on, + // v1 parity), soft-fail to an empty map so names/pricing degrade to + // mapper defaults instead of hiding the catalog. + const fetchEnrichmentSafe = async (): Promise> => { + if (resolved.enrichment === false) return { ok: true, value: new Map() }; + try { + return { + ok: true, + value: await defaultOmniRouteEnrichmentFetcher( + resolved.baseURL, + resolved.managementReadToken ?? resolved.apiKey, + timeouts.enrichment, + reportSourceError + ), + }; + } catch (err) { + log.warn( + `[omniroute-v2] enrichment fetch failed, keeping the last known names/pricing: ${err instanceof Error ? err.message : String(err)}` + ); + return { ok: false }; + } + }; + const fetchAutoCombosSafe = async (): Promise> => { + try { + return { + ok: true, + value: await defaultOmniRouteAutoCombosFetcher( + resolved.baseURL, + resolved.managementReadToken ?? resolved.apiKey, + timeouts.autoCombos, + log, + reportSourceError + ), + }; + } catch (err) { + // The default fetcher reports the refusal itself (with the + // management-token hint); this warn is the fallback for injected + // stubs that throw without reporting. + const reason = err instanceof Error ? err.message : String(err); + log.warn(`[omniroute-v2] auto combos fetch failed, keeping the last known ones: ${reason}`); + return { ok: false }; + } + }; + + /** + * Fetch in two tiers. Models are what a catalog *is*: without them there + * is nothing to publish. Everything else — combos, auto-combos, the + * provider list, the enrichment overlay — improves an already usable + * catalog, so awaiting any of them before publishing makes the catalog + * hostage to the slowest source: a gateway that accepts the connection + * and never answers one endpoint kept everything unpublished until that + * fetch's own timeout fired, which is longer than some hosts stay alive. + * + * The optional tier therefore keeps running after the publish and upgrades + * the stored snapshot when it lands, so the next transform serves the + * complete catalog. + */ + async function refreshSnapshot(): Promise { + // Models are what a catalog *is*; everything else improves one that + // already works. Combos used to sit here too, so a gateway slow to + // answer /api/combos held the whole picker back — the very thing the + // staged publish exists to prevent. + const essential = fetchModelsSafe(); + const optional = Promise.all([ + fetchCombosSafe(), + fetchAutoCombosSafe(), + fetchProvidersSafe(), + fetchEnrichmentSafe(), + ]); + const models = await essential; + const previous = state.entries.get(cacheKey); + // A gateway that just failed everything gets a short breather: serving + // last-known-good for a few seconds beats hammering it on every + // transform while it is down. Arms whenever the models fetch comes back + // empty — with or without a prior entry to serve — so a totally dead + // gateway stops getting hit every window. Partial degradation (models + // healthy, an optional tier failed) still retries normally next window. + if (models.length === 0) { + state.unreachableUntil = Date.now() + UNREACHABLE_COOLDOWN_MS; + } + // Carry every source forward until its replacement lands, and keep the + // old value when a fetch FAILED — but honour a gateway that legitimately + // returns nothing, which is a different answer from "I could not ask". + const snapshot: CatalogSnapshot = { + models, + combos: previous?.combos ?? [], + autoCombos: previous?.autoCombos ?? [], + providers: previous?.providers ?? [], + enrichment: previous?.enrichment ?? new Map(), + fetchedAt: Date.now(), + }; + if (models.length > 0) { + state.entries.set(cacheKey, snapshot); + await writeDiskSnapshot(X, snapshot, identityFingerprint); + } + void optional.then( + (parts) => upgradeWithOptional(snapshot, parts), + (err) => { + // The wrappers never reject; a throw here would be a bug in them, and + // an unhandled rejection is a worse way to learn about it. + log.warn( + `[omniroute-v2] optional catalog sources failed unexpectedly: ${err instanceof Error ? err.message : String(err)}` + ); + } + ); + return snapshot; + } + + /** + * Fold late optional data into the snapshot that was published without it. + * Skipped when a newer refresh has already replaced that snapshot, so a + * slow tier can never resurrect a stale catalog. + */ + async function upgradeWithOptional( + base: CatalogSnapshot, + [combos, autoCombos, providers, enrichment]: [ + SourceResult, + SourceResult, + SourceResult, + SourceResult, + ] + ): Promise { + if (state.entries.get(cacheKey) !== base) return; + // Per source: a success replaces (even with an empty answer — that is + // the gateway's answer), a failure keeps what we had. + const upgraded: CatalogSnapshot = { + ...base, + combos: combos.ok ? combos.value : base.combos, + autoCombos: autoCombos.ok ? autoCombos.value : base.autoCombos, + providers: providers.ok ? providers.value : base.providers, + enrichment: enrichment.ok ? enrichment.value : base.enrichment, + }; + const unchanged = + upgraded.combos === base.combos && + upgraded.autoCombos === base.autoCombos && + upgraded.providers === base.providers && + upgraded.enrichment === base.enrichment; + if (unchanged) return; + state.entries.set(cacheKey, upgraded); + if (upgraded.models.length > 0) { + await writeDiskSnapshot(X, upgraded, identityFingerprint); + } + // Reload only when the optional tier actually moved: the catalog + // fingerprint covers ids alone, so without this the host would rebuild + // its catalog once per TTL window for an identical result. + const optionalFingerprint = optionalTierFingerprint( + upgraded.autoCombos ?? [], + upgraded.providers ?? [], + upgraded.enrichment, + upgraded.combos + ); + const optionalChanged = state.optionalFingerprint !== optionalFingerprint; + state.optionalFingerprint = optionalFingerprint; + if (optionalChanged && typeof ctx.catalog.reload === "function") { + try { + await ctx.catalog.reload(); + } catch (err) { + log.warn( + `[omniroute-v2] catalog reload after late sources failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + } + + function loadSnapshot(): Promise { + const now = Date.now(); + const hit = state.entries.get(cacheKey); + if (hit && hit.fetchedAt + resolved.modelCacheTtlMs > now) return Promise.resolve(hit); + // Cooldown after a total models failure: skip the network until it + // lapses. Serves last-known-good when one exists; otherwise the refresh + // below still runs (nothing to serve, no point pretending). + if (now < state.unreachableUntil && hit) return Promise.resolve(hit); + if (now >= state.unreachableUntil) state.unreachableUntil = 0; + const inflight = state.inFlight.get(cacheKey); + if (inflight) return inflight; + const snapshot = refreshSnapshot(); + state.inFlight.set(cacheKey, snapshot); + const clear = () => { + if (state.inFlight.get(cacheKey) === snapshot) state.inFlight.delete(cacheKey); + }; + snapshot.then(clear, clear); + return snapshot; + } + + // Warm-startup: the disk snapshot is read at boot (without blocking + // the synchronous transform registration) to publish the last-known + // catalog before the first successful fetch. + /** + * Warm start: publish the last known catalog from disk before the first + * fetch returns. Deliberately read *after* the credential is resolved — + * the snapshot is keyed by the credential tuple, and resolving the host + * credential changes that key, so reading at setup time would look up the + * wrong identity and reject a perfectly good snapshot. + */ + let warmLoadedFor: string | undefined; + const ensureWarmSnapshot = async (): Promise => { + if (warmLoadedFor === identityFingerprint) return; + warmLoadedFor = identityFingerprint; + const warm = await readDiskSnapshot(X, identityFingerprint, log); + if (warm && !state.entries.has(cacheKey)) state.entries.set(cacheKey, warm); + }; + + // Fail-closed models (keep-last-good, validated): an empty models fetch + // (transient 500/timeout) must not wipe a known catalog. The latest + // non-empty entry (fresh fetch or warm disk snapshot) is replayed + // instead of publishing the empty set. `refreshSnapshot` never overwrites + // the memory entry on failure, so `entries` stays the last-known-good + // source — including cross-setup via the disk snapshot. + // Fail-open one level down, in the wrappers (never reject) and the + // `publishCatalog` catches — so no try/catch here. + const catalogRegistration = ctx.catalog.transform(async (draft) => { + await ensureCredential(); + await ensureWarmSnapshot(); + const snapshot = await loadSnapshot(); + let effective = snapshot; + if (snapshot.models.length === 0) { + const stale = state.entries.get(cacheKey); + if (stale !== undefined && stale.models.length > 0) { + log.warn( + `[omniroute-v2] models fetch returned empty, keeping last-known catalog (${stale.models.length} models, ${stale.combos.length} combos)` + ); + effective = stale; + } + } + const counts = await (async (): Promise<{ + models: number; + combos: number; + autoCombos: number; + }> => { + // fetcher-level fail-open covers fetches; this guard covers mapper/draft throws. + try { + return await publishCatalog(draft, resolved, { + onSourceError: reportSourceError, + models: async () => effective.models, + combos: async () => effective.combos, + autoCombos: async () => effective.autoCombos, + providers: async () => effective.providers ?? [], + enrichment: async () => effective.enrichment ?? new Map(), + }); + } catch (err) { + log.warn( + `[omniroute-v2] catalog publish failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}` + ); + return { models: 0, combos: 0, autoCombos: 0 }; + } + })(); + void counts; + const fingerprint = catalogContentFingerprint( + effective.models, + effective.combos, + effective.autoCombos + ); + const changed = state.fingerprint !== undefined && state.fingerprint !== fingerprint; + state.fingerprint = fingerprint; + if (changed && typeof ctx.catalog.reload === "function") { + await Promise.resolve(); + try { + await ctx.catalog.reload(); + } catch (err) { + log.warn( + `[omniroute-v2] catalog reload failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + }); + const integrationHook = (ctx.integration as Partial | undefined) + ?.transform; + // A host that exposes the hook but throws while registering it must cost + // the plugin nothing but the connect action: the throw happens OUTSIDE + // any await, so only a call-site guard catches it (an await-guard alone + // would let a synchronous throw escape setup and kill the catalog). + let integrationRegistration: unknown; + if (typeof integrationHook === "function") { + try { + integrationRegistration = integrationHook((draft) => { + draft.update(X, (integration) => { + integration.name = parsed.displayName ?? "OmniRoute"; + }); + draft.method.update({ integrationID: X, method: { type: "key", label: "API key" } }); + draft.method.update({ + integrationID: X, + method: { type: "env", names: ["OMNIROUTE_API_KEY"] }, + }); + }); + } catch (err) { + log.warn( + `[omniroute-v2] host refused the integration hook, the connect action will be missing: ${err instanceof Error ? err.message : String(err)}` + ); + integrationRegistration = undefined; + } + } + /** + * `aisdk.language` is newer than the catalog domain, so a host may not + * expose it; the plugin must stay loadable there, minus the sanitising. + */ + const languageHook = (ctx.aisdk as Partial | undefined)?.language; + // A host that rejects this registration must cost the catalog nothing: the + // plugin is a catalog first, and tool-schema cleaning is an extra. + let languageRegistration: Promise<{ dispose: () => Promise }> | undefined; + if (parsed.geminiSanitization !== false && typeof languageHook === "function") { + try { + languageRegistration = languageHook((input) => { + if (input.model.providerID !== X) return; + input.language = sanitizeToolSchemasFor(input.language, input.model.id, log); + }); + } catch (err) { + log.warn( + `[omniroute-v2] host refused the language-model hook, Gemini tool schemas will not be cleaned: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + + await catalogRegistration; + if (integrationRegistration !== undefined) { + try { + await integrationRegistration; + } catch (err) { + log.warn( + `[omniroute-v2] host refused the integration hook, the connect action will be missing: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + if (languageRegistration !== undefined) { + try { + await languageRegistration; + } catch (err) { + log.warn( + `[omniroute-v2] language-model hook registration failed, Gemini tool schemas will not be cleaned: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + }, +}); diff --git a/@omniroute/opencode-plugin-v2/src/options.ts b/@omniroute/opencode-plugin-v2/src/options.ts new file mode 100644 index 0000000000..9782ca74e6 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/options.ts @@ -0,0 +1,117 @@ +import { z } from "zod"; + +const apiFormatSchema = z + .object({ + allowAnthropic: z.boolean().optional(), + anthropicModels: z.array(z.string()).optional(), + // Deprecated v1 prefix list. Accepted (warn at resolve time) so copied + // v1 configs keep routing; prefer anthropicModels (full IDs). + anthropicPrefixes: z.array(z.string()).optional(), + }) + .strict(); + +const timeoutsSchema = z + .object({ + models: z.number().positive().optional(), + combos: z.number().positive().optional(), + autoCombos: z.number().positive().optional(), + enrichment: z.number().positive().optional(), + }) + .strict(); + +const pluginOptionsSchema = z + .object({ + // Reaches a filesystem path (the on-disk catalog snapshot) and the + // catalog keys, so it is bounded here rather than escaped at each use. + providerId: z + .string() + .regex(/^[A-Za-z0-9._-]+$/, "providerId may only contain letters, digits, '.', '_' and '-'") + .refine((v) => v !== "." && v !== "..", "providerId cannot be a path segment") + .default("omniroute"), + baseURL: z.string().url(), + apiKey: z.string().optional(), + displayName: z.string().optional(), + managementReadToken: z.string().optional(), + timeoutMs: z.number().positive().default(10000), + timeouts: timeoutsSchema.optional(), + logLevel: z.enum(["error", "warn", "info", "debug"]).optional(), + startupDebug: z.boolean().optional(), + modelCacheTtlMs: z.number().positive().optional(), + visibleModels: z.array(z.string()).optional(), + hiddenModels: z.array(z.string()).optional(), + usableOnly: z.boolean().default(false), + // v1 parity: enrichment overlay on by default (names + pricing). + enrichment: z.boolean().default(true), + // v1 parity: strip the JSON-Schema keywords Gemini rejects from tool + // declarations bound for a Gemini model. On by default — leaving them in + // fails the whole request with 400 INVALID_ARGUMENT. + geminiSanitization: z.boolean().default(true), + // v1 parity: prefix a model's display name with the upstream provider it + // routes to, so the same model sold through two connections is + // distinguishable in the picker. + providerTag: z.boolean().default(true), + apiFormat: apiFormatSchema.optional(), + }) + .strict(); + +export type PluginOptions = z.infer; + +/** Per-endpoint timeout defaults (v1 parity). `timeoutMs` is the global fallback. */ +export const DEFAULT_TIMEOUT_MS = 10_000 as const; +/** Auto-combos keep the v1 5s budget; the field is resolved now for the P3 port. */ +export const DEFAULT_AUTO_COMBOS_TIMEOUT_MS = 5_000 as const; + +export interface EndpointTimeouts { + models: number; + combos: number; + autoCombos: number; + enrichment: number; +} + +export function resolveTimeouts( + opts: Pick +): EndpointTimeouts { + const fallback = + typeof opts.timeoutMs === "number" && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_TIMEOUT_MS; + return { + models: opts.timeouts?.models ?? fallback, + combos: opts.timeouts?.combos ?? fallback, + autoCombos: opts.timeouts?.autoCombos ?? DEFAULT_AUTO_COMBOS_TIMEOUT_MS, + enrichment: opts.timeouts?.enrichment ?? fallback, + }; +} + +/** + * Parse the plugin block of `opencode.json`. + * + * A rejected option aborts the whole plugin, and the host reports that as a + * bare load failure with the validator's raw dump attached — which is how a + * single mistyped key turns into a wall of JSON and an empty model picker. The + * schema is strict on purpose (a silently ignored option is worse), so the + * least we owe the user is a first line naming what to fix. + */ +export function parsePluginOptions(raw: unknown): PluginOptions { + const result = pluginOptionsSchema.safeParse(raw); + if (result.success) return result.data; + const problems = result.error.issues.map((issue) => { + const at = issue.path.length > 0 ? issue.path.join(".") : "(root)"; + const unknown = issue.code === "unrecognized_keys" ? issue.keys.join(", ") : undefined; + return unknown !== undefined ? `unknown option "${unknown}"` : `${at}: ${issue.message}`; + }); + throw new Error(`[omniroute-v2] invalid plugin options — ${problems.join("; ")}`); +} + +/** + * The host reads the plugin id from the module, before any option is known, so + * it cannot carry the configured provider id. Publishing two gateways from one + * install is a `providerId` matter — that one does reach the catalog. + */ +export const PLUGIN_ID = "omniroute-v2"; + +export function providerIdFor(providerId: string): string { + return providerId; +} + +export function integrationIdFor(providerId: string): string { + return providerId; +} diff --git a/@omniroute/opencode-plugin-v2/src/shared/auto-combos.ts b/@omniroute/opencode-plugin-v2/src/shared/auto-combos.ts new file mode 100644 index 0000000000..04f70f7625 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/shared/auto-combos.ts @@ -0,0 +1,219 @@ +import type { Model as ModelV2 } from "@opencode-ai/sdk/v2"; +import type { ApiFormatV2 } from "./models-map.js"; +import { resolveApiBlockV2 } from "./models-map.js"; +import { autoComboModelId, formatAutoComboName, type AutoVariant } from "./naming.js"; + +export type { AutoVariant }; + +/** + * Raw shape of an auto combo entry as returned by OmniRoute's + * `/api/combos/auto` endpoint. Auto combos are virtual -- they self-manage + * provider selection via scoring/bandit exploration at runtime. + * + * Ported from the v1 plugin (`index.ts:1672-1698`); the shape is unchanged + * so old and new gateways stay wire-compatible. + */ +export interface OmniRouteRawAutoCombo { + /** Stable id (e.g. "auto", "auto/coding"). */ + id: string; + /** Human-readable name (e.g. "Auto", "Auto Coding"). */ + name?: string; + /** Variant key or undefined for the default auto. */ + variant?: AutoVariant; + /** Provider names eligible for this auto combo. */ + candidatePool?: string[]; + /** Number of candidates resolved at fetch time. */ + candidateCount?: number; + /** MAX of candidates' context windows, served by newer gateway builds. + * Absent on older servers -- the mapper falls back to a safe default. */ + context_length?: number; + /** MAX of candidates' max output tokens (same provenance as context_length). */ + max_output_tokens?: number; + /** Whether this auto combo should be hidden from the picker. */ + isHidden?: boolean; + /** Auto-combo configuration. */ + config?: { + auto?: { + candidatePool?: string[]; + explorationRate?: number; + routerStrategy?: string; + }; + }; +} + +/** Minimal warn sink so the fetcher never depends on the plugin logger. */ +export interface AutoCombosWarnSink { + warn: (message: string, ...args: unknown[]) => void; +} + +/** + * Fetcher contract for `/api/combos/auto`. Returns the list of virtual + * auto combos the server can create. Same DI shape as the other fetchers + * so unit tests can inject a stub instead of monkey-patching `fetch`. + * + * HTTP refusals (non-2xx other than 404) and network errors THROW: the caller + * distinguishes "the gateway failed" (keep last-known) from "the gateway + * answered empty" (publish empty). Only 404 stays soft — the endpoint does + * not exist yet on older gateways, and that is an answer, not a failure. + */ +export type OmniRouteAutoCombosFetcher = ( + baseURL: string, + apiKey: string, + timeoutMs?: number, + logger?: AutoCombosWarnSink, + onSourceError?: (endpoint: string, reason: string) => void +) => Promise; + +function trimTrailingSlashes(value: string): string { + let i = value.length; + while (i > 0 && value.charCodeAt(i - 1) === 0x2f /* "/" */) i--; + return i === value.length ? value : value.slice(0, i); +} + +function fallbackWarn(message: string, ...args: unknown[]): void { + console.warn(`[omniroute-plugin] [WARN] ${message}`, ...args); +} + +/** + * Default auto combos fetcher: `GET /api/combos/auto`. + * + * 404 stays soft (endpoint not deployed yet on older gateways — an answer, + * not a failure). Any other non-2xx or network error THROWS so the caller + * keeps last-known instead of publishing an empty tier: a 403 behind a + * management-token gate must not wipe the auto combos the picker had. + * v1 parity keeps the 5s timeout budget. + */ +export const defaultOmniRouteAutoCombosFetcher: OmniRouteAutoCombosFetcher = async ( + baseURL, + apiKey, + timeoutMs = 5_000, + logger?: AutoCombosWarnSink, + onSourceError?: (endpoint: string, reason: string) => void +) => { + if (!apiKey || !baseURL) return []; + const warn = logger?.warn ?? fallbackWarn; + const report = (reason: string): void => { + warn(reason); + onSourceError?.("/api/combos/auto", reason); + }; + + const trimmed = trimTrailingSlashes(baseURL); + const root = trimmed.replace(/\/v\d+$/, ""); + const url = `${root}/api/combos/auto`; + + 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, + }); + // 404 = endpoint not deployed yet -- expected during rollout + if (res.status === 404) { + warn(`/api/combos/auto not available (404) -- auto combos disabled`); + return []; + } + if (!res.ok) { + const reason = `HTTP ${res.status} ${res.statusText}`; + report(`/api/combos/auto refused (${reason}) -- keeping last-known auto combos`); + throw new Error(reason); + } + 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: OmniRouteRawAutoCombo[] = []; + for (const r of rawList) { + if (r && typeof r === "object" && typeof (r as { id?: unknown }).id === "string") { + out.push(r as OmniRouteRawAutoCombo); + } + } + return out; + } catch (err) { + // Network error, timeout, abort -- keep last-known, never publish empty. + // (The 404-soft path above returns directly and never reaches this throw.) + const reason = `/api/combos/auto fetch failed: ${err instanceof Error ? err.message : String(err)} -- keeping last-known auto combos`; + report(reason); + throw err instanceof Error ? err : new Error(String(err)); + } finally { + clearTimeout(timer); + } +}; + +/** Fallbacks when the server does not advertise auto-combo limits (older + * gateway builds). MUST be positive: OpenCode's overflow guard treats + * `limit.context === 0` as "never overflow" and silently DISABLES smart + * auto-compaction, letting the session grow until the gateway's destructive + * history purge kicks in. */ +export const AUTO_COMBO_FALLBACK_CONTEXT = 128_000; +export const AUTO_COMBO_FALLBACK_OUTPUT = 8_192; + +/** + * Convert a raw auto combo into a `ModelV2` entry for the picker. + * Auto combos route to capable models, so tool_call and reasoning default + * to true. Context/output limits come from the server (MAX of the + * candidate pool's windows); a safe positive fallback applies when the + * server omits them. Never 0. + */ +export function mapAutoComboToModelV2( + autoCombo: OmniRouteRawAutoCombo, + providerId: string, + baseURL: string, + apiFormat?: ApiFormatV2 +): ModelV2 { + const name = formatAutoComboName(autoCombo.variant, autoCombo.candidateCount); + const context = + typeof autoCombo.context_length === "number" && autoCombo.context_length > 0 + ? autoCombo.context_length + : AUTO_COMBO_FALLBACK_CONTEXT; + const output = + typeof autoCombo.max_output_tokens === "number" && autoCombo.max_output_tokens > 0 + ? autoCombo.max_output_tokens + : AUTO_COMBO_FALLBACK_OUTPUT; + return { + id: autoComboModelId(autoCombo.variant), + providerID: providerId, + api: resolveApiBlockV2(autoComboModelId(autoCombo.variant), baseURL, apiFormat), + name, + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + 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, + output, + }, + status: "active", + options: {}, + headers: {}, + release_date: "", + }; +} diff --git a/@omniroute/opencode-plugin-v2/src/shared/combos-map.ts b/@omniroute/opencode-plugin-v2/src/shared/combos-map.ts new file mode 100644 index 0000000000..74ad94117f --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/shared/combos-map.ts @@ -0,0 +1,254 @@ +import type { Model as ModelV2 } from "@opencode-ai/sdk/v2"; +import { type ApiFormatV2, type OmniRouteRawModelEntry, resolveApiBlockV2 } from "./models-map.js"; + +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; + /** + * Server-computed context window for this combo (aggregated from member + * models using the same logic as /v1/models). When present, the client + * uses this value directly instead of re-aggregating from member models. + * + * Added in 3.9.x — old servers do not send it. + */ + computed_context_length?: number; +} + +/** + * 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; + +function trimTrailingSlashes(value: string): string { + let i = value.length; + while (i > 0 && value.charCodeAt(i - 1) === 0x2f /* "/" */) i--; + return i === value.length ? value : value.slice(0, i); +} + +/** + * 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-v2] apiKey required to fetch /api/combos"); + if (!baseURL) throw new Error("[omniroute-v2] 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 = trimTrailingSlashes(baseURL); + 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-v2] 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: `cost` zeroed; `status = "active"`; + * `release_date = combo.release_date ?? ""`; + * `api = LCD (all-anthropic else 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, + apiFormat?: ApiFormatV2 +): 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: hasMembers && members.every((m) => Boolean(m.capabilities?.thinking)), + }; + + // Combos span multiple providers. Use Anthropic format only when ALL + // members resolve to Anthropic — otherwise fall back to OpenAI-compat + // (lowest common denominator that every upstream understands). + const comboApiBlock = (() => { + if (!hasMembers) return resolveApiBlockV2(combo.id, baseURL, apiFormat); + const allAnthropic = members.every( + (m) => resolveApiBlockV2(m.id, baseURL, apiFormat).id === "anthropic" + ); + return allAnthropic + ? resolveApiBlockV2(members[0].id, baseURL, apiFormat) + : resolveApiBlockV2(combo.id, baseURL, apiFormat); + })(); + + return { + id: combo.id, + providerID: providerId, + api: comboApiBlock, + 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: + typeof combo.computed_context_length === "number" && combo.computed_context_length > 0 + ? combo.computed_context_length + : 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 ?? "", + }; +} diff --git a/@omniroute/opencode-plugin-v2/src/shared/enrich.ts b/@omniroute/opencode-plugin-v2/src/shared/enrich.ts new file mode 100644 index 0000000000..1eec8c61c7 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/shared/enrich.ts @@ -0,0 +1,606 @@ +import type { Model as ModelV2 } from "@opencode-ai/sdk/v2"; +import { buildModelDisplayName } from "./naming.js"; +import type { FreeModelFreeType } from "./naming.js"; + +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; + }; + /** + * Provider alias prefix seen in `/v1/models` ids (e.g. `cc`, `gemini`). + * Populated by `defaultOmniRouteEnrichmentFetcher` from + * `/api/pricing/models` keys. Drives the `usableOnly` alias↔canonical + * resolution. + */ + providerAlias?: string; + /** + * Canonical provider id used by `/api/providers` connections (e.g. + * `claude`, `gemini`, `kiro`). Populated from the per-provider + * `entry.id` field inside `/api/pricing/models`. + */ + providerCanonical?: string; + /** + * Human-readable upstream provider label (e.g. `Claude`, `Kiro`, + * `Windsurf`, `GitHub Models`). Populated from the per-provider + * `entry.name` field inside `/api/pricing/models`. Used by the + * `providerTag` feature to suffix `ModelV2.name` with the routing + * destination so the OC TUI picker can differentiate the same + * model id sold through different upstream connections. + */ + providerDisplayName?: string; + /** Free-model budget type (from freeModelCatalog). */ + freeType?: FreeModelFreeType; + /** Monthly token budget for recurring free models. */ + monthlyTokens?: number; + /** Credit token budget for credit-based free models. */ + creditTokens?: number; +} + +/** Map keyed by full model id (possibly namespaced, e.g. `cc/claude-sonnet-4-6`). */ +export type OmniRouteEnrichmentMap = Map; + +/** + * Reverse-index the enrichment map from `providerCanonical → providerAlias`. + * + * OmniRoute's `/api/pricing/models` is keyed by short ALIAS (`cc`, `cx`, + * `pol`). But `/v1/models` exposes some models a SECOND time under their + * CANONICAL name (`claude/claude-opus-4-7`, `codex/gpt-5.5`, + * `pollinations/midjourney`). Without a reverse map, those canonical + * rows miss enrichment entirely and surface as raw ids in the picker. + * + * Built once per refresh from the enrichment entries themselves — no + * hardcoded registry. Only records `canonical → alias` mappings when + * both are present AND distinct (skips slots where alias === canonical + * like `kiro`). + */ +export function buildCanonicalToAliasMap( + enrichment: OmniRouteEnrichmentMap | undefined +): Map { + const out = new Map(); + if (!enrichment) return out; + for (const entry of enrichment.values()) { + const alias = typeof entry.providerAlias === "string" ? entry.providerAlias.trim() : ""; + const canonical = + typeof entry.providerCanonical === "string" ? entry.providerCanonical.trim() : ""; + if (alias.length === 0 || canonical.length === 0) continue; + if (alias === canonical) continue; + if (!out.has(canonical)) out.set(canonical, alias); + } + return out; +} + +/** + * Enrichment lookup with alias-fallback chain. + * + * Resolution order (first hit wins): + * + * 1. `enrichment.get(rawId)` — direct hit on `/` or + * bare id (the fetcher writes under both forms). + * 2. If `rawId` is `/` and `canonicalToAlias` has + * a mapping for `canonical`, try `/`. This rescues + * duplicate rows like `claude/claude-opus-4-7` (canonical) when + * enrichment only indexed under `cc/claude-opus-4-7` (alias). + * 3. Bare `` as a last resort. Already covered by step 1 in + * practice (fetcher writes bare keys), but kept defensive. + * + * Returns `undefined` when no lookup hits. + */ +export function lookupEnrichment( + rawId: string, + enrichment: OmniRouteEnrichmentMap | undefined, + canonicalToAlias: Map +): OmniRouteEnrichmentEntry | undefined { + if (!enrichment) return undefined; + const direct = enrichment.get(rawId); + if (direct) return direct; + const slash = rawId.indexOf("/"); + if (slash > 0) { + const prefix = rawId.slice(0, slash); + const modelId = rawId.slice(slash + 1); + const alias = canonicalToAlias.get(prefix); + if (alias && alias !== prefix) { + const viaAlias = enrichment.get(`${alias}/${modelId}`); + if (viaAlias) return viaAlias; + } + const bare = enrichment.get(modelId); + if (bare) return bare; + } + return undefined; +} + +/** + * Pre-pass: detect raw rows that are the CANONICAL twin of an ALIAS row + * already in the catalog. Returns the set of canonical-keyed ids to skip + * during the raw-model loop so each model surfaces exactly once under + * its enriched alias key. + * + * Example: `/v1/models` returns BOTH `cc/claude-opus-4-7` and + * `claude/claude-opus-4-7`. The former is enriched (alias `cc` exists + * in `/api/pricing/models`); the latter is raw. We keep `cc/...` and + * drop `claude/...`. + * + * Built once per refresh. Cheap — O(M) where M = raw model count. + */ +export function canonicalDedupSet( + rawModels: ReadonlyArray<{ id: string }>, + canonicalToAlias: Map +): Set { + const drop = new Set(); + if (canonicalToAlias.size === 0) return drop; + // Index every alias key present in the raw catalog. + const aliasKeys = new Set(); + for (const m of rawModels) { + if (typeof m.id === "string" && m.id.length > 0) aliasKeys.add(m.id); + } + for (const m of rawModels) { + if (typeof m.id !== "string" || m.id.length === 0) continue; + const slash = m.id.indexOf("/"); + if (slash <= 0) continue; + const prefix = m.id.slice(0, slash); + const modelId = m.id.slice(slash + 1); + const alias = canonicalToAlias.get(prefix); + if (!alias || alias === prefix) continue; + // Canonical row only gets suppressed if the alias row actually + // exists — otherwise we'd hide the model entirely. + if (aliasKeys.has(`${alias}/${modelId}`)) drop.add(m.id); + } + return drop; +} + +/** + * Build a per-alias index of enrichment metadata so we can render the + * provider prefix even for raw models that don't have their own + * curated `/api/pricing/models` entry. + * + * Real example: OmniRoute's `pricing['cohere']` slot lists 10 curated + * models but `/v1/models` also returns `cohere/rerank-multilingual-v3.0` + * and `cohere/rerank-v4.0-fast` (not in the curated 10). Without this + * index, those rows surface in the picker as `cohere/...` with no + * `Cohere - ` prefix because the per-model enrichment lookup misses. + * + * This index records the first non-empty `providerDisplayName` seen + * for each alias, plus the alias itself. Callers use it to synthesize + * a minimal `OmniRouteEnrichmentEntry` whenever the direct lookup + * misses but the raw id's prefix matches a known alias. + * + * Built once per refresh; first-wins on duplicate alias (matches + * `buildCanonicalToAliasMap` semantics). + */ +export function buildAliasIndex( + enrichment: OmniRouteEnrichmentMap | undefined +): Map { + const out = new Map(); + if (!enrichment) return out; + for (const entry of enrichment.values()) { + const alias = typeof entry.providerAlias === "string" ? entry.providerAlias.trim() : ""; + if (alias.length === 0) continue; + if (out.has(alias)) { + // First-wins, but upgrade to the first entry that carries a + // non-empty providerDisplayName so the prefix renders nicely. + const existing = out.get(alias); + if ( + existing && + (!existing.providerDisplayName || existing.providerDisplayName.trim().length === 0) && + typeof entry.providerDisplayName === "string" && + entry.providerDisplayName.trim().length > 0 + ) { + out.set(alias, entry); + } + continue; + } + out.set(alias, entry); + } + return out; +} + +/** + * Resolve a synthesised enrichment entry for `applyProviderTag` / + * `shortProviderLabel` consumption, combining two sources: + * + * 1. The direct per-model enrichment match (if present). + * 2. A per-alias fallback derived from `buildAliasIndex` — covers raw + * ids whose prefix matches a known alias but the specific model + * id wasn't curated in `/api/pricing/models`. Example: + * `cohere/rerank-multilingual-v3.0` falls back to the cohere slot's + * `providerDisplayName='Cohere'` even though that specific id + * isn't in the curated 10-model list. + * + * Returns `undefined` when neither source surfaces an alias. + * + * NOTE: this function is read-only over its inputs; it never mutates + * the underlying `direct` entry. When it falls back to the alias + * index, it constructs a fresh minimal entry exposing only the + * provider-prefix fields (`providerAlias`, `providerCanonical`, + * `providerDisplayName`). Other fields (name, pricing) are explicitly + * left undefined so `applyEnrichment` won't accidentally overwrite a + * model name with the alias-slot label. + */ +export function resolveProviderTagEntry( + rawId: string, + direct: OmniRouteEnrichmentEntry | undefined, + aliasIndex: Map, + canonicalToAlias?: Map +): OmniRouteEnrichmentEntry | undefined { + if (direct) { + const alias = typeof direct.providerAlias === "string" ? direct.providerAlias.trim() : ""; + const display = + typeof direct.providerDisplayName === "string" ? direct.providerDisplayName.trim() : ""; + if (alias.length > 0 || display.length > 0) return direct; + } + const slash = rawId.indexOf("/"); + if (slash <= 0) return direct; + const prefix = rawId.slice(0, slash); + // 1. Direct alias lookup (`cohere/...` → cohere slot keyed by alias=cohere). + let fromAlias = aliasIndex.get(prefix); + // 2. Canonical fallback (`pollinations/...` → look up via alias `pol`). + if (!fromAlias && canonicalToAlias) { + const alias = canonicalToAlias.get(prefix); + if (alias) fromAlias = aliasIndex.get(alias); + } + if (!fromAlias) return direct; + // Synthesize: borrow only the provider-prefix metadata. + return { + providerAlias: fromAlias.providerAlias, + providerCanonical: fromAlias.providerCanonical, + providerDisplayName: fromAlias.providerDisplayName, + }; +} + +/** + * Fetcher contract: resolves the enrichment overlay (display names + + * pricing + free-tier budgets) from a running OmniRoute instance. + */ +/** + * Reports a source that could not be read. Enrichment stays best-effort, but + * a caller that swallows this loses display names, provider tags, canonical + * dedupe and pricing with no way to tell why. + */ +export type OmniRouteEnrichmentSourceError = (endpoint: string, reason: string) => void; + +export type OmniRouteEnrichmentFetcher = ( + baseURL: string, + apiKey: string, + timeoutMs?: number, + onSourceError?: OmniRouteEnrichmentSourceError +) => Promise; + +function trimTrailingSlashes(value: string): string { + let i = value.length; + while (i > 0 && value.charCodeAt(i - 1) === 0x2f /* "/" */) i--; + return i === value.length ? value : value.slice(0, i); +} + +/** + * 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. A third best-effort fetch attaches free-tier budgets from + * `/api/free-tier/summary`. + * + * Ported from the v1 plugin (`index.ts:1906-2106`); the shared logger is + * the only intentional difference (no plugin-contract dependency here). + */ +export const defaultOmniRouteEnrichmentFetcher: OmniRouteEnrichmentFetcher = async ( + baseURL, + apiKey, + timeoutMs = 10_000, + onSourceError +) => { + const report = (endpoint: string, reason: unknown): void => { + onSourceError?.(endpoint, reason instanceof Error ? reason.message : String(reason)); + }; + const out: OmniRouteEnrichmentMap = new Map(); + if (!baseURL || !apiKey) return out; + const root = trimTrailingSlashes(baseURL.replace(/\/v1\/?$/, "")); + const headers = { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }; + + // 1. Catalog with nice display names. + const catalogAc = new AbortController(); + const catalogTimer = setTimeout(() => catalogAc.abort(), timeoutMs); + let catalogStatus = 0; + try { + const res = await fetch(`${root}/api/pricing/models`, { + method: "GET", + headers, + signal: catalogAc.signal, + }); + catalogStatus = res.status; + 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; + const canonicalRaw = (slot as { id?: unknown }).id; + const providerCanonical = + typeof canonicalRaw === "string" && canonicalRaw.length > 0 + ? canonicalRaw + : providerAlias; + const slotNameRaw = (slot as { name?: unknown }).name; + const providerDisplayName = + typeof slotNameRaw === "string" && slotNameRaw.trim().length > 0 + ? slotNameRaw.trim() + : undefined; + 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 = { + providerAlias, + providerCanonical, + }; + if (providerDisplayName) entry.providerDisplayName = providerDisplayName; + if (typeof name === "string" && name.trim().length > 0) entry.name = name; + const namespaced = `${providerAlias}/${id}`; + if (!out.has(namespaced)) out.set(namespaced, entry); + // The bare id is a fallback for ids that arrive unnamespaced. It + // gets its OWN copy: sharing the object would let a later write + // for one provider — a price, typically — land on another + // provider's entry that happens to sell the same model id. + if (!out.has(id)) out.set(id, { ...entry }); + } + } + } + } + } catch (err) { + // Network error, timeout, abort: nothing collected from THIS source, but + // the pricing fetch below may still succeed — let it try, then decide at + // the end whether the whole overlay failed (see the throw below). + report("/api/pricing/models", err); + catalogStatus = -1; + } finally { + clearTimeout(catalogTimer); + } + if ( + catalogStatus !== 0 && + catalogStatus !== -1 && + (catalogStatus < 200 || catalogStatus >= 300) + ) { + report("/api/pricing/models", `HTTP ${catalogStatus}`); + } + + // 2. Pricing values from /api/pricing. + const priceAc = new AbortController(); + const priceTimer = setTimeout(() => priceAc.abort(), timeoutMs); + let priceStatus = 0; + try { + const res = await fetch(`${root}/api/pricing`, { + method: "GET", + headers, + signal: priceAc.signal, + }); + priceStatus = res.status; + 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 = {}; + 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); + // Only the provider that owns the bare entry may price it. + // Otherwise the second provider selling the same model id + // overwrites the first one's price, and the picker shows a cost + // that belongs to a different connection. + const bareBelongsHere = + existingBare === undefined || existingBare.providerAlias === undefined + ? true + : existingBare.providerAlias === providerAlias; + if (bareBelongsHere) { + if (existingBare) { + existingBare.pricing = { ...(existingBare.pricing ?? {}), ...parsed }; + } else { + out.set(modelId, { pricing: parsed }); + } + } + } + } + } + } + } catch (err) { + // Same as above: report, mark this source failed, let the remaining + // sources try before deciding. + report("/api/pricing", err); + priceStatus = -1; + } finally { + clearTimeout(priceTimer); + } + if (priceStatus !== 0 && priceStatus !== -1 && (priceStatus < 200 || priceStatus >= 300)) { + report("/api/pricing", `HTTP ${priceStatus}`); + } + + // 3. Free model budgets from /api/free-tier/summary (best-effort). + const freeAc = new AbortController(); + const freeTimer = setTimeout(() => freeAc.abort(), timeoutMs); + let freeStatus = 0; + try { + const res = await fetch(`${root}/api/free-tier/summary`, { + method: "GET", + headers, + signal: freeAc.signal, + }); + freeStatus = res.status; + if (res.ok) { + const body = (await res.json()) as unknown; + const perModel: unknown[] = + body && typeof body === "object" && Array.isArray((body as { perModel?: unknown }).perModel) + ? ((body as { perModel: unknown[] }).perModel as unknown[]) + : Array.isArray(body) + ? (body as unknown[]) + : []; + for (const fm of perModel) { + if (!fm || typeof fm !== "object") continue; + const fmObj = fm as Record; + const provider = typeof fmObj.provider === "string" ? fmObj.provider : ""; + const modelId = typeof fmObj.modelId === "string" ? fmObj.modelId : ""; + const freeType = typeof fmObj.freeType === "string" ? fmObj.freeType : ""; + if (!modelId || !freeType) continue; + const monthlyTokens = + typeof fmObj.monthlyTokens === "number" ? fmObj.monthlyTokens : undefined; + const creditTokens = + typeof fmObj.creditTokens === "number" ? fmObj.creditTokens : undefined; + const displayName = typeof fmObj.displayName === "string" ? fmObj.displayName : ""; + const candidates = [ + `${provider}/${modelId}`, + modelId, + ...(displayName ? [displayName] : []), + ]; + for (const key of candidates) { + const entry = out.get(key); + if (entry) { + entry.freeType = freeType as FreeModelFreeType; + if (monthlyTokens !== undefined) entry.monthlyTokens = monthlyTokens; + if (creditTokens !== undefined) entry.creditTokens = creditTokens; + break; + } + } + } + } + } catch (err) { + report("/api/free-tier/summary", err); + // Soft-fail; free metadata is optional. + } finally { + clearTimeout(freeTimer); + } + if (freeStatus !== 0 && (freeStatus < 200 || freeStatus >= 300)) { + report("/api/free-tier/summary", `HTTP ${freeStatus}`); + } + + // A source that failed contributes nothing — but the overlay keeps its own + // memory per source: names collected while the catalog endpoint answered + // survive a later pricing outage, and prices collected while pricing + // answered survive a later catalog outage. Without this a single flapping + // source wipes the other source's good data on every refresh. So a failed + // catalog source throws (the caller keeps last-known) UNLESS the pricing + // source brought something on THIS call — then whatever was collected, + // names or prices, is the gateway's answer and ships as-is. (Status alone + // cannot decide: a 2xx pricing answer with zero priced models is still an + // answer, but it carries nothing to save the overlay with.) + const sourceFailed = (status: number): boolean => + status === -1 || (status !== 0 && (status < 200 || status >= 300)); + const catalogFailed = sourceFailed(catalogStatus); + const pricingBroughtSomething = !sourceFailed(priceStatus) && out.size > 0; + if (catalogFailed && !pricingBroughtSomething) { + throw new Error( + `enrichment catalog source failed (pricing/models: ${catalogStatus}, pricing: ${priceStatus})` + ); + } + + return out; +}; + +/** + * Apply enrichment overlay onto a ModelV2 entry. Mutates and returns the + * passed entry for convenience. + */ +/** What the caller knows about the entry that the overlay itself cannot tell. */ +export interface EnrichmentDisplayContext { + /** Combos never carry a provider tag: they route across providers. */ + isCombo?: boolean; + isAutoCombo?: boolean; + /** Set false to publish the bare display name, without the provider tag. */ + providerTag?: boolean; +} + +/** + * Fold the overlay into a mapped model: display name, provider tag, free-tier + * marker and budget, and pricing. + * + * The name is built rather than copied, because the gateway ships the parts + * separately — the pricing catalog gives a display name and an upstream + * provider label, the free-tier summary gives the budget. A picker showing + * `Claude - [Free] Sonnet 4.6 · 1M/mo` tells the user which connection serves + * the model and what it costs them; `claude-sonnet-4-6` tells them nothing. + */ +export function applyEnrichment( + model: ModelV2, + enrichment: OmniRouteEnrichmentEntry | undefined, + context: EnrichmentDisplayContext = {} +): ModelV2 { + if (!enrichment) return model; + const built = buildModelDisplayName({ + rawId: model.name && model.name.length > 0 ? model.name : model.id, + enrichmentName: enrichment.name, + providerAlias: context.providerTag === false ? undefined : enrichment.providerAlias, + providerDisplayName: context.providerTag === false ? undefined : enrichment.providerDisplayName, + isFree: enrichment.freeType !== undefined, + freeType: enrichment.freeType, + monthlyTokens: enrichment.monthlyTokens, + creditTokens: enrichment.creditTokens, + isCombo: context.isCombo, + isAutoCombo: context.isAutoCombo, + }); + if (built.trim().length > 0) { + model.name = built; + } + 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; +} diff --git a/@omniroute/opencode-plugin-v2/src/shared/fingerprint.ts b/@omniroute/opencode-plugin-v2/src/shared/fingerprint.ts new file mode 100644 index 0000000000..58835956bb --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/shared/fingerprint.ts @@ -0,0 +1,127 @@ +import { createHash } from "node:crypto"; + +/** + * Fingerprint the CONTENT of a catalog snapshot (not endpoint/credential + * identity) so lazy refresh can reload-after-publish only when something + * actually changed. + * + * sha256 over sorted `id + "|" + (release_date ?? "")` lines for models + * plus sorted combo ids, joined with `\n`. Order-insensitive: two + * snapshots with the same entries in different order hash identically. + */ +export function catalogContentFingerprint( + models: { id: string; release_date?: string }[], + combos: { id: string }[], + autoCombos: { id: string }[] = [] +): string { + const modelLines = models + .map((m) => `${m.id}|${m.release_date ?? ""}`) + .sort() + .join("\n"); + const comboLines = combos + .map((c) => c.id) + .sort() + .join("\n"); + const autoLines = autoCombos + .map((c) => c.id) + .sort() + .join("\n"); + return createHash("sha256").update(`${modelLines}\n${comboLines}\n${autoLines}`).digest("hex"); +} + +/** + * Digest of the optional tier (auto-combos, provider connections, enrichment). + * The catalog fingerprint covers model and combo ids only, so an overlay that + * moves — a renamed model, a provider going unusable — leaves it unchanged. + * Reloading on every refresh instead would ask the host to rebuild its catalog + * once per TTL window for nothing. + */ +export function optionalTierFingerprint( + autoCombos: { id: string }[], + providers: { + id?: string; + name?: string; + testStatus?: string; + isActive?: boolean; + providerDisplayName?: string; + }[], + enrichment: + | Map< + string, + { + name?: string; + freeType?: string; + providerDisplayName?: string; + monthlyTokens?: number; + creditTokens?: number; + pricing?: Record; + } + > + | undefined, + combos: { id: string; name?: string; models?: unknown[] }[] = [] +): string { + const parts: string[] = []; + // Membership matters: a combo keeping its id while losing a member is a + // different combo to anyone picking it. + parts.push( + combos + .map((c) => c.id + "|" + (c.name ?? "") + "|" + String(c.models?.length ?? 0)) + .sort() + .join(",") + ); + parts.push( + autoCombos + .map((c) => c.id) + .sort() + .join(",") + ); + // A provider going quiet or getting renamed is as visible to the user as a + // price move: its activity flag and display name belong in the digest. + parts.push( + providers + .map( + (p) => + (p.id ?? p.name ?? "") + + ":" + + (p.testStatus ?? "") + + ":" + + String(p.isActive ?? "") + + ":" + + (p.providerDisplayName ?? "") + ) + .sort() + .join(",") + ); + if (enrichment !== undefined) { + const rows: string[] = []; + for (const [key, entry] of enrichment) { + // Pricing is part of what the user sees, so a price move must reach + // the picker without waiting for an id to change. + const price = entry.pricing + ? Object.entries(entry.pricing) + .map(([k, v]) => k + "=" + String(v ?? "")) + .sort() + .join(";") + : ""; + rows.push( + key + + "|" + + (entry.name ?? "") + + "|" + + (entry.freeType ?? "") + + "|" + + (entry.providerDisplayName ?? "") + + "|" + + String(entry.monthlyTokens ?? "") + + ";" + + String(entry.creditTokens ?? "") + + "|" + + price + ); + } + rows.sort(); + parts.push(String(enrichment.size)); + parts.push(rows.join("\n")); + } + return createHash("sha256").update(parts.join(" ")).digest("hex"); +} diff --git a/@omniroute/opencode-plugin-v2/src/shared/gemini.ts b/@omniroute/opencode-plugin-v2/src/shared/gemini.ts new file mode 100644 index 0000000000..2fb8b42c71 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/shared/gemini.ts @@ -0,0 +1,166 @@ +/** + * Gemini rejects several standard JSON-Schema keywords in tool declarations + * and answers `400 INVALID_ARGUMENT` for the whole request when it meets one. + * The keywords carry no meaning Gemini would honour anyway, so stripping them + * costs nothing and is what keeps a tool-calling chain alive. + */ +/** + * Keywords Gemini rejects outright. `$ref` is deliberately NOT here: it + * cannot be stripped without turning the schema into "accept anything", so + * tools carrying one are forwarded untouched (see below). `ref` is not a + * JSON Schema keyword at all, and stripping it by name destroys a legitimate + * tool parameter called `ref` — a walker that cannot tell a keyword from a + * property name mangles the schema it was meant to repair. + */ +const REJECTED_KEYWORDS = new Set(["$schema", "additionalProperties"]); + +/** Keys whose value is itself a schema. */ +const SCHEMA_VALUE_KEYS = [ + "items", + "additionalItems", + "contains", + "not", + "if", + "then", + "else", + "propertyNames", + "contentSchema", + "unevaluatedItems", + "unevaluatedProperties", +]; +/** Keys whose value maps arbitrary NAMES to schemas — never keyword space. */ +const SCHEMA_MAP_KEYS = [ + "properties", + "patternProperties", + "$defs", + "definitions", + "dependentSchemas", + "dependencies", +]; +/** Keys whose value is a list of schemas. */ +const SCHEMA_LIST_KEYS = ["allOf", "anyOf", "oneOf", "prefixItems"]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** True when any schema in the tree carries a `$ref` we cannot resolve. */ +function hasUnresolvableRef(node: unknown): boolean { + if (Array.isArray(node)) return node.some(hasUnresolvableRef); + if (!isRecord(node)) return false; + if ("$ref" in node) return true; + for (const key of SCHEMA_VALUE_KEYS) if (hasUnresolvableRef(node[key])) return true; + // (arrays are handled by the Array branch at the top of this function) + for (const key of SCHEMA_LIST_KEYS) if (hasUnresolvableRef(node[key])) return true; + for (const key of SCHEMA_MAP_KEYS) { + const map = node[key]; + if (isRecord(map) && Object.values(map).some(hasUnresolvableRef)) return true; + } + return false; +} + +/** + * Strip the rejected keywords in place, walking only the positions where a + * schema can appear. Property names are never treated as keywords, so a tool + * whose parameter happens to be called `additionalProperties` keeps it. + * Returns whether anything was removed. + */ +function stripAtSchemaPositions(node: Record): boolean { + let changed = false; + for (const keyword of REJECTED_KEYWORDS) { + if (keyword in node) { + delete node[keyword]; + changed = true; + } + } + for (const key of SCHEMA_VALUE_KEYS) { + const child = node[key]; + if (isRecord(child)) { + changed = stripAtSchemaPositions(child) || changed; + continue; + } + // `items` also takes the tuple form: an array of schemas, one per position. + if (Array.isArray(child)) { + for (const item of child) { + if (isRecord(item)) changed = stripAtSchemaPositions(item) || changed; + } + } + } + for (const key of SCHEMA_LIST_KEYS) { + const list = node[key]; + if (Array.isArray(list)) { + for (const child of list) { + if (isRecord(child)) changed = stripAtSchemaPositions(child) || changed; + } + } + } + for (const key of SCHEMA_MAP_KEYS) { + const map = node[key]; + if (!isRecord(map)) continue; + for (const child of Object.values(map)) { + if (isRecord(child)) changed = stripAtSchemaPositions(child) || changed; + } + } + return changed; +} + +/** + * Families Google actually ships, anchored on the last path segment. A plain + * substring test also claims `gemini-compatible-proxy` and `my-gemini-wrapper` + * — and since the sanitiser removes keywords, a false positive is not free. + */ +const GEMINI_MODEL_ID = + /^gemini(?:[-_.](?:\d|pro|flash|ultra|nano|exp|thinking|embedding|live|imagen)|$)/i; + +/** + * True for the routing forms a Gemini model reaches a gateway under — bare + * (`gemini-2.5-flash`), canonical (`models/gemini-1.5-pro`) and prefixed + * (`google-vertex/gemini-2.0`). + */ +export function isGeminiModelId(modelId: unknown): boolean { + if (typeof modelId !== "string") return false; + const segment = modelId.split("/").pop() ?? ""; + return GEMINI_MODEL_ID.test(segment); +} + +/** The subset of an AI SDK tool declaration this module reads. */ +export interface ToolWithInputSchema { + readonly type?: string; + readonly inputSchema?: unknown; + readonly [key: string]: unknown; +} + +/** + * Return a copy of `tools` whose input schemas are free of the keywords Gemini + * rejects, or `undefined` when there was nothing to strip — which lets the + * caller forward the original array and skip the clone entirely. + * + * Tools this module cannot read (provider-defined tools, entries without an + * object schema) are carried through unchanged rather than dropped: a tool the + * sanitiser does not understand is still a tool the model needs. + */ +export function sanitizeToolInputSchemas( + tools: readonly T[] | undefined +): T[] | undefined { + if (tools === undefined || tools.length === 0) return undefined; + let changed = false; + const out = tools.map((tool) => { + if (!isRecord(tool.inputSchema)) return tool; + // A schema carrying something uncloneable is not worth failing a request + // over: forward the tool untouched and let the model answer. + // A `$ref` cannot be stripped without turning the schema into "anything + // goes", and cannot be resolved here. Forward the tool untouched and let + // the gateway answer rather than silently widen what the model may send. + if (hasUnresolvableRef(tool.inputSchema)) return tool; + let schema: Record; + try { + schema = structuredClone(tool.inputSchema) as Record; + } catch { + return tool; + } + if (!stripAtSchemaPositions(schema)) return tool; + changed = true; + return { ...tool, inputSchema: schema }; + }); + return changed ? out : undefined; +} diff --git a/@omniroute/opencode-plugin-v2/src/shared/index.ts b/@omniroute/opencode-plugin-v2/src/shared/index.ts new file mode 100644 index 0000000000..d65d093abb --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/shared/index.ts @@ -0,0 +1,9 @@ +export * from "./models-map.js"; +export * from "./combos-map.js"; +export * from "./auto-combos.js"; +export * from "./naming.js"; +export * from "./enrich.js"; +export * from "./fingerprint.js"; +export * from "./logger.js"; +export * from "./usable.js"; +export * from "./gemini.js"; diff --git a/@omniroute/opencode-plugin-v2/src/shared/logger.ts b/@omniroute/opencode-plugin-v2/src/shared/logger.ts new file mode 100644 index 0000000000..2439ddd34c --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/shared/logger.ts @@ -0,0 +1,81 @@ +/** + * Namespaced leveled logger shared by the OmniRoute OpenCode packages. + * + * Levels: error < warn < info < debug. Default: warn. + * Ported from the v1 plugin (`logger.ts`) so both new packages share one + * sink instead of raw `console.warn` / `console.log` calls. + */ + +export type LogLevel = "error" | "warn" | "info" | "debug"; + +const LEVEL_ORDER: Record = { + error: 0, + warn: 1, + info: 2, + debug: 3, +}; + +const TAG = "[omniroute-plugin]"; + +function shouldLog(current: LogLevel, target: LogLevel): boolean { + return LEVEL_ORDER[current] >= LEVEL_ORDER[target]; +} + +let _level: LogLevel = "warn"; + +export function setLogLevel(level: LogLevel): void { + _level = level; +} + +export function getLogLevel(): LogLevel { + return _level; +} + +function fmt(level: LogLevel, msg: string, tag?: string): string { + const prefix = tag ? `${TAG}${tag}` : TAG; + return `${prefix} [${level.toUpperCase()}] ${msg}`; +} + +function buildLogger(getLevel: () => LogLevel) { + return { + error(msg: string, ...args: unknown[]): void { + if (shouldLog(getLevel(), "error")) console.error(fmt("error", msg), ...args); + }, + warn(msg: string, ...args: unknown[]): void { + if (shouldLog(getLevel(), "warn")) console.warn(fmt("warn", msg), ...args); + }, + info(msg: string, ...args: unknown[]): void { + if (shouldLog(getLevel(), "info")) console.warn(fmt("info", msg), ...args); + }, + debug(msg: string, ...args: unknown[]): void { + if (shouldLog(getLevel(), "debug")) console.warn(fmt("debug", msg), ...args); + }, + /** Always emit regardless of level (for critical init breadcrumbs). */ + always(msg: string, ...args: unknown[]): void { + console.warn(TAG, msg, ...args); + }, + + child(tag: string) { + return { + error: (msg: string, ...args: unknown[]) => + shouldLog(getLevel(), "error") && console.error(fmt("error", msg, tag), ...args), + warn: (msg: string, ...args: unknown[]) => + shouldLog(getLevel(), "warn") && console.warn(fmt("warn", msg, tag), ...args), + info: (msg: string, ...args: unknown[]) => + shouldLog(getLevel(), "info") && console.warn(fmt("info", msg, tag), ...args), + debug: (msg: string, ...args: unknown[]) => + shouldLog(getLevel(), "debug") && console.warn(fmt("debug", msg, tag), ...args), + }; + }, + }; +} + +export type Logger = ReturnType; + +/** Create an instance-scoped logger whose level cannot be changed by other instances. */ +export function createLogger(level: LogLevel): Logger { + return buildLogger(() => level); +} + +/** Backward-compatible module-global logger controlled by setLogLevel(). */ +export const logger: Logger = buildLogger(() => _level); diff --git a/@omniroute/opencode-plugin-v2/src/shared/models-map.ts b/@omniroute/opencode-plugin-v2/src/shared/models-map.ts new file mode 100644 index 0000000000..625e02f232 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/shared/models-map.ts @@ -0,0 +1,323 @@ +import type { Model as ModelV2 } from "@opencode-ai/sdk/v2"; +import { normaliseFreeLabel } from "./naming.js"; + +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; + /** Runtime-learned or synced reasoning tiers (server-gated, blind-mapped). */ + effort_tiers?: string[]; + }; + 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 that drops the `capabilities` / `*_modalities` / + * `max_*_tokens` blocks the mapping needs for ModelV2 pass-through. + */ +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-v2] apiKey required to fetch /v1/models"); + if (!baseURL) throw new Error("[omniroute-v2] baseURL required to fetch /v1/models"); + + const trimmed = trimTrailingSlashes(baseURL); + // 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-v2] 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); + } +}; + +// Manual trim helpers avoid polynomial-regex CodeQL warnings on +// user-supplied baseURL strings (string.replace(/\/+$/, "")). The same +// behaviour, no backtracking. +function trimTrailingSlashes(value: string): string { + let i = value.length; + while (i > 0 && value.charCodeAt(i - 1) === 0x2f /* "/" */) i--; + return i === value.length ? value : value.slice(0, i); +} + +/** + * Ensure a baseURL ends with `/v1` so the OpenAI-compat SDK constructs + * `/v1/chat/completions` correctly. The Anthropic SDK does NOT want `/v1` + * (it appends `/v1/messages` automatically), so callers should branch on + * format first. + */ +export function ensureV1Suffix(url: string): string { + const trimmed = trimTrailingSlashes(url); + return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`; +} + +export interface ApiFormatV2 { + allowAnthropic?: boolean; + anthropicModels?: string[]; + /** + * Deprecated v1 prefix list (default v1: + * `cc,claude,anthropic,kiro,kr`). Accepted for backward compatibility: + * prefix OR allowlist routes to anthropic, with a one-time deprecation + * warning pointing at `anthropicModels`. Prefer full IDs. + */ + anthropicPrefixes?: string[]; +} + +/** Default v1 prefix list, kept so copied v1 configs keep routing. */ +export const DEFAULT_ANTHROPIC_PREFIXES_V1 = ["cc", "claude", "anthropic", "kiro", "kr"]; + +const warnedPrefixLists = new Set(); + +function warnDeprecatedPrefixesOnce(prefixes: string[]): void { + const key = [...prefixes].sort().join(","); + if (warnedPrefixLists.has(key)) return; + warnedPrefixLists.add(key); + console.warn( + "[omniroute-plugin] [WARN] apiFormat.anthropicPrefixes is deprecated; convert to anthropicModels (full IDs)" + ); +} + +/** + * The Anthropic SDK block appends `/v1/messages` itself, so it needs the + * gateway root. A config carrying the `/v1` the OpenAI-compatible block wants + * would otherwise produce `/v1/v1/messages`. + */ +function stripV1Suffix(baseURL: string): string { + return baseURL.replace(/\/v1\/?$/, ""); +} + +/** + * Resolve the API block (id + url + npm package) for a given model id. + * + * v2 rule: a model routes to the Anthropic SDK block when + * `apiFormat.allowAnthropic === true` AND (its FULL id is allowlisted in + * `apiFormat.anthropicModels` OR its prefix is listed in the deprecated + * `apiFormat.anthropicPrefixes`, defaulting to the v1 list when prefixes + * are absent). The deprecated path warns once per prefix list. With + * neither allowlist nor prefix match, the model stays openai-compatible. + */ +export function resolveApiBlockV2( + modelId: string, + baseURL: string, + apiFormat?: ApiFormatV2 +): { id: string; url: string; npm: string } { + if (apiFormat?.allowAnthropic === true) { + if ((apiFormat.anthropicModels ?? []).includes(modelId)) { + return { + id: "anthropic", + url: stripV1Suffix(trimTrailingSlashes(baseURL)), + npm: "@ai-sdk/anthropic", + }; + } + const prefixes = apiFormat.anthropicPrefixes ?? DEFAULT_ANTHROPIC_PREFIXES_V1; + if (apiFormat.anthropicPrefixes !== undefined) warnDeprecatedPrefixesOnce(prefixes); + const slash = modelId.indexOf("/"); + const prefix = slash === -1 ? modelId : modelId.slice(0, slash); + if (prefixes.includes(prefix)) { + return { + id: "anthropic", + url: stripV1Suffix(trimTrailingSlashes(baseURL)), + npm: "@ai-sdk/anthropic", + }; + } + } + return { + id: "openai-compatible", + url: ensureV1Suffix(baseURL), + npm: "@ai-sdk/openai-compatible", + }; +} + +/** + * 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 requires a much richer shape than a flat record. 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` + * + * Field adaptations: + * 1. 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 opencode reads this for display only — the live pricing + * is OmniRoute's responsibility at routing time. + * 3. `tool_call` → `toolcall` (ModelV2 field name; one word). + * 4. `attachment` maps from `capabilities.vision` per OmniRoute + * convention: vision = ability to receive image attachments. If the + * raw entry happens to expose an explicit `capabilities.attachment`, + * 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. + * `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 opencode 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 opencode 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; apiFormat?: ApiFormatV2 } +): ModelV2 { + const caps = raw.capabilities ?? {}; + // effort_tiers loop: server-declared tiers become ModelV2 variants so the + // UI offers exactly the tiers OmniRoute vouches for (instead of opencode's + // invented [low, medium, high] fallback). Blind: filtering/exclusion rules + // live server-side. Absent/empty/malformed => key omitted ENTIRELY (an + // empty variants object would suppress opencode's fallback for this model). + const declaredTiers = Array.isArray(caps.effort_tiers) + ? caps.effort_tiers.filter((t): t is string => typeof t === "string" && t.length > 0) + : []; + const variants = + declaredTiers.length > 0 + ? Object.fromEntries(declaredTiers.map((tier) => [tier, { reasoningEffort: tier }])) + : undefined; + const inMods = new Set(raw.input_modalities ?? ["text"]); + const outMods = new Set(raw.output_modalities ?? ["text"]); + + return { + // OC's static-catalog reader parses the key on `/` to recover + // `(providerID, modelID)`. If the raw id is already provider-prefixed + // (e.g. `cc/claude-opus-4-7` from the `cc` Claude Code alias, or + // `nvidia/llama-3-70b` from a provider that ships prefixed ids), leave + // it as-is — double-prefixing breaks OC's lookup. Bare **combo** ids + // (`owned_by: "combo"`, e.g. `gpt-5.6-sol`) must also stay unprefixed: + // OpenCode looks up `-m /` as model id `` under + // the plugin provider. Other bare ids still prefix with + // `providerId` so credentials resolve as `(omniroute, model)`. + id: raw.id.includes("/") || raw.owned_by === "combo" ? raw.id : `${ctx.providerId}/${raw.id}`, + /** + * Display name. Falls back to raw.id when no enrichment is available; + * the caller overlays `/api/pricing/models` data via enrichment when + * the enrichment feature is enabled. + */ + name: normaliseFreeLabel(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: Boolean(caps.thinking), + }, + 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, + }, + ...(variants ? { variants } : {}), + status: "active", + options: {}, + headers: {}, + release_date: raw.release_date ?? "", + providerID: ctx.providerId, + api: resolveApiBlockV2(raw.id, ctx.baseURL, ctx.apiFormat), + }; +} diff --git a/@omniroute/opencode-plugin-v2/src/shared/naming.ts b/@omniroute/opencode-plugin-v2/src/shared/naming.ts new file mode 100644 index 0000000000..823809cf20 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/src/shared/naming.ts @@ -0,0 +1,295 @@ +/** + * Universal model naming template for the OmniRoute plugin. + * + * Naming pipeline: + * [tag] + * + * [Free] - · ← free model + * Auto: (p) ← auto combo + * Combo: ← DB combo + * - ← regular model + */ + +// ── Constants ──────────────────────────────────────────────────────────── + +/** Separator between provider label and model display name. */ +export const PROVIDER_TAG_SEPARATOR = " - "; + +/** Threshold beyond which providerDisplayName is abbreviated. */ +const PROVIDER_LABEL_MAX_CHARS = 12; + +/** Aliases longer than this get title-case instead of UPPER. */ +const ALIAS_UPPER_MAX_CHARS = 5; + +// ── Auto Combo Types ───────────────────────────────────────────────────── + +export type AutoVariant = "coding" | "fast" | "cheap" | "offline" | "smart" | "lkgp"; + +export const AUTO_VARIANTS: AutoVariant[] = ["coding", "fast", "cheap", "offline", "smart", "lkgp"]; + +export const AUTO_VARIANT_DESCRIPTIONS: Record = { + default: "Best provider via scoring", + coding: "Quality-first for code tasks", + fast: "Latency-optimized routing", + cheap: "Cost-optimized routing", + offline: "Offline-friendly providers", + smart: "Quality-first with exploration", + lkgp: "Last-Known-Good-Provider routing", +}; + +// ── Free Model Types ───────────────────────────────────────────────────── + +export type FreeModelFreeType = + | "recurring-daily" + | "recurring-monthly" + | "recurring-credit" + | "one-time-initial" + | "keyless" + | "discontinued"; + +// ── Provider Label ──────────────────────────────────────────────────────── + +/** + * Title-case a long, lowercase-looking alias. + * `antigravity` → `Antigravity` + */ +function titleCaseAlias(alias: string): string { + if (alias.length === 0) return alias; + return alias.charAt(0).toUpperCase() + alias.slice(1).toLowerCase(); +} + +/** + * Pick the short label for an upstream provider. + * + * Rules: + * 1. Trim `providerDisplayName`. If ≤12 chars → use verbatim. + * 2. Alias ≤5 chars → UPPER(alias). Alias >5 → titleCase. + * 3. Neither → undefined. + */ +export function shortProviderLabel( + enrichment: { providerDisplayName?: string; providerAlias?: string } | undefined +): string | undefined { + if (!enrichment) return undefined; + const raw = + typeof enrichment.providerDisplayName === "string" ? enrichment.providerDisplayName.trim() : ""; + if (raw.length > 0 && raw.length <= PROVIDER_LABEL_MAX_CHARS) return raw; + const alias = typeof enrichment.providerAlias === "string" ? enrichment.providerAlias.trim() : ""; + if (alias.length > 0) { + return alias.length <= ALIAS_UPPER_MAX_CHARS ? alias.toUpperCase() : titleCaseAlias(alias); + } + // Long displayName with no alias to fall back on: keep the long label + // rather than dropping the provider prefix entirely. + return raw.length > 0 ? raw : undefined; +} + +// ── Free Label ──────────────────────────────────────────────────────────── + +/** + * Normalise display name so free-tier models get a consistent `[Free] ` prefix. + * + * "GPT-4.1 (Free)" → "[Free] GPT-4.1" + * "DeepSeek V4 Flash Free" → "[Free] DeepSeek V4 Flash" + * "Claude Opus 4.7" → "Claude Opus 4.7" (unchanged) + */ +export function normaliseFreeLabel(name: string): string { + // Bounded whitespace quantifiers ({0,8}/{1,8}) avoid the polynomial-ReDoS + // backtracking that unbounded \s* before an anchored \s*$ would allow on + // attacker-influenced display names. 8 covers any realistic label spacing. + const cleaned = name + .replace(/\s{0,8}\(free\)\s{0,8}$/i, "") + .replace(/[\s-]{1,8}free\s{0,8}$/i, "") + .trim(); + const wasFree = cleaned.length < name.trim().length; + if (!wasFree) return name; + return `[Free] ${cleaned}`; +} + +// ── Free Budget Formatting ──────────────────────────────────────────────── + +/** Scales, largest first, so the unit is chosen by descending magnitude. */ +const TOKEN_UNITS = [ + [1e9, "B"], + [1e6, "M"], + [1e3, "K"], +] as const; + +/** + * Format a token count as a short magnitude string: `25M`, `1.5K`, `999`. + * + * The unit has to be picked from the value that will actually be *printed*, + * not from the raw input. `toFixed(1)` rounds to the nearest tenth, so at the + * K scale 999_950 and above render as `1000.0` — and by then the M branch has + * already been skipped, producing `1000K` for a number that is `1M`. The same + * carry turns just under a billion into `1000M`. When the rounded value reaches + * the next scale, re-render at that scale instead. + */ +function fmtTokens(n: number): string { + for (let i = 0; i < TOKEN_UNITS.length; i++) { + const [scale, suffix] = TOKEN_UNITS[i]!; + if (n < scale) continue; + const value = Number((n / scale).toFixed(1)); + // `Number()` also drops a trailing `.0`, which the previous regex did. + if (value < 1000 || i === 0) return `${value}${suffix}`; + const [nextScale, nextSuffix] = TOKEN_UNITS[i - 1]!; + return `${Number((n / nextScale).toFixed(1))}${nextSuffix}`; + } + return String(n); +} + +/** + * Format a free model budget into a short human-readable suffix. + * + * recurring-daily → "25M tokens/day" + * recurring-monthly → "25M tokens/month" + * recurring-credit → "10M credits" + * one-time-initial → "1M credits (one-time)" + * keyless → "(keyless)" + * discontinued → "(discontinued)" + */ +export function formatFreeBudget(params: { + freeType: FreeModelFreeType; + monthlyTokens?: number; + creditTokens?: number; +}): string { + const { freeType, monthlyTokens = 0, creditTokens = 0 } = params; + + switch (freeType) { + case "recurring-daily": + return `${fmtTokens(monthlyTokens)} tokens/day`; + case "recurring-monthly": + return `${fmtTokens(monthlyTokens)} tokens/month`; + case "recurring-credit": + return `${fmtTokens(creditTokens)} credits`; + case "one-time-initial": + return `${fmtTokens(creditTokens)} credits (one-time)`; + case "keyless": + return "(keyless)"; + case "discontinued": + return "(discontinued)"; + default: + return ""; + } +} + +// ── Auto Combo Naming ───────────────────────────────────────────────────── + +/** + * Format auto combo display name. + * + * "Auto: Coding (4p)" + * "Auto: Default (6p)" + * "Auto" (no candidate count when unknown) + */ +export function formatAutoComboName( + variant: AutoVariant | undefined, + candidateCount?: number +): string { + const label = variant ? variant.charAt(0).toUpperCase() + variant.slice(1) : "Default"; + const count = + typeof candidateCount === "number" && candidateCount > 0 ? ` (${candidateCount}p)` : ""; + return `Auto: ${label}${count}`; +} + +/** + * Build the model ID for an auto combo entry. + * "auto/coding", "auto/fast", "auto" (default). + */ +export function autoComboModelId(variant: AutoVariant | undefined): string { + return variant ? `auto/${variant}` : "auto"; +} + +// ── Universal Display Name Builder ──────────────────────────────────────── + +export interface ModelDisplayNameParams { + /** Raw model ID (e.g. "cc/claude-sonnet-4-6"). */ + rawId: string; + /** Enrichment display name (e.g. "Claude Sonnet 4.6"). */ + enrichmentName?: string; + /** Provider tag enrichment. */ + providerAlias?: string; + /** Human-readable upstream provider label. */ + providerDisplayName?: string; + /** Whether model is free tier. */ + isFree?: boolean; + /** Free model budget info. */ + freeType?: FreeModelFreeType; + /** Monthly token budget (for recurring free models). */ + monthlyTokens?: number; + /** Credit token budget (for credit-based free models). */ + creditTokens?: number; + /** Whether this is a combo entry (skip provider tag). */ + isCombo?: boolean; + /** Whether this is an auto combo entry. */ + isAutoCombo?: boolean; + /** Auto combo variant. */ + autoVariant?: AutoVariant; + /** Auto combo candidate count. */ + autoCandidateCount?: number; +} + +/** + * Build the final display name following the universal template. + * + * Priority: + * 1. Auto combo → "Auto: (p)" + * 2. DB combo → "Combo: " + * 3. Free + enrichment + provider tag → "[Free]