diff --git a/.env.example b/.env.example index 187a1049d3..67097b7104 100644 --- a/.env.example +++ b/.env.example @@ -55,10 +55,11 @@ INITIAL_PASSWORD=CHANGEME # loader (bin/cli/plugins.mjs) at a package tree — this one drives the server-side scanner. # OMNIROUTE_PLUGINS_DIR=/opt/omniroute/plugins -# Escape hatch for the test-context DATA_DIR guard (#10428). A test run that never -# chose a DATA_DIR is redirected to a throwaway temp dir so it cannot open the -# operator's real database. Set to 1 only for a deliberate run against the real -# DATA_DIR — never for CI. Used by: src/lib/dataPaths.ts +# Escape hatch for the test/eval DATA_DIR guard (#10428). A test or node eval/print +# probe (-e/--eval/-p/--print, including --eval=/--print=) that never chose a DATA_DIR +# is redirected to a throwaway temp dir so it cannot open the operator's real database. +# Set to 1 only for a deliberate run against the real DATA_DIR — never for CI. +# Used by: src/lib/dataPaths.ts # OMNIROUTE_ALLOW_DEFAULT_DATA_DIR=1 # Build provenance (#10427). OMNIROUTE_BUILD_SHA lets a container inject the artifact's git @@ -96,9 +97,11 @@ STORAGE_ENCRYPTION_KEY= # Default: v1 | Increment when rotating STORAGE_ENCRYPTION_KEY. STORAGE_ENCRYPTION_KEY_VERSION=v1 -# Automatic SQLite backup on startup. -# Used by: src/lib/db/backup.ts — creates a timestamped backup before migrations. -# Default: false (backups enabled) | Set true to skip backup on every restart. +# Routine/pre-write SQLite backups. +# Used by: src/lib/db/backup.ts. Set true only when those backups are managed externally. +# This never disables the migration runner's mandatory, content-addressed safety snapshot +# or its mass-migration guard for an existing persistent database. +# Default: false (routine backups enabled). DISABLE_SQLITE_AUTO_BACKUP=false # ── Redis (Rate Limiting) ── @@ -415,7 +418,9 @@ ALLOW_API_KEY_REVEAL=false # provider dispatch. Heavyweight capacity is reserved before parsing; excess work # receives 503 + Retry-After instead of overlapping until the process OOMs. # Used by: src/shared/middleware/chatBodyAdmission.ts -# Actual bodies at or above this size require a heavyweight lease. Default 262144 (256 KB). +# Actual bodies at or above this size take the heavyweight lease (BYTE path, +# including POST /v1/responses) and use the same #10437 healthy-headroom escape +# as structure-heavy. Default 262144 (256 KB). # OMNIROUTE_CHAT_LARGE_BODY_BYTES=262144 # Actual-byte hard cap enforced during bounded ingestion. Default 52428800 (50 MB). # OMNIROUTE_CHAT_HARD_MAX_BODY_BYTES=52428800 @@ -423,6 +428,11 @@ ALLOW_API_KEY_REVEAL=false # left unset, heavyweight admission is gated by OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES below # instead (an auto-derived byte budget), fixing coding-agent fan-out (multiple # subagents/CLIs) collapsing to an effective concurrency of ~1 and 503ing. +# Two overlapping ~750k-token /v1/responses abort ~12 Gi heaps (#7849) — a +# memory-budget warning, not a hard product max of 2. A healthy heap may admit +# more via HEALTHY_HEADROOM. Tens of long SSE clients (40-50) is heap + +# OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES / #10110. Multiply heaps with N independent +# DATA_DIRs (#11024); never replicas>1 on one SQLite. # OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT=1 # Override for the auto-derived ingest byte budget (#503-fanout). Default: 25% of the # process's effective memory ceiling (V8 heap limit, or the tighter cgroup/container @@ -430,13 +440,15 @@ ALLOW_API_KEY_REVEAL=false # 2 GiB; explicit overrides are clamped to the same safe range. Read # chatAdmission.maxInflightBytes/budgetSource at /api/monitoring/health before overriding. # OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES=134217728 -# Heap-pressure shed ratio (heapUsed/heap_size_limit) for the structural admission gate -# (#10183, #10268): a second concurrent heavyweight request past OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT -# is only shed with a retryable 503 when the heap is ALSO under this much pressure — on a -# healthy heap it is admitted instead. Range (0, 1]. Default 0.75. +# Heap-pressure shed ratio (heapUsed/heap_size_limit) for BYTE and STRUCTURE +# heavyweight admission (#10183, #10268, #10437): a concurrent heavyweight request +# past OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT is only shed with a retryable 503 when the +# heap is ALSO under this much pressure — on a healthy heap it is admitted via +# healthy-headroom instead. Range (0, 1]. Default 0.75. # OMNIROUTE_CHAT_ADMISSION_HEAP_SHED_RATIO=0.75 # Bounded extra capacity for the healthy-heap fast path above OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT -# (#10437): once this many concurrent leases are active through the healthy-heap bypass, +# (#10437, BYTE + STRUCTURE, including bodies >= OMNIROUTE_CHAT_LARGE_BODY_BYTES): +# once this many concurrent leases are active through the healthy-heap bypass, # further busy requests fall through to the same bounded-wait/shed path used under real heap # pressure. 0 disables the bypass entirely. Default 1. # OMNIROUTE_CHAT_ADMISSION_HEALTHY_HEADROOM=1 @@ -869,6 +881,12 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # or <= 0 falls back to the default. # OMNIROUTE_A2A_HISTORY_RETENTION_DAYS=30 +# Kill-switch for the A2A memory-hits observability feature (Orchestration Canvas +# Fase 2). Set to "0" to skip the memory recall lookup entirely; any other value +# (including unset) keeps it enabled. +# Used by: src/lib/a2a/taskExecution.ts (collectMemoryHits). +# OMNIROUTE_A2A_MEMORY_HITS=1 + # Enable the offline/local Issue Agent recorded-triage endpoint. # Used by: src/app/api/issue-agent/runs/route.ts. Default: disabled. # OMNIROUTE_ISSUE_AGENT_ENABLED=false @@ -1284,7 +1302,7 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98 # Used by: open-sse/executors/base.ts — buildHeaders() dynamic lookup. # Update these when providers release new CLI versions to avoid blocks. -CLAUDE_USER_AGENT="claude-cli/2.1.219 (external, cli)" +CLAUDE_USER_AGENT="claude-cli/2.1.258 (external, cli)" # Disable the deterministic tool-name cloak applied on both Anthropic-bound paths # (executors/base.ts native OAuth + executors/cliproxyapi.ts CLIProxyAPI) — @@ -1314,6 +1332,16 @@ CURSOR_USER_AGENT="Cursor/3.4" # Override Codex client version sent in headers independently of the # CODEX_USER_AGENT string. Used by: open-sse/config/codexClient.ts. # CODEX_CLIENT_VERSION=0.144.1 +# +# Override the advertised Claude Code client version independently of +# CLAUDE_USER_AGENT. Anthropic gates some models (Fable 5.1) on this +# value; a UA-only override is not enough (#12417). Used by: +# src/shared/constants/claudeCodeClient.ts. +# CLAUDE_CODE_CLIENT_VERSION=2.1.259 +# +# Override the advertised GitHub Copilot CLI version independently of +# GITHUB_USER_AGENT. Used by: open-sse/config/providerHeaderProfiles.ts. +# GITHUB_COPILOT_CLI_VERSION=1.0.82 # Kill-switch to strip non-standard `codex.*` SSE events (e.g. codex.rate_limits) # from the Codex Responses stream. These frames break the OpenAI SDK's @@ -1518,6 +1546,15 @@ CURSOR_USER_AGENT="Cursor/3.4" # request into the browser-backed path. # OMNIROUTE_BROWSER_POOL=on # WEB_COOKIE_USE_BROWSER=0 +# Obscura (https://github.com/h4ckf0r0day/obscura) is the primary headless +# engine: a lightweight CDP server the pool and cloudflare-playground connect +# to before falling back to Chromium. Unset OBSCURA_BIN to auto-detect from +# PATH; set OBSCURA_CDP_ENDPOINT to reuse an already-running Obscura instead +# of spawning one; set OBSCURA_PORT to pin the spawned serve port. +# Used by: open-sse/services/obscura.ts +# OBSCURA_BIN= +# OBSCURA_CDP_ENDPOINT= +# OBSCURA_PORT= # ── Kimi Web (international kimi.ai Connect-RPC) ── # Used by: open-sse/executors/kimi-web.ts. Override the base/chat URLs only if @@ -1896,6 +1933,11 @@ APP_LOG_TO_FILE=true # Default: true # MODEL_CATALOG_INCLUDE_NAMES=true +# Cold-path wait bound for a coalesced GET /v1/models catalog rebuild (#12627). +# Used by: src/app/api/v1/models/catalogCache.ts +# Default: 8000 (8 seconds). On timeout, a last-good 200 is served when available. +# CATALOG_BUILD_TIMEOUT_MS=8000 + # ── NanoBanana (Image Generation) ── # Polling config for async image generation jobs. # Used by: open-sse/handlers/imageGeneration.ts diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6adc2d6669..4ba95c5652 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -22,10 +22,10 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + - uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: languages: javascript-typescript queries: security-extended - - uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: category: "/language:javascript-typescript" diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 917876b361..77e722b318 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -535,7 +535,7 @@ jobs: - name: Upload Trivy SARIF to Security tab if: needs.prepare.outputs.version != 'main' continue-on-error: true - uses: github/codeql-action/upload-sarif@v4.37.8 + uses: github/codeql-action/upload-sarif@v4.37.9 with: sarif_file: trivy-results.sarif category: trivy-image 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/.husky/pre-commit b/.husky/pre-commit index ec14ffcd28..268c9b5f7d 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -7,6 +7,7 @@ fi # Cheap, deterministic local gates (re-enabled). Slower checks (i18n drift, # openapi coverage/security-tiers, env-doc sync) run in CI to keep commits fast. +sh scripts/check/check-git-identity.sh npx lint-staged node scripts/check/check-docs-sync.mjs npm run check:any-budget:t11 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]