mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-07 23:42:10 +03:00
Compare commits
65 Commits
fix/releas
...
chore/open
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
384ce49a4d | ||
|
|
d6f315018a | ||
|
|
ce49d969ca | ||
|
|
6b587d0046 | ||
|
|
c1b34db50d | ||
|
|
ebdbd2c67d | ||
|
|
aa35d460dc | ||
|
|
1b97f42ba3 | ||
|
|
f12b87c80b | ||
|
|
c042a51884 | ||
|
|
d7721559a0 | ||
|
|
e8a91173de | ||
|
|
6d6b6027c5 | ||
|
|
600abe68d0 | ||
|
|
d4d2e68a1f | ||
|
|
25bc16d87e | ||
|
|
d857bd053a | ||
|
|
ce55151ca5 | ||
|
|
b345c7f6cd | ||
|
|
f9a1cc8a9b | ||
|
|
2b2d34eb53 | ||
|
|
92a617c23f | ||
|
|
9d1a896c60 | ||
|
|
a9f7598c60 | ||
|
|
d345520d72 | ||
|
|
7b2c9b5548 | ||
|
|
ec4f951e39 | ||
|
|
3858923f68 | ||
|
|
8c4fb8faf2 | ||
|
|
7da6e10c4e | ||
|
|
0df5be5b09 | ||
|
|
f40c77e837 | ||
|
|
c5d47dad8a | ||
|
|
366099a08c | ||
|
|
82f78b3b3b | ||
|
|
891cb26b2c | ||
|
|
c3945a724c | ||
|
|
008da6d19a | ||
|
|
488f57e9d3 | ||
|
|
c41ec7f862 | ||
|
|
6ff7b26277 | ||
|
|
74c2d26393 | ||
|
|
f8a0f9c1f8 | ||
|
|
2a6eff0aec | ||
|
|
d36d077a4d | ||
|
|
36be267a17 | ||
|
|
57d7c8bc88 | ||
|
|
3b7c541f72 | ||
|
|
5f9c358e9b | ||
|
|
ac94dd9bcf | ||
|
|
6aa3690dea | ||
|
|
85b8d128eb | ||
|
|
8c1dfc416d | ||
|
|
3d2bcc9f12 | ||
|
|
8c8d23a98f | ||
|
|
b0557543b8 | ||
|
|
bb8e75a00d | ||
|
|
04ba19fa62 | ||
|
|
57d9357d88 | ||
|
|
6e35ad01cc | ||
|
|
9cbc4f118e | ||
|
|
9271a34ec1 | ||
|
|
11e1c79e65 | ||
|
|
d6771779f7 | ||
|
|
16b0d4e3ad |
36
.env.example
36
.env.example
@@ -418,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
|
||||
@@ -426,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
|
||||
@@ -433,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
|
||||
@@ -1323,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
|
||||
@@ -1914,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
|
||||
|
||||
89
.github/workflows/npm-publish.yml
vendored
89
.github/workflows/npm-publish.yml
vendored
@@ -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}"
|
||||
|
||||
38
.github/workflows/opencode-plugin-ci.yml
vendored
38
.github/workflows/opencode-plugin-ci.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
4
@omniroute/opencode-plugin-v2/.gitignore
vendored
Normal file
4
@omniroute/opencode-plugin-v2/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.DS_Store
|
||||
21
@omniroute/opencode-plugin-v2/LICENSE
Normal file
21
@omniroute/opencode-plugin-v2/LICENSE
Normal file
@@ -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.
|
||||
107
@omniroute/opencode-plugin-v2/README.md
Normal file
107
@omniroute/opencode-plugin-v2/README.md
Normal file
@@ -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": "<management read token>"
|
||||
}
|
||||
```
|
||||
|
||||
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 `<providerId>/…` |
|
||||
| `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
|
||||
9
@omniroute/opencode-plugin-v2/RELEASE.md
Normal file
9
@omniroute/opencode-plugin-v2/RELEASE.md
Normal file
@@ -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/...`.
|
||||
1884
@omniroute/opencode-plugin-v2/package-lock.json
generated
Normal file
1884
@omniroute/opencode-plugin-v2/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
70
@omniroute/opencode-plugin-v2/package.json
Normal file
70
@omniroute/opencode-plugin-v2/package.json
Normal file
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "^0.28.1"
|
||||
}
|
||||
}
|
||||
211
@omniroute/opencode-plugin-v2/src/cache.ts
Normal file
211
@omniroute/opencode-plugin-v2/src/cache.ts
Normal file
@@ -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 <dir>/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<CatalogSnapshot | undefined> {
|
||||
try {
|
||||
const body = await readFile(diskSnapshotPath(providerId), "utf8");
|
||||
const parsed = JSON.parse(body) as Partial<DiskSnapshotV2>;
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
try {
|
||||
await unlink(diskSnapshotPath(providerId));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
798
@omniroute/opencode-plugin-v2/src/catalog.ts
Normal file
798
@omniroute/opencode-plugin-v2/src/catalog.ts
Normal file
@@ -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<string>;
|
||||
}
|
||||
|
||||
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:<npm>`). */
|
||||
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<string, unknown>;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Legacy variants read their options from `settings`, not `headers`/`body`. */
|
||||
export type BinaryCompatVariant = ModelV2Info["variants"][number] & {
|
||||
settings: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type BinaryCompatModel = ModelV2Info & BinaryCompatFields;
|
||||
export type BinaryCompatProvider = ProviderV2Info &
|
||||
BinaryCompatPackage & {
|
||||
settings: Record<string, unknown>;
|
||||
};
|
||||
|
||||
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<string, unknown>,
|
||||
}));
|
||||
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<string>;
|
||||
suffixes: Set<string>;
|
||||
}
|
||||
|
||||
export function compileModelListFilter(list?: string[]): ModelListFilter | undefined {
|
||||
if (!list || list.length === 0) return undefined;
|
||||
const exact = new Set<string>();
|
||||
const suffixes = new Set<string>();
|
||||
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<string>): 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<string, unknown> = {
|
||||
...(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:<npm>` value as its models, and
|
||||
// the endpoint as `settings.baseURL`.
|
||||
if (source.api.type !== "aisdk") return;
|
||||
const settings: Record<string, unknown> = { ...(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<string, unknown> | 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<OmniRouteEnrichmentMap> {
|
||||
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<ReturnType<typeof usableProviderAliasSet> | 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<string, OmniRouteRawModelEntry>;
|
||||
publishedKeys: Set<string>;
|
||||
publishedModelIds: Map<string, string>;
|
||||
visibleFilter: ReturnType<typeof compileModelListFilter>;
|
||||
hiddenFilter: ReturnType<typeof compileModelListFilter>;
|
||||
usable: ReturnType<typeof usableProviderAliasSet> | undefined;
|
||||
canonicalToAlias: ReturnType<typeof buildCanonicalToAliasMap>;
|
||||
combosFetcher: CatalogFetchers["combos"] | undefined;
|
||||
combosTimeout: number;
|
||||
/** Shared with the auto-combos pass: one collision warning per key, per run. */
|
||||
warnedCombos: Set<string>;
|
||||
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<number | undefined> {
|
||||
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<string, LegacyModelV2>();
|
||||
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<PublishCounts> {
|
||||
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<string, OmniRouteRawModelEntry>();
|
||||
for (const entry of rawModels) {
|
||||
if (entry.id) rawModelById.set(entry.id, entry);
|
||||
}
|
||||
|
||||
const publishedKeys = new Set<string>();
|
||||
// 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<string, string>();
|
||||
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<string>();
|
||||
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/<variant>" (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 };
|
||||
}
|
||||
66
@omniroute/opencode-plugin-v2/src/compat.ts
Normal file
66
@omniroute/opencode-plugin-v2/src/compat.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
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";
|
||||
}
|
||||
101
@omniroute/opencode-plugin-v2/src/credentials.ts
Normal file
101
@omniroute/opencode-plugin-v2/src/credentials.ts
Normal file
@@ -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<PluginContext["integration"]>).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<string | undefined> {
|
||||
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<ResolvedApiKey> {
|
||||
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}.`
|
||||
);
|
||||
}
|
||||
41
@omniroute/opencode-plugin-v2/src/enrichment-report.ts
Normal file
41
@omniroute/opencode-plugin-v2/src/enrichment-report.ts
Normal file
@@ -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<string>();
|
||||
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}`
|
||||
);
|
||||
};
|
||||
}
|
||||
43
@omniroute/opencode-plugin-v2/src/gemini-language.ts
Normal file
43
@omniroute/opencode-plugin-v2/src/gemini-language.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider";
|
||||
import { type Logger, isGeminiModelId, sanitizeToolInputSchemas } from "./shared/index.js";
|
||||
|
||||
type CallOptions = Parameters<LanguageModelV3["doGenerate"]>[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<T extends LanguageModelV3 | undefined>(
|
||||
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;
|
||||
}
|
||||
539
@omniroute/opencode-plugin-v2/src/index.ts
Normal file
539
@omniroute/opencode-plugin-v2/src/index.ts
Normal file
@@ -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<T> = { ok: true; value: T } | { ok: false };
|
||||
|
||||
interface RefreshState {
|
||||
entries: Map<string, CatalogSnapshot>;
|
||||
inFlight: Map<string, Promise<CatalogSnapshot>>;
|
||||
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<void> => {
|
||||
// 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<OmniRouteRawModelEntry[]> => {
|
||||
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<SourceResult<OmniRouteRawCombo[]>> => {
|
||||
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<SourceResult<OmniRouteProviderConnection[]>> => {
|
||||
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<SourceResult<OmniRouteEnrichmentMap>> => {
|
||||
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<SourceResult<OmniRouteRawAutoCombo[]>> => {
|
||||
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<CatalogSnapshot> {
|
||||
// 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<OmniRouteRawCombo[]>,
|
||||
SourceResult<OmniRouteRawAutoCombo[]>,
|
||||
SourceResult<OmniRouteProviderConnection[]>,
|
||||
SourceResult<OmniRouteEnrichmentMap>,
|
||||
]
|
||||
): Promise<void> {
|
||||
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<CatalogSnapshot> {
|
||||
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<void> => {
|
||||
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<PluginContext["integration"]> | 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<PluginContext["aisdk"]> | 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<void> }> | 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)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
117
@omniroute/opencode-plugin-v2/src/options.ts
Normal file
117
@omniroute/opencode-plugin-v2/src/options.ts
Normal file
@@ -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<typeof pluginOptionsSchema>;
|
||||
|
||||
/** 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<PluginOptions, "timeoutMs" | "timeouts">
|
||||
): 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;
|
||||
}
|
||||
219
@omniroute/opencode-plugin-v2/src/shared/auto-combos.ts
Normal file
219
@omniroute/opencode-plugin-v2/src/shared/auto-combos.ts
Normal file
@@ -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<OmniRouteRawAutoCombo[]>;
|
||||
|
||||
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 <baseURL>/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: "",
|
||||
};
|
||||
}
|
||||
254
@omniroute/opencode-plugin-v2/src/shared/combos-map.ts
Normal file
254
@omniroute/opencode-plugin-v2/src/shared/combos-map.ts
Normal file
@@ -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<OmniRouteRawCombo[]>;
|
||||
|
||||
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 <baseURL>/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<Set<string>>, 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 ?? "",
|
||||
};
|
||||
}
|
||||
606
@omniroute/opencode-plugin-v2/src/shared/enrich.ts
Normal file
606
@omniroute/opencode-plugin-v2/src/shared/enrich.ts
Normal file
@@ -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<string, OmniRouteEnrichmentEntry>;
|
||||
|
||||
/**
|
||||
* 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<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
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 `<prefix>/<modelId>` or
|
||||
* bare id (the fetcher writes under both forms).
|
||||
* 2. If `rawId` is `<canonical>/<modelId>` and `canonicalToAlias` has
|
||||
* a mapping for `canonical`, try `<alias>/<modelId>`. This rescues
|
||||
* duplicate rows like `claude/claude-opus-4-7` (canonical) when
|
||||
* enrichment only indexed under `cc/claude-opus-4-7` (alias).
|
||||
* 3. Bare `<modelId>` 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<string, string>
|
||||
): 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<string, string>
|
||||
): Set<string> {
|
||||
const drop = new Set<string>();
|
||||
if (canonicalToAlias.size === 0) return drop;
|
||||
// Index every alias key present in the raw catalog.
|
||||
const aliasKeys = new Set<string>();
|
||||
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<string, OmniRouteEnrichmentEntry> {
|
||||
const out = new Map<string, OmniRouteEnrichmentEntry>();
|
||||
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<string, OmniRouteEnrichmentEntry>,
|
||||
canonicalToAlias?: Map<string, string>
|
||||
): 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<OmniRouteEnrichmentMap>;
|
||||
|
||||
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<string, { models?: unknown[] }> })?.providers ??
|
||||
(body as Record<string, { models?: unknown[] }>);
|
||||
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<string, unknown>)) {
|
||||
if (!slot || typeof slot !== "object" || Array.isArray(slot)) continue;
|
||||
for (const [modelId, raw] of Object.entries(slot as Record<string, unknown>)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const p = raw as Record<string, unknown>;
|
||||
const parsed: NonNullable<OmniRouteEnrichmentEntry["pricing"]> = {};
|
||||
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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
127
@omniroute/opencode-plugin-v2/src/shared/fingerprint.ts
Normal file
127
@omniroute/opencode-plugin-v2/src/shared/fingerprint.ts
Normal file
@@ -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<string, number | undefined>;
|
||||
}
|
||||
>
|
||||
| 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");
|
||||
}
|
||||
166
@omniroute/opencode-plugin-v2/src/shared/gemini.ts
Normal file
166
@omniroute/opencode-plugin-v2/src/shared/gemini.ts
Normal file
@@ -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<string, unknown> {
|
||||
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<string, unknown>): 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<T extends ToolWithInputSchema>(
|
||||
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<string, unknown>;
|
||||
try {
|
||||
schema = structuredClone(tool.inputSchema) as Record<string, unknown>;
|
||||
} catch {
|
||||
return tool;
|
||||
}
|
||||
if (!stripAtSchemaPositions(schema)) return tool;
|
||||
changed = true;
|
||||
return { ...tool, inputSchema: schema };
|
||||
});
|
||||
return changed ? out : undefined;
|
||||
}
|
||||
9
@omniroute/opencode-plugin-v2/src/shared/index.ts
Normal file
9
@omniroute/opencode-plugin-v2/src/shared/index.ts
Normal file
@@ -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";
|
||||
81
@omniroute/opencode-plugin-v2/src/shared/logger.ts
Normal file
81
@omniroute/opencode-plugin-v2/src/shared/logger.ts
Normal file
@@ -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<LogLevel, number> = {
|
||||
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<typeof buildLogger>;
|
||||
|
||||
/** 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);
|
||||
323
@omniroute/opencode-plugin-v2/src/shared/models-map.ts
Normal file
323
@omniroute/opencode-plugin-v2/src/shared/models-map.ts
Normal file
@@ -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<OmniRouteRawModelEntry[]>;
|
||||
|
||||
/**
|
||||
* Default fetcher: `GET <baseURL>/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<string>();
|
||||
|
||||
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 <plugin>/<combo>` as model id `<combo>` 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),
|
||||
};
|
||||
}
|
||||
295
@omniroute/opencode-plugin-v2/src/shared/naming.ts
Normal file
295
@omniroute/opencode-plugin-v2/src/shared/naming.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Universal model naming template for the OmniRoute plugin.
|
||||
*
|
||||
* Naming pipeline:
|
||||
* [tag] <provider-label><separator><display-name><suffix>
|
||||
*
|
||||
* [Free] <provider> - <name> · <budget> ← free model
|
||||
* Auto: <variant> (<N>p) ← auto combo
|
||||
* Combo: <name> ← DB combo
|
||||
* <provider> - <name> ← 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<AutoVariant | "default", string> = {
|
||||
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: <variant> (<N>p)"
|
||||
* 2. DB combo → "Combo: <name>"
|
||||
* 3. Free + enrichment + provider tag → "[Free] <label> - <name> · <budget>"
|
||||
* 4. Free + enrichment → "[Free] <name> · <budget>"
|
||||
* 5. Free + raw → "[Free] <rawId> · <budget>"
|
||||
* 6. Enrichment + provider tag → "<label> - <name>"
|
||||
* 7. Enrichment only → "<name>"
|
||||
* 8. Raw fallback → normaliseFreeLabel(rawId)
|
||||
*/
|
||||
export function buildModelDisplayName(params: ModelDisplayNameParams): string {
|
||||
// Auto combos
|
||||
if (params.isAutoCombo) {
|
||||
return formatAutoComboName(params.autoVariant, params.autoCandidateCount);
|
||||
}
|
||||
|
||||
// Determine base name — strip any existing free suffix first
|
||||
const rawBase =
|
||||
params.enrichmentName && params.enrichmentName.trim().length > 0
|
||||
? params.enrichmentName
|
||||
: params.rawId;
|
||||
const cleanedBase = rawBase
|
||||
.replace(/\s*\(free\)\s*$/i, "")
|
||||
.replace(/[\s-]+free\s*$/i, "")
|
||||
.trim();
|
||||
const wasFree = cleanedBase.length < rawBase.trim().length;
|
||||
const isFree = !!params.isFree || wasFree;
|
||||
|
||||
let baseName = cleanedBase;
|
||||
|
||||
// Provider tag (skip for combos)
|
||||
if (!params.isCombo) {
|
||||
const label = shortProviderLabel({
|
||||
providerDisplayName: params.providerDisplayName,
|
||||
providerAlias: params.providerAlias,
|
||||
});
|
||||
if (label) {
|
||||
const prefix = `${label}${PROVIDER_TAG_SEPARATOR}`;
|
||||
if (!baseName.startsWith(prefix)) {
|
||||
baseName = `${prefix}${baseName}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prepend [Free] if applicable (AFTER provider tag for correct ordering)
|
||||
if (isFree) {
|
||||
baseName = `[Free] ${baseName}`;
|
||||
}
|
||||
|
||||
// Free budget suffix
|
||||
if (isFree && params.freeType) {
|
||||
const budget = formatFreeBudget({
|
||||
freeType: params.freeType,
|
||||
monthlyTokens: params.monthlyTokens,
|
||||
creditTokens: params.creditTokens,
|
||||
});
|
||||
if (budget) {
|
||||
baseName = `${baseName} · ${budget}`;
|
||||
}
|
||||
}
|
||||
|
||||
return baseName;
|
||||
}
|
||||
171
@omniroute/opencode-plugin-v2/src/shared/usable.ts
Normal file
171
@omniroute/opencode-plugin-v2/src/shared/usable.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import type { OmniRouteEnrichmentMap } from "./enrich.js";
|
||||
import type { OmniRouteRawCombo } from "./combos-map.js";
|
||||
|
||||
/** Subset of `/api/providers` connections read by the usableOnly filter. */
|
||||
export interface OmniRouteProviderConnection {
|
||||
/** Connection UUID. */
|
||||
id: string;
|
||||
/** Canonical provider id, e.g. `claude`, `gemini`, `kiro`. */
|
||||
provider: string;
|
||||
/** Operator toggle — when false, the connection is provisioned but disabled. */
|
||||
isActive?: boolean;
|
||||
/** Health-check verdict — `active` means routable. */
|
||||
testStatus?: string;
|
||||
/** Permissive bag — additional fields pass through untouched. */
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export type OmniRouteProvidersFetcher = (
|
||||
baseURL: string,
|
||||
apiKey: string,
|
||||
timeoutMs?: number,
|
||||
onSourceError?: (endpoint: string, reason: string) => void
|
||||
) => Promise<OmniRouteProviderConnection[]>;
|
||||
|
||||
export interface UsableProviderSet {
|
||||
aliases: Set<string>;
|
||||
canonicals: Set<string>;
|
||||
knownAliases: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default providers fetcher: `GET <baseURL>/api/providers` with bearer auth
|
||||
* + AbortController timeout. Accepts the `{ connections: [...] }` envelope
|
||||
* the gateway emits today, a bare-array envelope, and a `{ data: [...] }`
|
||||
* envelope (defensive). Refusals and network errors THROW so the caller keeps
|
||||
* last-known instead of silently disabling the filter; a 2xx with an empty
|
||||
* list is the only empty answer.
|
||||
*/
|
||||
export const defaultOmniRouteProvidersFetcher: OmniRouteProvidersFetcher = async (
|
||||
baseURL,
|
||||
apiKey,
|
||||
timeoutMs = 10_000,
|
||||
onSourceError
|
||||
) => {
|
||||
const empty: OmniRouteProviderConnection[] = [];
|
||||
if (!baseURL || !apiKey) return empty;
|
||||
const root = baseURL.replace(/\/v1\/?$/, "").replace(/\/$/, "");
|
||||
const url = `${root}/api/providers`;
|
||||
const ac = new AbortController();
|
||||
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: ac.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
// A refusal (403 behind a management-token gate, 503 mid-outage) is a
|
||||
// failure, not an empty allowlist: the caller keeps last-known instead
|
||||
// of silently disabling the filter.
|
||||
onSourceError?.("/api/providers", `HTTP ${res.status}`);
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const body = (await res.json()) as unknown;
|
||||
const list = Array.isArray(body)
|
||||
? body
|
||||
: Array.isArray((body as { connections?: unknown[] })?.connections)
|
||||
? (body as { connections: unknown[] }).connections
|
||||
: Array.isArray((body as { data?: unknown[] })?.data)
|
||||
? (body as { data: unknown[] }).data
|
||||
: [];
|
||||
const out: OmniRouteProviderConnection[] = [];
|
||||
for (const raw of list) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const provider = (raw as { provider?: unknown }).provider;
|
||||
if (typeof provider !== "string" || provider.length === 0) continue;
|
||||
const id = (raw as { id?: unknown }).id;
|
||||
const idStr = typeof id === "string" && id.length > 0 ? id : provider;
|
||||
out.push({ ...(raw as Record<string, unknown>), id: idStr, provider });
|
||||
}
|
||||
return out;
|
||||
} catch (err) {
|
||||
// Network error, timeout, abort: keep last-known, never silently unfilter.
|
||||
onSourceError?.("/api/providers", err instanceof Error ? err.message : String(err));
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute the provider prefixes safe to keep. A canonical provider is usable
|
||||
* when at least one connection is not explicitly disabled (`isActive: false`)
|
||||
* and has no failing health verdict (`testStatus` present and not "active"). Aliases (e.g. `cc` -> `claude`) resolve through the enrichment
|
||||
* map, which records every alias in `knownAliases` so the downstream filter
|
||||
* decides "this prefix was in /api/pricing/models" in O(1).
|
||||
*
|
||||
* Subtract-filter semantics: callers keep prefixes unknown to BOTH tables.
|
||||
*/
|
||||
export function usableProviderAliasSet(
|
||||
connections: OmniRouteProviderConnection[],
|
||||
enrichment: OmniRouteEnrichmentMap | undefined
|
||||
): UsableProviderSet {
|
||||
const usableCanonicals = new Set<string>();
|
||||
for (const c of connections) {
|
||||
// A missing toggle means "no opinion", not "disabled": the field only
|
||||
// exists on newer gateways, and treating its absence as a veto would hide
|
||||
// every model behind a filter the operator never asked to tighten. Only an
|
||||
// explicit `false` disables.
|
||||
if (!c || c.isActive === false) continue;
|
||||
if (typeof c.testStatus === "string" && c.testStatus !== "active") continue;
|
||||
if (typeof c.provider === "string" && c.provider.length > 0) {
|
||||
usableCanonicals.add(c.provider);
|
||||
}
|
||||
}
|
||||
const aliases = new Set<string>();
|
||||
const knownAliases = new Set<string>();
|
||||
if (enrichment) {
|
||||
for (const entry of enrichment.values()) {
|
||||
const alias = entry.providerAlias;
|
||||
const canonical = entry.providerCanonical;
|
||||
if (typeof alias !== "string" || alias.length === 0) continue;
|
||||
knownAliases.add(alias);
|
||||
if (typeof canonical !== "string" || canonical.length === 0) continue;
|
||||
if (usableCanonicals.has(canonical)) aliases.add(alias);
|
||||
}
|
||||
}
|
||||
for (const canonical of usableCanonicals) aliases.add(canonical);
|
||||
return { aliases, canonicals: usableCanonicals, knownAliases };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a raw `/v1/models` id passes the usableOnly filter.
|
||||
* Rules (subtract-filter, bias toward keep): no `/` -> keep; usable alias
|
||||
* or canonical -> keep; unknown to both tables -> keep; known alias but not
|
||||
* usable -> drop.
|
||||
*/
|
||||
export function isUsableRawModelId(id: string, usable: UsableProviderSet): boolean {
|
||||
const slash = id.indexOf("/");
|
||||
if (slash <= 0) return true;
|
||||
const prefix = id.slice(0, slash);
|
||||
if (usable.aliases.has(prefix) || usable.canonicals.has(prefix)) return true;
|
||||
if (usable.knownAliases.has(prefix)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a combo passes the usableOnly filter. A combo keeps when AT
|
||||
* LEAST ONE member maps to a usable provider; unknown prefixes keep (cannot
|
||||
* prove unroutable); combos with zero resolvable members keep.
|
||||
*/
|
||||
export function isUsableCombo(combo: OmniRouteRawCombo, usable: UsableProviderSet): boolean {
|
||||
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 : "";
|
||||
const slash = modelId.indexOf("/");
|
||||
if (slash <= 0) continue;
|
||||
sawResolvableMember = true;
|
||||
const prefix = modelId.slice(0, slash);
|
||||
if (usable.aliases.has(prefix) || usable.canonicals.has(prefix)) return true;
|
||||
if (!usable.knownAliases.has(prefix)) return true;
|
||||
}
|
||||
if (!sawResolvableMember) return true;
|
||||
return false;
|
||||
}
|
||||
93
@omniroute/opencode-plugin-v2/tests/api-package.test.ts
Normal file
93
@omniroute/opencode-plugin-v2/tests/api-package.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
|
||||
const SUPPORTED_PACKAGES = new Set(["@ai-sdk/openai-compatible", "@ai-sdk/anthropic"]);
|
||||
|
||||
function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft } {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
const draft = {
|
||||
provider: {
|
||||
list: () => [],
|
||||
get: (id: string) => providers.get(id) as never,
|
||||
update: (id: string, fn: (p: ProviderV2Info) => void) => {
|
||||
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
|
||||
fn(p);
|
||||
providers.set(id, p);
|
||||
},
|
||||
remove: () => {},
|
||||
},
|
||||
model: {
|
||||
get: () => undefined,
|
||||
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
|
||||
const k = pid + "/" + mid;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
|
||||
fn(m);
|
||||
models.set(k, m);
|
||||
},
|
||||
remove: () => {},
|
||||
default: { get: () => undefined, set: () => {} },
|
||||
},
|
||||
} as CatalogDraft;
|
||||
return { models, draft };
|
||||
}
|
||||
|
||||
const baseOpts = {
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 300000,
|
||||
usableOnly: false,
|
||||
};
|
||||
|
||||
function apiPackageOf(m: ModelV2Info | undefined): string {
|
||||
assert.ok(m, "model must be published");
|
||||
assert.equal(m?.api.type, "aisdk");
|
||||
if (m?.api.type !== "aisdk") throw new Error("model api must be aisdk");
|
||||
return m.api.package;
|
||||
}
|
||||
|
||||
describe("catalog api package (models + combos + auto-combos)", () => {
|
||||
it("every published entry carries a non-empty supported api.package", async () => {
|
||||
const { models, draft } = fakeDraft();
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => [{ id: "gpt-x", context_length: 128000, max_output_tokens: 4096 }],
|
||||
combosFetcher: async () => [
|
||||
{ id: "combo-a", name: "Combo A", models: [{ kind: "model", model: "gpt-x" }] },
|
||||
],
|
||||
autoCombosFetcher: async () => [{ id: "auto", candidateCount: 6 }],
|
||||
});
|
||||
assert.deepEqual(res, { models: 1, combos: 1, autoCombos: 1 });
|
||||
for (const key of ["omniroute/gpt-x", "omniroute/combo-a", "omniroute/auto"]) {
|
||||
const pkg = apiPackageOf(models.get(key));
|
||||
assert.ok(pkg.length > 0, `${key} api.package must be non-empty`);
|
||||
assert.ok(SUPPORTED_PACKAGES.has(pkg), `${key} api.package must be supported, got ${pkg}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-combos follow the same anthropic apiFormat rule as models", async () => {
|
||||
const { models, draft } = fakeDraft();
|
||||
await publishCatalog(
|
||||
draft,
|
||||
{
|
||||
...baseOpts,
|
||||
apiFormat: { allowAnthropic: true, anthropicModels: ["anthropic/claude-x", "auto/coding"] },
|
||||
},
|
||||
{
|
||||
fetcher: async () => [{ id: "anthropic/claude-x" }],
|
||||
combosFetcher: async () => [],
|
||||
autoCombosFetcher: async () => [
|
||||
{ id: "auto/coding", variant: "coding", candidateCount: 4 },
|
||||
{ id: "auto/fast", variant: "fast", candidateCount: 2 },
|
||||
],
|
||||
}
|
||||
);
|
||||
assert.equal(apiPackageOf(models.get("omniroute/anthropic/claude-x")), "@ai-sdk/anthropic");
|
||||
assert.equal(apiPackageOf(models.get("omniroute/auto/coding")), "@ai-sdk/anthropic");
|
||||
assert.equal(apiPackageOf(models.get("omniroute/auto/fast")), "@ai-sdk/openai-compatible");
|
||||
});
|
||||
});
|
||||
196
@omniroute/opencode-plugin-v2/tests/auto-combos.test.ts
Normal file
196
@omniroute/opencode-plugin-v2/tests/auto-combos.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
|
||||
function fakeDraft(): {
|
||||
models: Map<string, ModelV2Info>;
|
||||
draft: CatalogDraft;
|
||||
warns: string[];
|
||||
restore: () => void;
|
||||
} {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
const draft = {
|
||||
provider: {
|
||||
list: () => [],
|
||||
get: (id: string) => providers.get(id) as never,
|
||||
update: (id: string, fn: (p: ProviderV2Info) => void) => {
|
||||
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
|
||||
fn(p);
|
||||
providers.set(id, p);
|
||||
},
|
||||
remove: () => {},
|
||||
},
|
||||
model: {
|
||||
get: () => undefined,
|
||||
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
|
||||
const k = pid + "/" + mid;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
|
||||
fn(m);
|
||||
models.set(k, m);
|
||||
},
|
||||
remove: () => {},
|
||||
default: { get: () => undefined, set: () => {} },
|
||||
},
|
||||
} as CatalogDraft;
|
||||
return {
|
||||
models,
|
||||
draft,
|
||||
warns,
|
||||
restore: () => {
|
||||
console.warn = origWarn;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const baseOpts = {
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k",
|
||||
timeoutMs: 1000,
|
||||
timeouts: { autoCombos: 5000 },
|
||||
modelCacheTtlMs: 300000,
|
||||
usableOnly: false,
|
||||
};
|
||||
|
||||
describe("catalog auto combos (v1 parity)", () => {
|
||||
it("publishes auto/* ids from the auto-combos fetcher", async () => {
|
||||
const { models, draft, warns, restore } = fakeDraft();
|
||||
try {
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => [],
|
||||
combosFetcher: async () => [],
|
||||
autoCombosFetcher: async () => [
|
||||
{ id: "auto", variant: undefined, candidateCount: 6 },
|
||||
{ id: "auto/coding", variant: "coding", candidateCount: 4 },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(res, { models: 0, combos: 0, autoCombos: 2 });
|
||||
assert.ok(models.has("omniroute/auto"), "auto entry must be published");
|
||||
assert.ok(models.has("omniroute/auto/coding"), "auto/coding entry must be published");
|
||||
const coding = models.get("omniroute/auto/coding");
|
||||
assert.equal(coding?.limit.context, 128_000);
|
||||
assert.equal(coding?.limit.output, 8_192);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
assert.ok(!warns.some((w) => w.includes("auto combo") && w.includes("failed")));
|
||||
});
|
||||
|
||||
it("auto-combos fetch throw stays fail-open (warn, models kept)", async () => {
|
||||
const { models, draft, warns, restore } = fakeDraft();
|
||||
try {
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => [{ id: "m1" }],
|
||||
combosFetcher: async () => [],
|
||||
autoCombosFetcher: async () => {
|
||||
throw new Error("GET https://gw.example.com/api/combos/auto failed: 500 boom");
|
||||
},
|
||||
});
|
||||
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
|
||||
assert.ok(models.has("omniroute/m1"));
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
assert.ok(warns.some((w) => w.includes("auto combos") || w.includes("auto-combos")));
|
||||
});
|
||||
|
||||
it("forwards the 5s auto-combos timeout to the fetcher", async () => {
|
||||
const seen: number[] = [];
|
||||
const { draft, restore } = fakeDraft();
|
||||
try {
|
||||
await publishCatalog(
|
||||
draft,
|
||||
{ ...baseOpts, timeoutMs: 9999, timeouts: { autoCombos: 5000 } },
|
||||
{
|
||||
fetcher: async () => [],
|
||||
combosFetcher: async () => [],
|
||||
autoCombosFetcher: async (_base, _key, timeoutMs) => {
|
||||
seen.push(timeoutMs ?? -1);
|
||||
return [];
|
||||
},
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
assert.deepEqual(seen, [5000]);
|
||||
});
|
||||
|
||||
it("hidden auto combos are skipped", async () => {
|
||||
const { models, draft, restore } = fakeDraft();
|
||||
try {
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => [],
|
||||
combosFetcher: async () => [],
|
||||
autoCombosFetcher: async () => [
|
||||
{ id: "auto/offline", variant: "offline", isHidden: true },
|
||||
{ id: "auto/fast", variant: "fast" },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(res, { models: 0, combos: 0, autoCombos: 1 });
|
||||
assert.ok(!models.has("omniroute/auto/offline"));
|
||||
assert.ok(models.has("omniroute/auto/fast"));
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("auto combos obey the same allowlists as everything else", () => {
|
||||
it("hides an auto combo the user hid, and keeps the rest", async () => {
|
||||
const { publishCatalog } = await import("../src/catalog.js");
|
||||
const published = new Map<string, Record<string, unknown>>();
|
||||
const draft = {
|
||||
provider: { update: (_i: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
|
||||
model: {
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => {
|
||||
const e: Record<string, unknown> = { id: mid, providerID: pid };
|
||||
fn(e);
|
||||
published.set(`${pid}/${mid}`, e);
|
||||
},
|
||||
},
|
||||
};
|
||||
await publishCatalog(
|
||||
draft as never,
|
||||
{
|
||||
providerId: "omni",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 1000,
|
||||
usableOnly: false,
|
||||
enrichment: new Map(),
|
||||
hiddenModels: ["auto/best-chaos"],
|
||||
} as never,
|
||||
{
|
||||
models: async () => [{ id: "m1" }],
|
||||
combos: async () => [],
|
||||
autoCombos: async () =>
|
||||
[
|
||||
{ id: "auto/best-coding", variant: "best-coding", candidateCount: 3 },
|
||||
{ id: "auto/best-chaos", variant: "best-chaos", candidateCount: 2 },
|
||||
] as never,
|
||||
providers: async () => [],
|
||||
enrichment: async () => new Map(),
|
||||
}
|
||||
);
|
||||
const keys = [...published.keys()];
|
||||
assert.ok(
|
||||
keys.some((k) => k.includes("best-coding")),
|
||||
`the other auto combo stays, got ${JSON.stringify(keys)}`
|
||||
);
|
||||
assert.equal(
|
||||
keys.some((k) => k.includes("best-chaos")),
|
||||
false,
|
||||
`a hidden auto combo must not reach the picker, got ${JSON.stringify(keys)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
378
@omniroute/opencode-plugin-v2/tests/cache-ttl-snapshot.test.ts
Normal file
378
@omniroute/opencode-plugin-v2/tests/cache-ttl-snapshot.test.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, statSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import plugin from "../src/index.js";
|
||||
import { DEFAULT_MODEL_CACHE_TTL_MS, diskSnapshotPath } from "../src/cache.js";
|
||||
|
||||
interface CtxOpts {
|
||||
providerId: string;
|
||||
baseURL: string;
|
||||
apiKey: string;
|
||||
modelCacheTtlMs?: number;
|
||||
}
|
||||
|
||||
function stubFetch(
|
||||
counter: { models: number; combos: number; autoCombos?: number; enrichment?: number },
|
||||
modelIds: string[]
|
||||
) {
|
||||
return (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/api/pricing") || href.includes("/api/free-tier")) {
|
||||
if (counter.enrichment !== undefined) counter.enrichment += 1;
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({}) };
|
||||
}
|
||||
if (href.includes("/api/combos/auto")) {
|
||||
if (counter.autoCombos !== undefined) counter.autoCombos += 1;
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
if (href.includes("/api/combos")) {
|
||||
counter.combos += 1;
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
counter.models += 1;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ data: modelIds.map((id) => ({ id })) }),
|
||||
};
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
async function setupPlugin(opts: CtxOpts): Promise<{
|
||||
callbacks: Array<(draft: unknown) => Promise<void>>;
|
||||
reloads: { count: number };
|
||||
}> {
|
||||
const callbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
const reloads = { count: 0 };
|
||||
const ctx = {
|
||||
options: { ...opts },
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => Promise<void>) => {
|
||||
callbacks.push(cb);
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
reload: async () => {
|
||||
reloads.count += 1;
|
||||
},
|
||||
},
|
||||
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
|
||||
};
|
||||
const logs: string[] = [];
|
||||
const warns: string[] = [];
|
||||
const origLog = console.log;
|
||||
const origWarn = console.warn;
|
||||
console.log = (...args: unknown[]) => {
|
||||
logs.push(String(args[0]));
|
||||
};
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
console.warn = origWarn;
|
||||
}
|
||||
return { callbacks, reloads };
|
||||
}
|
||||
|
||||
function stubDraft(): { draft: unknown; published: Map<string, Record<string, unknown>> } {
|
||||
const published = new Map<string, Record<string, unknown>>();
|
||||
return {
|
||||
published,
|
||||
draft: {
|
||||
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
|
||||
model: {
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => {
|
||||
const entry: Record<string, unknown> = { id: mid, providerID: pid };
|
||||
fn(entry);
|
||||
published.set(pid + "/" + mid, entry);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isolateDisk(): { dir: string; restore: () => void } {
|
||||
const dir = mkdtempSync(join(tmpdir(), "omniroute-p1-"));
|
||||
const prev = process.env.OPENCODE_DATA_DIR;
|
||||
process.env.OPENCODE_DATA_DIR = dir;
|
||||
return {
|
||||
dir,
|
||||
restore: () => {
|
||||
if (prev === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = prev;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
|
||||
it("TTL default is 300s (v1 parity)", () => {
|
||||
assert.equal(DEFAULT_MODEL_CACHE_TTL_MS, 300_000);
|
||||
});
|
||||
|
||||
it("2nd transform within TTL -> 0 network fetches (stub counter)", async () => {
|
||||
const disk = isolateDisk();
|
||||
const counter = { models: 0, combos: 0, autoCombos: 0 };
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch(counter, ["m1"]);
|
||||
try {
|
||||
const { callbacks } = await setupPlugin({
|
||||
providerId: "ttl-hit",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k-ttl",
|
||||
});
|
||||
const { draft, published } = stubDraft();
|
||||
await callbacks[0](draft);
|
||||
assert.equal(counter.models, 1);
|
||||
assert.equal(counter.combos, 1);
|
||||
assert.equal(counter.autoCombos, 1);
|
||||
assert.ok(published.has("ttl-hit/m1"));
|
||||
await callbacks[0](draft);
|
||||
assert.equal(counter.models, 1);
|
||||
assert.equal(counter.combos, 1);
|
||||
assert.equal(counter.autoCombos, 1);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
disk.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("expired TTL -> network refetch", async () => {
|
||||
const disk = isolateDisk();
|
||||
const counter = { models: 0, combos: 0 };
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch(counter, ["m1"]);
|
||||
const origNow = Date.now;
|
||||
let now = 1_000_000;
|
||||
Date.now = () => now;
|
||||
try {
|
||||
const { callbacks } = await setupPlugin({
|
||||
providerId: "ttl-expire",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k-expire",
|
||||
modelCacheTtlMs: 1000,
|
||||
});
|
||||
const { draft } = stubDraft();
|
||||
await callbacks[0](draft);
|
||||
assert.equal(counter.models, 1);
|
||||
now += 500;
|
||||
await callbacks[0](draft);
|
||||
assert.equal(counter.models, 1);
|
||||
now += 1000;
|
||||
await callbacks[0](draft);
|
||||
assert.equal(counter.models, 2);
|
||||
} finally {
|
||||
Date.now = origNow;
|
||||
globalThis.fetch = origFetch;
|
||||
disk.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("concurrent calls share a single refresh promise", async () => {
|
||||
const disk = isolateDisk();
|
||||
const counter = { models: 0, combos: 0 };
|
||||
const origFetch = globalThis.fetch;
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/api/pricing") || href.includes("/api/free-tier")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({}) };
|
||||
}
|
||||
if (href.includes("/v1/models")) {
|
||||
counter.models += 1;
|
||||
await gate;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ data: [{ id: "m1" }] }),
|
||||
};
|
||||
}
|
||||
if (href.includes("/api/combos/auto")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
if (href.includes("/api/providers")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({}) };
|
||||
}
|
||||
counter.combos += 1;
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}) as typeof fetch;
|
||||
const origLog = console.log;
|
||||
const origWarn = console.warn;
|
||||
console.log = () => {};
|
||||
console.warn = () => {};
|
||||
try {
|
||||
const { callbacks } = await setupPlugin({
|
||||
providerId: "singleflight",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k-sf",
|
||||
});
|
||||
const { draft } = stubDraft();
|
||||
const a = callbacks[0](draft);
|
||||
const b = callbacks[0](draft);
|
||||
release();
|
||||
await Promise.all([a, b]);
|
||||
assert.equal(counter.models, 1);
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
console.warn = origWarn;
|
||||
globalThis.fetch = origFetch;
|
||||
disk.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("snapshot: fetch down at boot + snapshot present -> warm catalog published", async () => {
|
||||
const disk = isolateDisk();
|
||||
const warmCounter = { models: 0, combos: 0 };
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch(warmCounter, ["mw"]);
|
||||
const origLog = console.log;
|
||||
const origWarn = console.warn;
|
||||
console.log = () => {};
|
||||
console.warn = () => {};
|
||||
try {
|
||||
const { callbacks } = await setupPlugin({
|
||||
providerId: "warm",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k-warm",
|
||||
});
|
||||
const { draft } = stubDraft();
|
||||
await callbacks[0](draft);
|
||||
assert.ok(statSync(diskSnapshotPath("warm")).isFile());
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
console.warn = origWarn;
|
||||
globalThis.fetch = origFetch;
|
||||
disk.restore();
|
||||
}
|
||||
|
||||
const warmBody = (await import("node:fs/promises").then((fs) =>
|
||||
fs.readFile(join(disk.dir, "plugins", "omniroute-warm.json"), "utf8")
|
||||
)) as string;
|
||||
void warmBody;
|
||||
|
||||
const disk2prev = process.env.OPENCODE_DATA_DIR;
|
||||
process.env.OPENCODE_DATA_DIR = disk.dir;
|
||||
const downFetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/api/pricing") || href.includes("/api/free-tier")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({}) };
|
||||
}
|
||||
if (href.includes("/api/combos/auto")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
if (href.includes("/api/combos")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
return { ok: false, status: 500, statusText: "Down", json: async () => ({}) };
|
||||
}) as typeof fetch;
|
||||
globalThis.fetch = downFetch;
|
||||
const warns: string[] = [];
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
console.log = () => {};
|
||||
try {
|
||||
const { callbacks } = await setupPlugin({
|
||||
providerId: "warm",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k-warm",
|
||||
});
|
||||
const { draft, published } = stubDraft();
|
||||
await callbacks[0](draft);
|
||||
assert.ok(
|
||||
published.has("warm/mw"),
|
||||
`warm snapshot must publish mw, got: ${JSON.stringify([...published.keys()])}`
|
||||
);
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
console.warn = origWarn;
|
||||
globalThis.fetch = origFetch;
|
||||
if (disk2prev === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = disk2prev;
|
||||
}
|
||||
void warns;
|
||||
});
|
||||
|
||||
it("invalidation: fresh setup does not reuse the previous in-memory cache", async () => {
|
||||
const disk = isolateDisk();
|
||||
const counter = { models: 0, combos: 0 };
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch(counter, ["m1"]);
|
||||
const origLog = console.log;
|
||||
const origWarn = console.warn;
|
||||
console.log = () => {};
|
||||
console.warn = () => {};
|
||||
try {
|
||||
const first = await setupPlugin({
|
||||
providerId: "inval",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k-inval",
|
||||
modelCacheTtlMs: 1,
|
||||
});
|
||||
const { draft } = stubDraft();
|
||||
await first.callbacks[0](draft);
|
||||
assert.equal(counter.models, 1);
|
||||
// Fresh setup = empty memory (setup closure): the stale disk warm entry
|
||||
// expires + the refetch starts, no reuse of the previous cache.
|
||||
const { setTimeout: sleep } = await import("node:timers/promises");
|
||||
await sleep(5);
|
||||
const second = await setupPlugin({
|
||||
providerId: "inval",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k-inval",
|
||||
modelCacheTtlMs: 1,
|
||||
});
|
||||
await second.callbacks[0](draft);
|
||||
assert.equal(counter.models, 2);
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
console.warn = origWarn;
|
||||
globalThis.fetch = origFetch;
|
||||
disk.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the snapshot carries the display overlay across a restart", () => {
|
||||
it("round-trips enrichment so a cold start is not a wall of raw ids", async () => {
|
||||
const { mkdtempSync } = await import("node:fs");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const { writeDiskSnapshot, readDiskSnapshot } = await import("../src/cache.js");
|
||||
const dir = mkdtempSync(join(tmpdir(), "omniroute-enrich-snap-"));
|
||||
const prev = process.env.OPENCODE_DATA_DIR;
|
||||
process.env.OPENCODE_DATA_DIR = dir;
|
||||
try {
|
||||
const enrichment = new Map([
|
||||
["cc/sonnet", { name: "Claude Sonnet 4.6", providerDisplayName: "Claude" }],
|
||||
]);
|
||||
await writeDiskSnapshot(
|
||||
"omni",
|
||||
{
|
||||
models: [{ id: "cc/sonnet" }],
|
||||
combos: [],
|
||||
autoCombos: [],
|
||||
providers: [],
|
||||
enrichment,
|
||||
fetchedAt: Date.now(),
|
||||
} as never,
|
||||
"fp-1"
|
||||
);
|
||||
const back = await readDiskSnapshot("omni", "fp-1");
|
||||
assert.ok(back, "snapshot readable");
|
||||
assert.equal(back.enrichment?.get("cc/sonnet")?.name, "Claude Sonnet 4.6");
|
||||
assert.equal(back.enrichment?.get("cc/sonnet")?.providerDisplayName, "Claude");
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
330
@omniroute/opencode-plugin-v2/tests/catalog.test.ts
Normal file
330
@omniroute/opencode-plugin-v2/tests/catalog.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
|
||||
interface FakeDraft {
|
||||
providers: Map<string, ProviderV2Info>;
|
||||
models: Map<string, ModelV2Info>;
|
||||
warns: string[];
|
||||
provider: CatalogDraft["provider"];
|
||||
model: CatalogDraft["model"];
|
||||
}
|
||||
|
||||
function fakeDraft(): FakeDraft {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
return {
|
||||
providers,
|
||||
models,
|
||||
warns: [],
|
||||
provider: {
|
||||
list: () => [],
|
||||
get: (id: string) => providers.get(id) as never,
|
||||
update: (id: string, fn: (p: ProviderV2Info) => void) => {
|
||||
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
|
||||
fn(p);
|
||||
providers.set(id, p);
|
||||
},
|
||||
remove: () => {},
|
||||
},
|
||||
model: {
|
||||
get: () => undefined,
|
||||
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
|
||||
const k = pid + "/" + mid;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
|
||||
fn(m);
|
||||
models.set(k, m);
|
||||
},
|
||||
remove: () => {},
|
||||
default: { get: () => undefined, set: () => {} },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const baseOpts = {
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 300000,
|
||||
usableOnly: false,
|
||||
};
|
||||
|
||||
describe("catalog provider template", () => {
|
||||
it("writes provider api package/url plus integrationID", async () => {
|
||||
const draft = fakeDraft();
|
||||
await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => [],
|
||||
combosFetcher: async () => [],
|
||||
});
|
||||
const p = draft.providers.get("omniroute");
|
||||
assert.ok(p);
|
||||
assert.equal(p?.name, "OmniRoute");
|
||||
assert.deepEqual(p?.api, {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://gw.example.com/v1",
|
||||
});
|
||||
assert.equal(p?.integrationID, "omniroute");
|
||||
});
|
||||
|
||||
it("publishes stub models with mapped fields, counts them", async () => {
|
||||
const draft = fakeDraft();
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => [
|
||||
{
|
||||
id: "gpt-x",
|
||||
context_length: 128000,
|
||||
max_output_tokens: 4096,
|
||||
input_modalities: ["text", "image"],
|
||||
capabilities: { tool_calling: true, reasoning: true },
|
||||
},
|
||||
{ id: "plain" },
|
||||
],
|
||||
combosFetcher: async () => [],
|
||||
});
|
||||
assert.deepEqual(res, { models: 2, combos: 0, autoCombos: 0 });
|
||||
const m = draft.models.get("omniroute/gpt-x");
|
||||
assert.ok(m);
|
||||
assert.equal(m?.providerID, "omniroute");
|
||||
assert.equal(m?.limit.context, 128000);
|
||||
assert.equal(m?.capabilities.tools, true);
|
||||
assert.equal(m?.status, "active");
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog allowlist", () => {
|
||||
const stubModels = async () => [{ id: "cc/keep-me" }, { id: "cc/drop-me" }, { id: "bare-keep" }];
|
||||
|
||||
it("visible exact allowlist keeps matches, drops the rest", async () => {
|
||||
const draft = fakeDraft();
|
||||
const res = await publishCatalog(
|
||||
draft,
|
||||
{ ...baseOpts, visibleModels: ["cc/keep-me"] },
|
||||
{ fetcher: stubModels, combosFetcher: async () => [] }
|
||||
);
|
||||
assert.equal(res.models, 1);
|
||||
assert.ok(draft.models.has("omniroute/cc/keep-me"));
|
||||
assert.ok(!draft.models.has("omniroute/cc/drop-me"));
|
||||
});
|
||||
|
||||
it("hidden deny-wins over visible allow", async () => {
|
||||
const draft = fakeDraft();
|
||||
const res = await publishCatalog(
|
||||
draft,
|
||||
{ ...baseOpts, visibleModels: ["cc/keep-me"], hiddenModels: ["cc/keep-me"] },
|
||||
{ fetcher: stubModels, combosFetcher: async () => [] }
|
||||
);
|
||||
assert.equal(res.models, 0);
|
||||
});
|
||||
|
||||
it("bare hidden id matches any prefix via suffix rule", async () => {
|
||||
const draft = fakeDraft();
|
||||
const res = await publishCatalog(
|
||||
draft,
|
||||
{ ...baseOpts, hiddenModels: ["drop-me"] },
|
||||
{ fetcher: stubModels, combosFetcher: async () => [] }
|
||||
);
|
||||
assert.equal(res.models, 2);
|
||||
assert.ok(!draft.models.has("omniroute/cc/drop-me"));
|
||||
assert.ok(draft.models.has("omniroute/bare-keep"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog fail-open", () => {
|
||||
it("models fetch throw warns and returns zeros without throwing", async () => {
|
||||
const draft = fakeDraft();
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => {
|
||||
throw new Error("boom 500");
|
||||
},
|
||||
combosFetcher: async () => [],
|
||||
});
|
||||
assert.deepEqual(res, { models: 0, combos: 0, autoCombos: 0 });
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.equal(warns.length, 1);
|
||||
assert.match(warns[0], /models fetch failed/);
|
||||
assert.ok(draft.providers.has("omniroute"));
|
||||
});
|
||||
|
||||
it("combos fetch throw keeps models, warns, returns models-only counts", async () => {
|
||||
const draft = fakeDraft();
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => [{ id: "m1" }],
|
||||
enrichmentFetcher: async () => new Map(),
|
||||
combosFetcher: async () => {
|
||||
throw Object.assign(new Error("Not Found"), { status: 404 });
|
||||
},
|
||||
});
|
||||
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.equal(warns.length, 1);
|
||||
assert.match(warns[0], /combos fetch failed/);
|
||||
assert.ok(draft.models.has("omniroute/m1"));
|
||||
});
|
||||
|
||||
it("combos fetch 403 (PROD) keeps models, warns, never rejects", async () => {
|
||||
const draft = fakeDraft();
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => [{ id: "m1" }],
|
||||
enrichmentFetcher: async () => new Map(),
|
||||
combosFetcher: async () => {
|
||||
throw new Error(
|
||||
"[omniroute-v2] GET https://gw.example.com/api/combos failed: 403 Forbidden"
|
||||
);
|
||||
},
|
||||
});
|
||||
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.equal(warns.length, 1);
|
||||
assert.match(warns[0], /combos fetch failed/);
|
||||
assert.match(warns[0], /403/);
|
||||
assert.ok(draft.models.has("omniroute/m1"));
|
||||
});
|
||||
|
||||
it("combos fetch 500 keeps models, warns, never rejects", async () => {
|
||||
const draft = fakeDraft();
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => [{ id: "m1" }],
|
||||
enrichmentFetcher: async () => new Map(),
|
||||
combosFetcher: async () => {
|
||||
throw new Error(
|
||||
"[omniroute-v2] GET https://gw.example.com/api/combos failed: 500 Internal Server Error"
|
||||
);
|
||||
},
|
||||
});
|
||||
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.equal(warns.length, 1);
|
||||
assert.match(warns[0], /combos fetch failed/);
|
||||
assert.match(warns[0], /500/);
|
||||
assert.ok(draft.models.has("omniroute/m1"));
|
||||
});
|
||||
|
||||
it("combos fetch timeout (AbortError) keeps models, warns, never rejects", async () => {
|
||||
const draft = fakeDraft();
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => [{ id: "m1" }],
|
||||
enrichmentFetcher: async () => new Map(),
|
||||
combosFetcher: async () => {
|
||||
const err = new Error("This operation was aborted");
|
||||
err.name = "AbortError";
|
||||
throw err;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.equal(warns.length, 1);
|
||||
assert.match(warns[0], /combos fetch failed/);
|
||||
assert.ok(draft.models.has("omniroute/m1"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog combo vs combo", () => {
|
||||
it("second combo with same id warns only once", async () => {
|
||||
const draft = fakeDraft();
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => [{ id: "dupe" }],
|
||||
enrichmentFetcher: async () => new Map(),
|
||||
combosFetcher: async () => [
|
||||
{
|
||||
id: "dupe",
|
||||
name: "Dupe Combo",
|
||||
models: [{ kind: "model", model: "dupe" }],
|
||||
},
|
||||
{
|
||||
id: "dupe",
|
||||
name: "Dupe Combo Again",
|
||||
models: [{ kind: "model", model: "dupe" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.deepEqual(res, { models: 1, combos: 2, autoCombos: 0 });
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.equal(warns.length, 1);
|
||||
assert.match(warns[0], /collides with a model id; combo wins/);
|
||||
const m = draft.models.get("omniroute/dupe");
|
||||
assert.ok(m);
|
||||
assert.equal(m?.name, "Dupe Combo Again");
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog model bare vs combo", () => {
|
||||
it("bare model id colliding with combo id warns exactly once", async () => {
|
||||
const draft = fakeDraft();
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => [{ id: "dupe" }],
|
||||
enrichmentFetcher: async () => new Map(),
|
||||
combosFetcher: async () => [
|
||||
{
|
||||
id: "dupe",
|
||||
name: "Dupe Combo",
|
||||
models: [{ kind: "model", model: "dupe" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.deepEqual(res, { models: 1, combos: 1, autoCombos: 0 });
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.equal(warns.length, 1);
|
||||
assert.match(warns[0], /collides with a model id; combo wins/);
|
||||
assert.match(warns[0], /"omniroute\/dupe"/);
|
||||
});
|
||||
});
|
||||
34
@omniroute/opencode-plugin-v2/tests/compat.test.ts
Normal file
34
@omniroute/opencode-plugin-v2/tests/compat.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { assertContext } from "../src/compat.js";
|
||||
|
||||
function validContext() {
|
||||
return {
|
||||
options: { baseURL: "https://gw.example.com" },
|
||||
catalog: { transform: async () => {} },
|
||||
integration: { transform: async () => {} },
|
||||
};
|
||||
}
|
||||
|
||||
describe("assertContext", () => {
|
||||
it("throws on non-object ctx", () => {
|
||||
assert.throws(() => assertContext(null), /\[omniroute-v2\] contract breach/);
|
||||
});
|
||||
it("throws when catalog.transform is missing", () => {
|
||||
const ctx = { ...validContext(), catalog: {} };
|
||||
assert.throws(() => assertContext(ctx), /\[omniroute-v2\] contract breach/);
|
||||
});
|
||||
it("serves a catalog on a host that has no integration domain", () => {
|
||||
// The integration domain carries the credential flow, not the catalog.
|
||||
// Refusing to load without it would deny the whole plugin to a host that
|
||||
// simply does not implement that surface yet.
|
||||
assert.doesNotThrow(() => assertContext({ catalog: { transform: () => {} }, options: {} }));
|
||||
});
|
||||
it("throws when options is not an object", () => {
|
||||
const ctx = { ...validContext(), options: undefined };
|
||||
assert.throws(() => assertContext(ctx), /\[omniroute-v2\] contract breach/);
|
||||
});
|
||||
it("passes for a valid context", () => {
|
||||
assert.doesNotThrow(() => assertContext(validContext()));
|
||||
});
|
||||
});
|
||||
128
@omniroute/opencode-plugin-v2/tests/credentials.test.ts
Normal file
128
@omniroute/opencode-plugin-v2/tests/credentials.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { Logger } from "../src/shared/index.js";
|
||||
import { resolveApiKey, warnIfMissing } from "../src/credentials.js";
|
||||
|
||||
function collectingLogger(): { log: Logger; warnings: string[] } {
|
||||
const warnings: string[] = [];
|
||||
const log = {
|
||||
error: () => {},
|
||||
warn: (m: string) => warnings.push(m),
|
||||
info: () => {},
|
||||
debug: () => {},
|
||||
} as unknown as Logger;
|
||||
return { log, warnings };
|
||||
}
|
||||
|
||||
/** A host exposing `integration.connection`, optionally with a stored value. */
|
||||
function ctxWith(stored: unknown, opts: { withConnectionApi?: boolean } = {}): PluginContext {
|
||||
const integration =
|
||||
opts.withConnectionApi === false
|
||||
? {}
|
||||
: {
|
||||
connection: {
|
||||
active: async () =>
|
||||
stored === undefined ? undefined : { type: "credential", id: "c", label: "l" },
|
||||
resolve: async () => stored,
|
||||
},
|
||||
};
|
||||
return { integration } as unknown as PluginContext;
|
||||
}
|
||||
|
||||
const ENV = "OMNIROUTE_API_KEY";
|
||||
|
||||
async function withEnv<T>(value: string | undefined, fn: () => Promise<T>): Promise<T> {
|
||||
const previous = process.env[ENV];
|
||||
if (value === undefined) delete process.env[ENV];
|
||||
else process.env[ENV] = value;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env[ENV];
|
||||
else process.env[ENV] = previous;
|
||||
}
|
||||
}
|
||||
|
||||
describe("gateway credential resolution", () => {
|
||||
it("prefers the credential the host stored over one written in config", async () => {
|
||||
const { log } = collectingLogger();
|
||||
const got = await withEnv("from-env", () =>
|
||||
resolveApiKey(ctxWith({ type: "key", key: "from-host" }), "omniroute", "from-option", log)
|
||||
);
|
||||
assert.deepEqual(got, { key: "from-host", origin: "connection" });
|
||||
});
|
||||
|
||||
it("falls back to the configured option, then to the environment", async () => {
|
||||
const { log } = collectingLogger();
|
||||
const option = await withEnv("from-env", () =>
|
||||
resolveApiKey(ctxWith(undefined), "omniroute", "from-option", log)
|
||||
);
|
||||
assert.deepEqual(option, { key: "from-option", origin: "option" });
|
||||
const env = await withEnv("from-env", () =>
|
||||
resolveApiKey(ctxWith(undefined), "omniroute", undefined, log)
|
||||
);
|
||||
assert.deepEqual(env, { key: "from-env", origin: "env" });
|
||||
});
|
||||
|
||||
it("reports no key rather than pretending an empty one works", async () => {
|
||||
const { log, warnings } = collectingLogger();
|
||||
const got = await withEnv(undefined, () =>
|
||||
resolveApiKey(ctxWith(undefined), "omniroute", undefined, log)
|
||||
);
|
||||
assert.deepEqual(got, { key: "", origin: "missing" });
|
||||
warnIfMissing(got, "omniroute", log);
|
||||
assert.equal(warnings.length, 1);
|
||||
// The message must name every way out, or it sends the user hunting.
|
||||
assert.match(warnings[0] ?? "", /Connect the integration/);
|
||||
assert.match(warnings[0] ?? "", /"apiKey"/);
|
||||
assert.match(warnings[0] ?? "", new RegExp(ENV));
|
||||
});
|
||||
|
||||
it("declines an oauth credential instead of reading a token as a key", async () => {
|
||||
const { log, warnings } = collectingLogger();
|
||||
const got = await withEnv(undefined, () =>
|
||||
resolveApiKey(
|
||||
ctxWith({ type: "oauth", methodID: "m", refresh: "r", access: "a", expires: 0 }),
|
||||
"omniroute",
|
||||
undefined,
|
||||
log
|
||||
)
|
||||
);
|
||||
assert.equal(got.origin, "missing");
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0] ?? "", /oauth/);
|
||||
});
|
||||
|
||||
it("stays loadable on a host that has no connection api", async () => {
|
||||
const { log, warnings } = collectingLogger();
|
||||
const got = await withEnv(undefined, () =>
|
||||
resolveApiKey(
|
||||
ctxWith(undefined, { withConnectionApi: false }),
|
||||
"omniroute",
|
||||
"from-option",
|
||||
log
|
||||
)
|
||||
);
|
||||
assert.deepEqual(got, { key: "from-option", origin: "option" });
|
||||
assert.deepEqual(warnings, []);
|
||||
});
|
||||
|
||||
it("treats a lookup that throws as no credential, not as a failure to load", async () => {
|
||||
const { log, warnings } = collectingLogger();
|
||||
const ctx = {
|
||||
integration: {
|
||||
connection: {
|
||||
active: async () => {
|
||||
throw new Error("store unavailable");
|
||||
},
|
||||
resolve: async () => undefined,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginContext;
|
||||
const got = await withEnv(undefined, () => resolveApiKey(ctx, "omniroute", "from-option", log));
|
||||
assert.deepEqual(got, { key: "from-option", origin: "option" });
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0] ?? "", /store unavailable/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { defaultOmniRouteEnrichmentFetcher } from "../src/shared/enrich.js";
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
/**
|
||||
* A gateway routes the same model id through several upstream providers. The
|
||||
* overlay must keep them apart: one connection's price shown on another's
|
||||
* model is worse than no price at all, because it looks authoritative.
|
||||
*/
|
||||
describe("two providers selling the same model id keep their own overlay", () => {
|
||||
it("does not let the second provider's price land on the first one's model", async () => {
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
const ok = (body: unknown) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => body,
|
||||
});
|
||||
if (href.includes("/api/pricing/models"))
|
||||
return ok({
|
||||
providers: {
|
||||
cc: {
|
||||
id: "claude",
|
||||
alias: "cc",
|
||||
name: "Claude",
|
||||
models: [{ id: "shared", name: "From Claude" }],
|
||||
},
|
||||
kir: {
|
||||
id: "kiro",
|
||||
alias: "kir",
|
||||
name: "Kiro",
|
||||
models: [{ id: "shared", name: "From Kiro" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
if (href.includes("/api/pricing"))
|
||||
return ok({
|
||||
cc: { shared: { input: 3, output: 15 } },
|
||||
kir: { shared: { input: 99, output: 99 } },
|
||||
});
|
||||
return ok({});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const map = await defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000);
|
||||
assert.deepEqual(map.get("cc/shared")?.pricing, { input: 3, output: 15 });
|
||||
assert.deepEqual(map.get("kir/shared")?.pricing, { input: 99, output: 99 });
|
||||
// The bare fallback belongs to whoever claimed it first, and keeps that
|
||||
// provider's numbers rather than the last writer's.
|
||||
assert.equal(map.get("shared")?.providerAlias, "cc");
|
||||
assert.deepEqual(map.get("shared")?.pricing, { input: 3, output: 15 });
|
||||
assert.notEqual(map.get("shared"), map.get("cc/shared"), "the bare entry is its own object");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { applyEnrichment } from "../src/shared/enrich.js";
|
||||
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
|
||||
|
||||
function model(id: string, name = id): ModelV2 {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
} as unknown as ModelV2;
|
||||
}
|
||||
|
||||
describe("the enrichment overlay reaches the picker", () => {
|
||||
it("renders the upstream provider a model routes to", () => {
|
||||
const m = applyEnrichment(model("cc/sonnet"), {
|
||||
name: "Claude Sonnet 4.6",
|
||||
providerDisplayName: "Claude",
|
||||
providerAlias: "cc",
|
||||
});
|
||||
assert.equal(m.name, "Claude - Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
it("marks a free model and states the budget the user actually gets", () => {
|
||||
const m = applyEnrichment(model("pol/grok"), {
|
||||
name: "Grok 4 Fast",
|
||||
providerDisplayName: "Pollinations",
|
||||
freeType: "recurring-monthly" as const,
|
||||
monthlyTokens: 1_000_000,
|
||||
});
|
||||
assert.match(m.name, /\[Free\]/);
|
||||
assert.match(m.name, /Grok 4 Fast/);
|
||||
assert.match(
|
||||
m.name,
|
||||
/tokens\/month/,
|
||||
"the budget the gateway reports is stated, not just the fact it is free"
|
||||
);
|
||||
assert.equal(m.name, "[Free] Pollinations - Grok 4 Fast · 1M tokens/month");
|
||||
});
|
||||
|
||||
it("drops the tag when the caller turns it off", () => {
|
||||
const m = applyEnrichment(
|
||||
model("cc/sonnet"),
|
||||
{ name: "Claude Sonnet 4.6", providerDisplayName: "Claude" },
|
||||
{ providerTag: false }
|
||||
);
|
||||
assert.equal(m.name, "Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
it("never tags a combo: it routes across providers, not to one", () => {
|
||||
const m = applyEnrichment(
|
||||
model("combo-fast", "Combo Fast"),
|
||||
{ name: "Combo Fast", providerDisplayName: "Claude" },
|
||||
{ isCombo: true }
|
||||
);
|
||||
assert.equal(m.name, "Combo Fast");
|
||||
});
|
||||
|
||||
it("still overlays pricing", () => {
|
||||
const m = applyEnrichment(model("x"), { pricing: { input: 3, output: 15 } });
|
||||
assert.equal(m.cost.input, 3);
|
||||
assert.equal(m.cost.output, 15);
|
||||
});
|
||||
});
|
||||
106
@omniroute/opencode-plugin-v2/tests/enrichment-report.test.ts
Normal file
106
@omniroute/opencode-plugin-v2/tests/enrichment-report.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { Logger } from "../src/shared/index.js";
|
||||
import { createSourceErrorReporter } from "../src/enrichment-report.js";
|
||||
|
||||
function collectingLogger(): { log: Logger; warnings: string[] } {
|
||||
const warnings: string[] = [];
|
||||
const log = {
|
||||
error: () => {},
|
||||
warn: (m: string) => warnings.push(m),
|
||||
info: () => {},
|
||||
debug: () => {},
|
||||
} as unknown as Logger;
|
||||
return { log, warnings };
|
||||
}
|
||||
|
||||
describe("degraded enrichment is reported, not swallowed", () => {
|
||||
it("names the endpoint and what the catalog loses", () => {
|
||||
const { log, warnings } = collectingLogger();
|
||||
createSourceErrorReporter(log, false)("/api/pricing", "HTTP 500");
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0] ?? "", /\/api\/pricing/);
|
||||
assert.match(warnings[0] ?? "", /HTTP 500/);
|
||||
assert.match(warnings[0] ?? "", /pricing are degraded/);
|
||||
});
|
||||
|
||||
it("points a 403 at the management token when the inference key stands in", () => {
|
||||
const { log, warnings } = collectingLogger();
|
||||
createSourceErrorReporter(log, true)("/api/pricing/models", "HTTP 403");
|
||||
assert.match(warnings[0] ?? "", /managementReadToken/);
|
||||
assert.match(warnings[0] ?? "", /falls back to "apiKey"/);
|
||||
});
|
||||
|
||||
it("does not blame the fallback when a management token was configured", () => {
|
||||
const { log, warnings } = collectingLogger();
|
||||
createSourceErrorReporter(log, false)("/api/pricing/models", "HTTP 403");
|
||||
assert.match(warnings[0] ?? "", /was rejected/);
|
||||
assert.doesNotMatch(warnings[0] ?? "", /falls back/);
|
||||
});
|
||||
|
||||
it("keeps a transport failure free of auth advice", () => {
|
||||
const { log, warnings } = collectingLogger();
|
||||
createSourceErrorReporter(log, true)("/api/pricing", "connect ECONNREFUSED");
|
||||
assert.doesNotMatch(warnings[0] ?? "", /managementReadToken/);
|
||||
});
|
||||
|
||||
it("warns once per endpoint so a refresh loop cannot spam the log", () => {
|
||||
const { log, warnings } = collectingLogger();
|
||||
const report = createSourceErrorReporter(log, true);
|
||||
report("/api/pricing", "HTTP 403");
|
||||
report("/api/pricing", "HTTP 403");
|
||||
report("/api/free-tier/summary", "HTTP 403");
|
||||
assert.equal(warnings.length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the library path reports too, not only the plugin", () => {
|
||||
it("passes a refused /api/providers up to the caller through publishCatalog", async () => {
|
||||
const { publishCatalog } = await import("../src/catalog.js");
|
||||
const seen: Array<[string, string]> = [];
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
const ok = (body: unknown) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => body,
|
||||
});
|
||||
if (href.includes("/api/providers"))
|
||||
return { ok: false, status: 403, statusText: "Forbidden", json: async () => ({}) };
|
||||
if (href.includes("/api/pricing") || href.includes("/api/free-tier"))
|
||||
return { ok: false, status: 403, statusText: "Forbidden", json: async () => ({}) };
|
||||
if (href.includes("/api/combos")) return ok({ combos: [] });
|
||||
return ok({ data: [{ id: "m1" }] });
|
||||
}) as unknown as typeof fetch;
|
||||
const draft = {
|
||||
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
|
||||
model: {
|
||||
update: (_p: string, _m: string, fn: (x: Record<string, unknown>) => void) => fn({}),
|
||||
},
|
||||
};
|
||||
try {
|
||||
await publishCatalog(
|
||||
draft as never,
|
||||
{
|
||||
providerId: "omni",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 1000,
|
||||
usableOnly: true,
|
||||
} as never,
|
||||
{ onSourceError: (endpoint, reason) => seen.push([endpoint, reason]) }
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
const endpoints = seen.map(([e]) => e);
|
||||
assert.ok(
|
||||
endpoints.includes("/api/providers"),
|
||||
`the usable filter must say it was refused, got ${JSON.stringify(endpoints)}`
|
||||
);
|
||||
assert.ok(endpoints.some((e) => e.startsWith("/api/pricing")));
|
||||
});
|
||||
});
|
||||
124
@omniroute/opencode-plugin-v2/tests/enrichment.test.ts
Normal file
124
@omniroute/opencode-plugin-v2/tests/enrichment.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import type { OmniRouteEnrichmentMap } from "../src/shared/index.js";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
|
||||
function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft } {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
const draft = {
|
||||
provider: {
|
||||
list: () => [],
|
||||
get: (id: string) => providers.get(id) as never,
|
||||
update: (id: string, fn: (p: ProviderV2Info) => void) => {
|
||||
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
|
||||
fn(p);
|
||||
providers.set(id, p);
|
||||
},
|
||||
remove: () => {},
|
||||
},
|
||||
model: {
|
||||
get: () => undefined,
|
||||
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
|
||||
const k = pid + "/" + mid;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
|
||||
fn(m);
|
||||
models.set(k, m);
|
||||
},
|
||||
remove: () => {},
|
||||
default: { get: () => undefined, set: () => {} },
|
||||
},
|
||||
} as CatalogDraft;
|
||||
return { models, draft };
|
||||
}
|
||||
|
||||
const baseOpts = {
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 300000,
|
||||
usableOnly: false,
|
||||
};
|
||||
|
||||
const stubModels = async () => [{ id: "cc/m1", context_length: 1000 }];
|
||||
|
||||
describe("catalog enrichment source", () => {
|
||||
it("applies names and pricing from an injected enrichmentFetcher", async () => {
|
||||
const { models, draft } = fakeDraft();
|
||||
const enrichment: OmniRouteEnrichmentMap = new Map([
|
||||
["cc/m1", { name: "Model One", pricing: { input: 3, output: 15 } }],
|
||||
["m1", { name: "Model One", pricing: { input: 3, output: 15 } }],
|
||||
]);
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: stubModels,
|
||||
combosFetcher: async () => [],
|
||||
enrichmentFetcher: async () => enrichment,
|
||||
});
|
||||
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
|
||||
const m = models.get("omniroute/cc/m1");
|
||||
assert.ok(m);
|
||||
assert.equal(m?.name, "Model One");
|
||||
assert.equal(m?.cost[0].input, 3);
|
||||
assert.equal(m?.cost[0].output, 15);
|
||||
});
|
||||
|
||||
it("stays fail-open when the enrichment fetch throws", async () => {
|
||||
const { models, draft } = fakeDraft();
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const res = await publishCatalog(draft, baseOpts, {
|
||||
fetcher: stubModels,
|
||||
combosFetcher: async () => [],
|
||||
enrichmentFetcher: async () => {
|
||||
throw new Error("pricing down");
|
||||
},
|
||||
});
|
||||
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
|
||||
assert.ok(models.get("omniroute/cc/m1"));
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.ok(warns.some((w) => w.includes("enrichment fetch failed")));
|
||||
});
|
||||
|
||||
it("forwards the enrichment timeout and skips the fetch when enrichment is false", async () => {
|
||||
const { draft } = fakeDraft();
|
||||
let seenTimeout: number | undefined;
|
||||
await publishCatalog(
|
||||
draft,
|
||||
{ ...baseOpts, timeouts: { enrichment: 4321 } },
|
||||
{
|
||||
fetcher: stubModels,
|
||||
combosFetcher: async () => [],
|
||||
enrichmentFetcher: async (_base, _key, timeout) => {
|
||||
seenTimeout = timeout;
|
||||
return new Map();
|
||||
},
|
||||
}
|
||||
);
|
||||
assert.equal(seenTimeout, 4321);
|
||||
|
||||
const { draft: draft2 } = fakeDraft();
|
||||
let called = 0;
|
||||
await publishCatalog(
|
||||
draft2,
|
||||
{ ...baseOpts, enrichment: false },
|
||||
{
|
||||
fetcher: stubModels,
|
||||
combosFetcher: async () => [],
|
||||
enrichmentFetcher: async () => {
|
||||
called += 1;
|
||||
return new Map();
|
||||
},
|
||||
}
|
||||
);
|
||||
assert.equal(called, 0);
|
||||
});
|
||||
});
|
||||
68
@omniroute/opencode-plugin-v2/tests/fixtures/catalog.json
vendored
Normal file
68
@omniroute/opencode-plugin-v2/tests/fixtures/catalog.json
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"id": "m-alpha",
|
||||
"object": "model",
|
||||
"owned_by": "openai",
|
||||
"context_length": 128000,
|
||||
"max_output_tokens": 4096,
|
||||
"input_modalities": ["text", "image"],
|
||||
"output_modalities": ["text"],
|
||||
"capabilities": { "tool_calling": true, "reasoning": true }
|
||||
},
|
||||
{
|
||||
"id": "m-beta",
|
||||
"object": "model",
|
||||
"owned_by": "openai",
|
||||
"context_length": 64000,
|
||||
"max_output_tokens": 2048,
|
||||
"input_modalities": ["text"],
|
||||
"output_modalities": ["text"],
|
||||
"capabilities": { "tool_calling": false, "thinking": true, "temperature": false }
|
||||
},
|
||||
{
|
||||
"id": "cc/m-gamma",
|
||||
"object": "model",
|
||||
"owned_by": "anthropic",
|
||||
"context_length": 200000,
|
||||
"max_output_tokens": 32000,
|
||||
"input_modalities": ["text", "image", "pdf"],
|
||||
"output_modalities": ["text"],
|
||||
"capabilities": { "tool_calling": true, "reasoning": true, "vision": true }
|
||||
},
|
||||
{
|
||||
"id": "local-delta",
|
||||
"object": "model",
|
||||
"context_length": 32768,
|
||||
"capabilities": { "effort_tiers": ["low", "high"] }
|
||||
},
|
||||
{
|
||||
"id": "good-combo",
|
||||
"object": "model",
|
||||
"owned_by": "omniroute",
|
||||
"context_length": 100000,
|
||||
"max_output_tokens": 8000,
|
||||
"capabilities": { "tool_calling": true }
|
||||
}
|
||||
],
|
||||
"combos": [
|
||||
{
|
||||
"id": "combo-fast",
|
||||
"name": "Combo Fast",
|
||||
"strategy": "priority",
|
||||
"models": [
|
||||
{ "kind": "model", "model": "m-alpha" },
|
||||
{ "kind": "model", "model": "cc/m-gamma" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "good-combo",
|
||||
"name": "Good Combo",
|
||||
"strategy": "priority",
|
||||
"models": [
|
||||
{ "kind": "model", "model": "m-beta" },
|
||||
{ "kind": "model", "model": "local-delta" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
301
@omniroute/opencode-plugin-v2/tests/fixtures/v1-parity.json
vendored
Normal file
301
@omniroute/opencode-plugin-v2/tests/fixtures/v1-parity.json
vendored
Normal file
@@ -0,0 +1,301 @@
|
||||
{
|
||||
"_note": "Recorded output of @omniroute/opencode-plugin (v1) against tests/fixtures/catalog.json. This is the contract this package claims parity with; regenerate only when intentionally re-basing on a newer v1.",
|
||||
"v1PluginVersion": "0.2.1",
|
||||
"hookId": "opencode-omniroute",
|
||||
"publishedKeys": ["combo-fast", "good-combo", "local-delta", "m-alpha", "m-beta"],
|
||||
"comboSlugs": ["combo-fast", "good-combo"],
|
||||
"mappedModels": {
|
||||
"m-alpha": {
|
||||
"id": "omniroute/m-alpha",
|
||||
"name": "m-alpha",
|
||||
"capabilities": {
|
||||
"temperature": true,
|
||||
"reasoning": true,
|
||||
"attachment": false,
|
||||
"toolcall": true,
|
||||
"input": {
|
||||
"text": true,
|
||||
"audio": false,
|
||||
"image": true,
|
||||
"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": 128000,
|
||||
"output": 4096
|
||||
},
|
||||
"status": "active",
|
||||
"options": {},
|
||||
"headers": {},
|
||||
"release_date": "",
|
||||
"providerID": "omniroute",
|
||||
"api": {
|
||||
"id": "openai-compatible",
|
||||
"url": "https://gw.example.com/v1",
|
||||
"npm": "@ai-sdk/openai-compatible"
|
||||
}
|
||||
},
|
||||
"m-beta": {
|
||||
"id": "omniroute/m-beta",
|
||||
"name": "m-beta",
|
||||
"capabilities": {
|
||||
"temperature": false,
|
||||
"reasoning": true,
|
||||
"attachment": false,
|
||||
"toolcall": false,
|
||||
"input": {
|
||||
"text": true,
|
||||
"audio": false,
|
||||
"image": false,
|
||||
"video": false,
|
||||
"pdf": false
|
||||
},
|
||||
"output": {
|
||||
"text": true,
|
||||
"audio": false,
|
||||
"image": false,
|
||||
"video": false,
|
||||
"pdf": false
|
||||
},
|
||||
"interleaved": true
|
||||
},
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cache": {
|
||||
"read": 0,
|
||||
"write": 0
|
||||
}
|
||||
},
|
||||
"limit": {
|
||||
"context": 64000,
|
||||
"output": 2048
|
||||
},
|
||||
"status": "active",
|
||||
"options": {},
|
||||
"headers": {},
|
||||
"release_date": "",
|
||||
"providerID": "omniroute",
|
||||
"api": {
|
||||
"id": "openai-compatible",
|
||||
"url": "https://gw.example.com/v1",
|
||||
"npm": "@ai-sdk/openai-compatible"
|
||||
}
|
||||
},
|
||||
"local-delta": {
|
||||
"id": "omniroute/local-delta",
|
||||
"name": "local-delta",
|
||||
"capabilities": {
|
||||
"temperature": true,
|
||||
"reasoning": false,
|
||||
"attachment": false,
|
||||
"toolcall": false,
|
||||
"input": {
|
||||
"text": true,
|
||||
"audio": false,
|
||||
"image": false,
|
||||
"video": false,
|
||||
"pdf": false
|
||||
},
|
||||
"output": {
|
||||
"text": true,
|
||||
"audio": false,
|
||||
"image": false,
|
||||
"video": false,
|
||||
"pdf": false
|
||||
},
|
||||
"interleaved": false
|
||||
},
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cache": {
|
||||
"read": 0,
|
||||
"write": 0
|
||||
}
|
||||
},
|
||||
"limit": {
|
||||
"context": 32768,
|
||||
"output": 0
|
||||
},
|
||||
"variants": {
|
||||
"low": {
|
||||
"reasoningEffort": "low"
|
||||
},
|
||||
"high": {
|
||||
"reasoningEffort": "high"
|
||||
}
|
||||
},
|
||||
"status": "active",
|
||||
"options": {},
|
||||
"headers": {},
|
||||
"release_date": "",
|
||||
"providerID": "omniroute",
|
||||
"api": {
|
||||
"id": "openai-compatible",
|
||||
"url": "https://gw.example.com/v1",
|
||||
"npm": "@ai-sdk/openai-compatible"
|
||||
}
|
||||
},
|
||||
"good-combo": {
|
||||
"id": "omniroute/good-combo",
|
||||
"name": "good-combo",
|
||||
"capabilities": {
|
||||
"temperature": true,
|
||||
"reasoning": false,
|
||||
"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": 100000,
|
||||
"output": 8000
|
||||
},
|
||||
"status": "active",
|
||||
"options": {},
|
||||
"headers": {},
|
||||
"release_date": "",
|
||||
"providerID": "omniroute",
|
||||
"api": {
|
||||
"id": "openai-compatible",
|
||||
"url": "https://gw.example.com/v1",
|
||||
"npm": "@ai-sdk/openai-compatible"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mappedCombos": {
|
||||
"combo-fast": {
|
||||
"id": "combo-fast",
|
||||
"providerID": "omniroute",
|
||||
"api": {
|
||||
"id": "openai-compatible",
|
||||
"url": "https://gw.example.com/v1",
|
||||
"npm": "@ai-sdk/openai-compatible"
|
||||
},
|
||||
"name": "Combo Fast",
|
||||
"capabilities": {
|
||||
"temperature": true,
|
||||
"reasoning": true,
|
||||
"attachment": false,
|
||||
"toolcall": true,
|
||||
"input": {
|
||||
"text": true,
|
||||
"audio": false,
|
||||
"image": true,
|
||||
"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": 128000,
|
||||
"output": 4096
|
||||
},
|
||||
"status": "active",
|
||||
"options": {},
|
||||
"headers": {},
|
||||
"release_date": ""
|
||||
},
|
||||
"good-combo": {
|
||||
"id": "good-combo",
|
||||
"providerID": "omniroute",
|
||||
"api": {
|
||||
"id": "openai-compatible",
|
||||
"url": "https://gw.example.com/v1",
|
||||
"npm": "@ai-sdk/openai-compatible"
|
||||
},
|
||||
"name": "Good Combo",
|
||||
"capabilities": {
|
||||
"temperature": false,
|
||||
"reasoning": false,
|
||||
"attachment": false,
|
||||
"toolcall": false,
|
||||
"input": {
|
||||
"text": true,
|
||||
"audio": false,
|
||||
"image": false,
|
||||
"video": false,
|
||||
"pdf": false
|
||||
},
|
||||
"output": {
|
||||
"text": true,
|
||||
"audio": false,
|
||||
"image": false,
|
||||
"video": false,
|
||||
"pdf": false
|
||||
},
|
||||
"interleaved": false
|
||||
},
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cache": {
|
||||
"read": 0,
|
||||
"write": 0
|
||||
}
|
||||
},
|
||||
"limit": {
|
||||
"context": 32768,
|
||||
"output": 2048
|
||||
},
|
||||
"status": "active",
|
||||
"options": {},
|
||||
"headers": {},
|
||||
"release_date": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
226
@omniroute/opencode-plugin-v2/tests/gemini-language.test.ts
Normal file
226
@omniroute/opencode-plugin-v2/tests/gemini-language.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { Logger } from "../src/shared/index.js";
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider";
|
||||
import plugin from "../src/index.js";
|
||||
import { sanitizeToolSchemasFor } from "../src/gemini-language.js";
|
||||
|
||||
function collectingLogger(): { log: Logger; warnings: string[] } {
|
||||
const warnings: string[] = [];
|
||||
const log = {
|
||||
error: () => {},
|
||||
warn: (m: string) => warnings.push(m),
|
||||
info: () => {},
|
||||
debug: () => {},
|
||||
} as unknown as Logger;
|
||||
return { log, warnings };
|
||||
}
|
||||
|
||||
/** Records what the underlying model was actually asked to do. */
|
||||
function recordingModel(): { model: Record<string, unknown>; seen: unknown[] } {
|
||||
const seen: unknown[] = [];
|
||||
const model = {
|
||||
specificationVersion: "v3",
|
||||
modelId: "gemini-2.5-flash",
|
||||
provider: "omniroute",
|
||||
doGenerate: async (options: unknown) => {
|
||||
seen.push(options);
|
||||
return { content: [], finishReason: "stop", usage: {}, warnings: [] };
|
||||
},
|
||||
doStream: async (options: unknown) => {
|
||||
seen.push(options);
|
||||
return { stream: new ReadableStream() };
|
||||
},
|
||||
};
|
||||
return { model, seen };
|
||||
}
|
||||
|
||||
const dirtyTools = [
|
||||
{
|
||||
type: "function",
|
||||
name: "edit",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: { p: { type: "string" } },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("Gemini sanitising on the language model", () => {
|
||||
it("cleans the tool schemas a Gemini model would reject, on both call paths", async () => {
|
||||
const { log } = collectingLogger();
|
||||
const { model, seen } = recordingModel();
|
||||
const wrapped = sanitizeToolSchemasFor(
|
||||
model as unknown as LanguageModelV3,
|
||||
"gemini-2.5-flash",
|
||||
log
|
||||
);
|
||||
await wrapped.doGenerate({ prompt: [], tools: structuredClone(dirtyTools) } as never);
|
||||
await wrapped.doStream({ prompt: [], tools: structuredClone(dirtyTools) } as never);
|
||||
assert.equal(seen.length, 2);
|
||||
for (const options of seen) {
|
||||
const schema = (options as { tools: Array<{ inputSchema: Record<string, unknown> }> })
|
||||
.tools[0]!.inputSchema;
|
||||
assert.equal("additionalProperties" in schema, false);
|
||||
assert.equal(
|
||||
((schema["properties"] as Record<string, Record<string, unknown>>)["p"] ?? {})["type"],
|
||||
"string"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves a non-Gemini model's request exactly as it was", async () => {
|
||||
const { log } = collectingLogger();
|
||||
const { model, seen } = recordingModel();
|
||||
const wrapped = sanitizeToolSchemasFor(
|
||||
model as unknown as LanguageModelV3,
|
||||
"claude-opus-5",
|
||||
log
|
||||
);
|
||||
assert.equal(wrapped, model, "a model that needs no cleaning must not even be wrapped");
|
||||
await (wrapped as unknown as { doGenerate: (o: unknown) => Promise<unknown> }).doGenerate({
|
||||
prompt: [],
|
||||
tools: structuredClone(dirtyTools),
|
||||
});
|
||||
const schema = (seen[0] as { tools: Array<{ inputSchema: Record<string, unknown> }> }).tools[0]!
|
||||
.inputSchema;
|
||||
assert.equal(schema["additionalProperties"], false);
|
||||
});
|
||||
|
||||
it("forwards a request with no tools untouched", async () => {
|
||||
const { log } = collectingLogger();
|
||||
const { model, seen } = recordingModel();
|
||||
const wrapped = sanitizeToolSchemasFor(
|
||||
model as unknown as LanguageModelV3,
|
||||
"gemini-2.5-flash",
|
||||
log
|
||||
);
|
||||
await wrapped.doGenerate({ prompt: [] } as never);
|
||||
assert.deepEqual(seen[0], { prompt: [] });
|
||||
});
|
||||
|
||||
it("keeps the properties the model still needs, and says once that it cleaned", async () => {
|
||||
const { log, warnings } = collectingLogger();
|
||||
const { model, seen } = recordingModel();
|
||||
const wrapped = sanitizeToolSchemasFor(
|
||||
model as unknown as LanguageModelV3,
|
||||
"gemini-2.5-flash",
|
||||
log
|
||||
);
|
||||
await wrapped.doGenerate({ prompt: [], tools: structuredClone(dirtyTools) } as never);
|
||||
await wrapped.doGenerate({ prompt: [], tools: structuredClone(dirtyTools) } as never);
|
||||
const schema = (seen[0] as { tools: Array<{ inputSchema: Record<string, any> }> }).tools[0]!
|
||||
.inputSchema;
|
||||
assert.equal(schema["properties"]["p"]["type"], "string");
|
||||
assert.equal(warnings.length, 0, "a routine cleaning is not a warning");
|
||||
});
|
||||
|
||||
it("passes the untouched model through when the host hands over nothing to wrap", () => {
|
||||
const { log } = collectingLogger();
|
||||
assert.equal(
|
||||
sanitizeToolSchemasFor(undefined as LanguageModelV3 | undefined, "gemini-2.5-flash", log),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Gemini sanitising is wired into the host, and only where it belongs", () => {
|
||||
interface LanguageInput {
|
||||
model: { id: string; providerID: string };
|
||||
sdk: unknown;
|
||||
options: Record<string, unknown>;
|
||||
language?: LanguageModelV3;
|
||||
}
|
||||
|
||||
function hostCtx(opts: { geminiSanitization?: boolean; withAisdk?: boolean }): {
|
||||
ctx: Record<string, unknown>;
|
||||
languageCallbacks: Array<(input: LanguageInput) => void | Promise<void>>;
|
||||
} {
|
||||
const languageCallbacks: Array<(input: LanguageInput) => void | Promise<void>> = [];
|
||||
const registration = Promise.resolve({ dispose: async () => {} });
|
||||
const options: Record<string, unknown> = {
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "omni",
|
||||
apiKey: "k",
|
||||
};
|
||||
if (opts.geminiSanitization !== undefined)
|
||||
options["geminiSanitization"] = opts.geminiSanitization;
|
||||
const ctx: Record<string, unknown> = {
|
||||
options,
|
||||
catalog: { transform: () => registration, reload: async () => {} },
|
||||
integration: { transform: () => registration },
|
||||
};
|
||||
if (opts.withAisdk !== false) {
|
||||
ctx["aisdk"] = {
|
||||
language: (cb: (input: LanguageInput) => void | Promise<void>) => {
|
||||
languageCallbacks.push(cb);
|
||||
return registration;
|
||||
},
|
||||
sdk: () => registration,
|
||||
};
|
||||
}
|
||||
return { ctx, languageCallbacks };
|
||||
}
|
||||
|
||||
async function setupPlugin(ctx: Record<string, unknown>): Promise<void> {
|
||||
const warn = console.warn;
|
||||
const log = console.log;
|
||||
console.warn = () => {};
|
||||
console.log = () => {};
|
||||
try {
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
console.log = log;
|
||||
}
|
||||
}
|
||||
|
||||
const bareModel = { specificationVersion: "v3", modelId: "x" } as unknown as LanguageModelV3;
|
||||
|
||||
it("wraps a Gemini model of this provider and leaves every other one alone", async () => {
|
||||
const { ctx, languageCallbacks } = hostCtx({});
|
||||
await setupPlugin(ctx);
|
||||
assert.equal(languageCallbacks.length, 1);
|
||||
const wrapOf = async (model: { id: string; providerID: string }) => {
|
||||
const input: LanguageInput = { model, sdk: {}, options: {}, language: bareModel };
|
||||
await languageCallbacks[0]!(input);
|
||||
return input.language !== bareModel;
|
||||
};
|
||||
assert.equal(await wrapOf({ id: "gemini-2.5-flash", providerID: "omni" }), true);
|
||||
assert.equal(await wrapOf({ id: "claude-opus-5", providerID: "omni" }), false);
|
||||
assert.equal(
|
||||
await wrapOf({ id: "gemini-2.5-flash", providerID: "some-other-provider" }),
|
||||
false,
|
||||
"another provider's models are none of this plugin's business"
|
||||
);
|
||||
});
|
||||
|
||||
it("registers nothing when the option is turned off", async () => {
|
||||
const { ctx, languageCallbacks } = hostCtx({ geminiSanitization: false });
|
||||
await setupPlugin(ctx);
|
||||
assert.deepEqual(languageCallbacks, []);
|
||||
});
|
||||
|
||||
it("still loads on a host that exposes no aisdk domain", async () => {
|
||||
const { ctx } = hostCtx({ withAisdk: false });
|
||||
await setupPlugin(ctx);
|
||||
});
|
||||
|
||||
it("keeps the catalog when the host refuses the language hook", async () => {
|
||||
const registration = Promise.resolve({ dispose: async () => {} });
|
||||
const ctx: Record<string, unknown> = {
|
||||
options: { baseURL: "https://gw.example.com", providerId: "omni", apiKey: "k" },
|
||||
catalog: { transform: () => registration, reload: async () => {} },
|
||||
integration: { transform: () => registration },
|
||||
aisdk: {
|
||||
language: () => {
|
||||
throw new Error("host says no");
|
||||
},
|
||||
sdk: () => registration,
|
||||
},
|
||||
};
|
||||
// Must not reject: tool-schema cleaning is an extra, the catalog is the job.
|
||||
await setupPlugin(ctx);
|
||||
});
|
||||
});
|
||||
164
@omniroute/opencode-plugin-v2/tests/host-contract.test.ts
Normal file
164
@omniroute/opencode-plugin-v2/tests/host-contract.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import {
|
||||
publishCatalog,
|
||||
type BinaryCompatModel,
|
||||
type BinaryCompatProvider,
|
||||
type BinaryCompatVariant,
|
||||
} from "../src/catalog.js";
|
||||
import { detectHostContract, emitsLegacyFields } from "../src/compat.js";
|
||||
|
||||
/**
|
||||
* A host seed shape. `legacy` mirrors `Provider.Info.empty` as observed on
|
||||
* `@opencode-ai/cli` 0.0.0-beta-17823; `sdk` mirrors the pinned SDK contract;
|
||||
* `bare` is a host that discloses neither.
|
||||
*/
|
||||
type SeedKind = "legacy" | "sdk" | "bare";
|
||||
|
||||
function providerSeed(id: string, kind: SeedKind): ProviderV2Info {
|
||||
if (kind === "legacy") {
|
||||
return { id, name: id, activation: "auto", package: "" } as unknown as ProviderV2Info;
|
||||
}
|
||||
if (kind === "sdk") {
|
||||
return { id, name: id, api: { type: "aisdk", package: "", url: "" } } as ProviderV2Info;
|
||||
}
|
||||
return { id } as ProviderV2Info;
|
||||
}
|
||||
|
||||
function fakeDraft(kind: SeedKind): {
|
||||
draft: CatalogDraft;
|
||||
providers: Map<string, ProviderV2Info>;
|
||||
models: Map<string, ModelV2Info>;
|
||||
} {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
const draft = {
|
||||
provider: {
|
||||
list: () => [],
|
||||
get: (id: string) => providers.get(id) as never,
|
||||
update: (id: string, fn: (p: ProviderV2Info) => void) => {
|
||||
const p = providers.get(id) ?? providerSeed(id, kind);
|
||||
fn(p);
|
||||
providers.set(id, p);
|
||||
},
|
||||
remove: () => {},
|
||||
},
|
||||
model: {
|
||||
get: () => undefined,
|
||||
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
|
||||
const k = pid + "/" + mid;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
|
||||
fn(m);
|
||||
models.set(k, m);
|
||||
},
|
||||
remove: () => {},
|
||||
default: { get: () => undefined, set: () => {} },
|
||||
},
|
||||
} as CatalogDraft;
|
||||
return { draft, providers, models };
|
||||
}
|
||||
|
||||
const baseOpts = {
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 300000,
|
||||
usableOnly: false,
|
||||
};
|
||||
|
||||
const rawModel = {
|
||||
id: "af/chat-latest",
|
||||
capabilities: { effort_tiers: ["low", "high"] },
|
||||
};
|
||||
|
||||
async function publish(kind: SeedKind) {
|
||||
const { draft, providers, models } = fakeDraft(kind);
|
||||
await publishCatalog(draft, baseOpts, {
|
||||
fetcher: async () => [rawModel],
|
||||
combosFetcher: async () => [],
|
||||
});
|
||||
const provider = providers.get("omniroute");
|
||||
const model = models.get("omniroute/af/chat-latest");
|
||||
assert.ok(provider, "provider must be published");
|
||||
assert.ok(model, "model must be published");
|
||||
return { provider: provider as BinaryCompatProvider, model: model as BinaryCompatModel };
|
||||
}
|
||||
|
||||
describe("host contract detection", () => {
|
||||
it("reads the contract off the seeded object, not off a version", () => {
|
||||
assert.equal(detectHostContract({ id: "x", package: "" }), "legacy-package");
|
||||
assert.equal(detectHostContract({ id: "x", api: { type: "aisdk" } }), "sdk-api");
|
||||
assert.equal(detectHostContract({ id: "x" }), "unknown");
|
||||
assert.equal(detectHostContract({ id: "x", api: {}, package: "" }), "unknown");
|
||||
assert.equal(detectHostContract(undefined), "unknown");
|
||||
assert.equal(detectHostContract("nope"), "unknown");
|
||||
});
|
||||
|
||||
it("publishes the legacy fields for every contract but the sdk one", () => {
|
||||
assert.equal(emitsLegacyFields("legacy-package"), true);
|
||||
assert.equal(emitsLegacyFields("unknown"), true);
|
||||
assert.equal(emitsLegacyFields("sdk-api"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy-package host (cli 0.0.0-beta-17823)", () => {
|
||||
it("publishes package and settings.baseURL on the provider", async () => {
|
||||
const { provider } = await publish("legacy");
|
||||
assert.equal(provider.api.type, "aisdk");
|
||||
assert.equal(provider.package, "aisdk:@ai-sdk/openai-compatible");
|
||||
assert.equal(provider.settings.baseURL, "https://gw.example.com/v1");
|
||||
});
|
||||
|
||||
it("publishes package, settings.baseURL and headers on the model", async () => {
|
||||
const { model } = await publish("legacy");
|
||||
if (model.api.type !== "aisdk") throw new Error("model api must be aisdk");
|
||||
assert.equal(model.package, `aisdk:${model.api.package}`);
|
||||
assert.equal(model.package, "aisdk:@ai-sdk/openai-compatible");
|
||||
assert.equal(model.settings.baseURL, model.api.url);
|
||||
assert.deepEqual(model.headers, model.request.headers);
|
||||
});
|
||||
|
||||
it("publishes each variant in both shapes", async () => {
|
||||
const { model } = await publish("legacy");
|
||||
const variants = model.variants as BinaryCompatVariant[];
|
||||
assert.deepEqual(
|
||||
variants.map((v) => v.id),
|
||||
["low", "high"]
|
||||
);
|
||||
for (const variant of variants) {
|
||||
assert.deepEqual(variant.settings, { reasoningEffort: variant.id });
|
||||
// The pinned-contract shape stays intact next to the legacy one.
|
||||
assert.deepEqual(variant.body, { reasoningEffort: variant.id });
|
||||
assert.deepEqual(variant.headers, {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("sdk-api host", () => {
|
||||
it("publishes the api block only, with no legacy field", async () => {
|
||||
const { provider, model } = await publish("sdk");
|
||||
assert.equal(provider.api.type, "aisdk");
|
||||
assert.equal("package" in provider, false);
|
||||
assert.equal("settings" in provider, false);
|
||||
assert.equal("package" in model, false);
|
||||
assert.equal("settings" in model, false);
|
||||
assert.equal("headers" in model, false);
|
||||
for (const variant of model.variants) {
|
||||
assert.equal("settings" in variant, false);
|
||||
assert.deepEqual(variant.body, { reasoningEffort: variant.id });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("undisclosed host contract", () => {
|
||||
it("falls back to the superset so an unknown host still routes", async () => {
|
||||
const { provider, model } = await publish("bare");
|
||||
assert.equal(provider.package, "aisdk:@ai-sdk/openai-compatible");
|
||||
assert.equal(model.package, "aisdk:@ai-sdk/openai-compatible");
|
||||
assert.ok(model.settings.baseURL);
|
||||
assert.ok(model.api);
|
||||
});
|
||||
});
|
||||
244
@omniroute/opencode-plugin-v2/tests/index.test.ts
Normal file
244
@omniroute/opencode-plugin-v2/tests/index.test.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import plugin from "../src/index.js";
|
||||
|
||||
interface CapturedCall {
|
||||
kind: "catalog" | "integration";
|
||||
}
|
||||
|
||||
interface FakeCtx {
|
||||
options: Record<string, unknown>;
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => unknown) => Promise<{ dispose: () => Promise<void> }>;
|
||||
};
|
||||
integration: {
|
||||
transform: (cb: (draft: unknown) => unknown) => Promise<{ dispose: () => Promise<void> }>;
|
||||
};
|
||||
}
|
||||
|
||||
function fakeCtx(options: Record<string, unknown>, seen: CapturedCall[]): FakeCtx {
|
||||
return {
|
||||
options,
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => unknown) => {
|
||||
seen.push({ kind: "catalog" });
|
||||
assert.equal(typeof cb, "function");
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
},
|
||||
integration: {
|
||||
transform: (cb: (draft: unknown) => unknown) => {
|
||||
seen.push({ kind: "integration" });
|
||||
assert.equal(typeof cb, "function");
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("plugin-v2 entrypoint", () => {
|
||||
it("boot line is silent by default, visible with startupDebug", async () => {
|
||||
const seen: CapturedCall[] = [];
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(args.map(String).join(" "));
|
||||
};
|
||||
try {
|
||||
await (plugin as unknown as { setup: (ctx: FakeCtx) => Promise<void> }).setup(
|
||||
fakeCtx({ baseURL: "https://gw.example.com", providerId: "omniroute" }, seen)
|
||||
);
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.ok(
|
||||
!warns.some((line) => line.includes("init providerId=")),
|
||||
`boot line must stay silent by default, got: ${JSON.stringify(warns)}`
|
||||
);
|
||||
assert.deepEqual(
|
||||
seen.map((s) => s.kind),
|
||||
["catalog", "integration"]
|
||||
);
|
||||
const seen2: CapturedCall[] = [];
|
||||
const warns2: string[] = [];
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns2.push(args.map(String).join(" "));
|
||||
};
|
||||
try {
|
||||
await (plugin as unknown as { setup: (ctx: FakeCtx) => Promise<void> }).setup(
|
||||
fakeCtx(
|
||||
{ baseURL: "https://gw.example.com", providerId: "omniroute", startupDebug: true },
|
||||
seen2
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.ok(
|
||||
warns2.some((line) => line.includes("init providerId=omniroute")),
|
||||
`boot line must show with startupDebug, got: ${JSON.stringify(warns2)}`
|
||||
);
|
||||
});
|
||||
|
||||
it("registers transforms synchronously: captures exist without awaiting fetch", async () => {
|
||||
const seen: CapturedCall[] = [];
|
||||
const ctx = fakeCtx({ baseURL: "https://gw.example.com" }, seen);
|
||||
const pending = (plugin as unknown as { setup: (ctx: FakeCtx) => Promise<void> }).setup(ctx);
|
||||
assert.deepEqual(
|
||||
seen.map((s) => s.kind),
|
||||
["catalog", "integration"]
|
||||
);
|
||||
await pending;
|
||||
});
|
||||
|
||||
it("declares key plus env methods and no oauth in the integration transform", async () => {
|
||||
const seen: CapturedCall[] = [];
|
||||
const integrationCallbacks: Array<(draft: unknown) => unknown> = [];
|
||||
const catalogCallbacks: Array<(draft: unknown) => unknown> = [];
|
||||
const ctx: FakeCtx = {
|
||||
options: { baseURL: "https://gw.example.com", providerId: "omniroute" },
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => unknown) => {
|
||||
seen.push({ kind: "catalog" });
|
||||
catalogCallbacks.push(cb);
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
},
|
||||
integration: {
|
||||
transform: (cb: (draft: unknown) => unknown) => {
|
||||
seen.push({ kind: "integration" });
|
||||
integrationCallbacks.push(cb);
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
},
|
||||
};
|
||||
const logs: string[] = [];
|
||||
const origLog = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
logs.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
await (plugin as unknown as { setup: (ctx: FakeCtx) => Promise<void> }).setup(ctx);
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
}
|
||||
assert.equal(integrationCallbacks.length, 1);
|
||||
|
||||
interface MethodUpdate {
|
||||
integrationID: string;
|
||||
method: { type: string; label?: string; names?: string[] };
|
||||
}
|
||||
interface FakeIntegrationDraft {
|
||||
updates: Array<{ id: string; name: string }>;
|
||||
methods: MethodUpdate[];
|
||||
update: (id: string, fn: (i: { name: string }) => void) => void;
|
||||
method: { update: (input: MethodUpdate) => void };
|
||||
}
|
||||
const draft: FakeIntegrationDraft = {
|
||||
updates: [],
|
||||
methods: [],
|
||||
update(id: string, fn: (i: { name: string }) => void) {
|
||||
const entry = { name: "" };
|
||||
fn(entry);
|
||||
this.updates.push({ id, name: entry.name });
|
||||
},
|
||||
method: {
|
||||
update(input: MethodUpdate) {
|
||||
draft.methods.push(input);
|
||||
},
|
||||
},
|
||||
};
|
||||
(integrationCallbacks[0] as (draft: FakeIntegrationDraft) => unknown)(draft);
|
||||
assert.deepEqual(draft.updates, [{ id: "omniroute", name: "OmniRoute" }]);
|
||||
const keyMethod = draft.methods.find((m) => m.method.type === "key");
|
||||
const envMethod = draft.methods.find((m) => m.method.type === "env");
|
||||
assert.ok(keyMethod);
|
||||
assert.equal(keyMethod?.integrationID, "omniroute");
|
||||
assert.ok(envMethod);
|
||||
assert.deepEqual(envMethod?.method.names, ["OMNIROUTE_API_KEY"]);
|
||||
assert.ok(!draft.methods.some((m) => m.method.type === "oauth"));
|
||||
});
|
||||
|
||||
it("lazy refresh: [m1] then [m1,m2] reloads once; identical runs never reload", async () => {
|
||||
const { mkdtempSync } = await import("node:fs");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const { setTimeout: sleep } = await import("node:timers/promises");
|
||||
const dir = mkdtempSync(join(tmpdir(), "omniroute-lazy-"));
|
||||
const prevDataDir = process.env.OPENCODE_DATA_DIR;
|
||||
process.env.OPENCODE_DATA_DIR = dir;
|
||||
let modelsCall = 0;
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
if (!href.includes("/v1/models")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
modelsCall += 1;
|
||||
const ids = modelsCall <= 1 ? [{ id: "m1" }] : [{ id: "m1" }, { id: "m2" }];
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ data: ids }) };
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
let reloads = 0;
|
||||
const ctx = {
|
||||
options: {
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "lazy-reload",
|
||||
apiKey: "k-lazy",
|
||||
modelCacheTtlMs: 1,
|
||||
},
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => Promise<void>) => {
|
||||
catalogCallbacks.push(cb);
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
reload: async () => {
|
||||
reloads += 1;
|
||||
},
|
||||
},
|
||||
integration: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
};
|
||||
const logs: string[] = [];
|
||||
const origLog = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
logs.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
}
|
||||
assert.equal(catalogCallbacks.length, 1);
|
||||
const cb = catalogCallbacks[0] as (draft: unknown) => Promise<void>;
|
||||
const draft = {
|
||||
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
|
||||
model: {
|
||||
update: (_pid: string, _mid: string, fn: (m: Record<string, unknown>) => void) => fn({}),
|
||||
},
|
||||
};
|
||||
await cb(draft);
|
||||
assert.equal(reloads, 0, "the first publish sets the baseline, it does not reload");
|
||||
assert.equal(modelsCall, 1);
|
||||
await sleep(5);
|
||||
// The optional tier lands after that first publish and brings combos and
|
||||
// the overlay with it — one reload, so the picker shows them without
|
||||
// waiting for the next refresh.
|
||||
const afterFirstUpgrade = reloads;
|
||||
assert.ok(afterFirstUpgrade <= 1, `at most one reload for the first upgrade, got ${reloads}`);
|
||||
await cb(draft);
|
||||
assert.equal(reloads, afterFirstUpgrade + 1, "a new model id reloads once");
|
||||
assert.equal(modelsCall, 2);
|
||||
await sleep(5);
|
||||
await cb(draft);
|
||||
assert.equal(reloads, afterFirstUpgrade + 1, "an identical run never reloads");
|
||||
assert.equal(modelsCall, 3);
|
||||
if (prevDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = prevDataDir;
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
233
@omniroute/opencode-plugin-v2/tests/management-token.test.ts
Normal file
233
@omniroute/opencode-plugin-v2/tests/management-token.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import plugin from "../src/index.js";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
|
||||
const MODELS_URL = "https://gw.example.com/v1/models";
|
||||
const COMBOS_URL = "https://gw.example.com/api/combos";
|
||||
|
||||
function silence() {
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
const origLog = console.log;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
console.log = () => {};
|
||||
return {
|
||||
warns,
|
||||
restore() {
|
||||
console.warn = origWarn;
|
||||
console.log = origLog;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setup(options: Record<string, unknown>, reload?: () => Promise<void>) {
|
||||
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
const ctx = {
|
||||
options,
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => Promise<void>) => {
|
||||
catalogCallbacks.push(cb);
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
...(reload ? { reload } : {}),
|
||||
},
|
||||
integration: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
};
|
||||
return { catalogCallbacks, ctx };
|
||||
}
|
||||
|
||||
function stubDraft() {
|
||||
const published = new Map<string, Record<string, unknown>>();
|
||||
const draft = {
|
||||
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
|
||||
model: {
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => {
|
||||
const entry: Record<string, unknown> = { id: mid, providerID: pid };
|
||||
fn(entry);
|
||||
published.set(pid + "/" + mid, entry);
|
||||
},
|
||||
},
|
||||
};
|
||||
return { draft, published };
|
||||
}
|
||||
|
||||
describe("plugin-v2 managementReadToken wiring (F1)", () => {
|
||||
it("combos fetch uses managementReadToken while models use apiKey", async () => {
|
||||
const seen = new Map<string, string>();
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: unknown, init?: { headers?: Record<string, string> }) => {
|
||||
const href = String(url);
|
||||
seen.set(href, String(init?.headers?.Authorization ?? ""));
|
||||
if (href.includes("/api/combos/auto")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
if (href.includes("/api/combos")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ data: [{ id: "m1" }] }),
|
||||
};
|
||||
}) as typeof fetch;
|
||||
const guard = silence();
|
||||
try {
|
||||
const { catalogCallbacks, ctx } = setup({
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "omniroute",
|
||||
apiKey: "chat-key",
|
||||
managementReadToken: "mgmt-key",
|
||||
});
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
const { draft, published } = stubDraft();
|
||||
await catalogCallbacks[0](draft);
|
||||
assert.ok(published.has("omniroute/m1"));
|
||||
assert.equal(seen.get(COMBOS_URL), "Bearer mgmt-key");
|
||||
assert.equal(seen.get(MODELS_URL), "Bearer chat-key");
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
guard.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("combos fetch falls back to apiKey when managementReadToken is absent", async () => {
|
||||
const seen = new Map<string, string>();
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: unknown, init?: { headers?: Record<string, string> }) => {
|
||||
const href = String(url);
|
||||
seen.set(href, String(init?.headers?.Authorization ?? ""));
|
||||
if (href.includes("/api/combos/auto")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
if (href.includes("/api/combos")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ data: [{ id: "m1" }] }),
|
||||
};
|
||||
}) as typeof fetch;
|
||||
const guard = silence();
|
||||
try {
|
||||
const { catalogCallbacks, ctx } = setup({
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "omniroute",
|
||||
apiKey: "chat-key",
|
||||
});
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
const { draft } = stubDraft();
|
||||
await catalogCallbacks[0](draft);
|
||||
assert.equal(seen.get(COMBOS_URL), "Bearer chat-key");
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
guard.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("publishCatalog routes combosFetcher to managementReadToken, models to apiKey", async () => {
|
||||
const calls: Array<[string, string]> = [];
|
||||
const draft = {
|
||||
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
|
||||
model: {
|
||||
update: (_p: string, _m: string, fn: (m: Record<string, unknown>) => void) => fn({}),
|
||||
},
|
||||
};
|
||||
const res = await publishCatalog(
|
||||
draft as never,
|
||||
{
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "chat-key",
|
||||
managementReadToken: "mgmt-key",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 300000,
|
||||
usableOnly: false,
|
||||
},
|
||||
{
|
||||
fetcher: async (_baseURL, token) => {
|
||||
calls.push(["models", token]);
|
||||
return [{ id: "m1" }];
|
||||
},
|
||||
combosFetcher: async (_baseURL, token) => {
|
||||
calls.push(["combos", token]);
|
||||
return [];
|
||||
},
|
||||
}
|
||||
);
|
||||
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
|
||||
assert.deepEqual(calls, [
|
||||
["models", "chat-key"],
|
||||
["combos", "mgmt-key"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin-v2 fail-closed models (F2)", () => {
|
||||
it("empty models fetch on 2nd refresh keeps the last-known catalog", async () => {
|
||||
const { mkdtempSync } = await import("node:fs");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const dir = mkdtempSync(join(tmpdir(), "omniroute-f2-"));
|
||||
const prevDataDir = process.env.OPENCODE_DATA_DIR;
|
||||
process.env.OPENCODE_DATA_DIR = dir;
|
||||
let modelsCall = 0;
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/api/combos/auto")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
if (href.includes("/api/combos")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
modelsCall += 1;
|
||||
if (modelsCall === 1) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ data: [{ id: "m1" }] }),
|
||||
};
|
||||
}
|
||||
return { ok: false, status: 500, statusText: "Error", json: async () => ({}) };
|
||||
}) as typeof fetch;
|
||||
const guard = silence();
|
||||
try {
|
||||
const { catalogCallbacks, ctx } = setup({
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "f2-keep",
|
||||
apiKey: "k-f2",
|
||||
modelCacheTtlMs: 1,
|
||||
});
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
const first = stubDraft();
|
||||
await catalogCallbacks[0](first.draft);
|
||||
assert.ok(first.published.has("f2-keep/m1"), "first refresh must publish m1");
|
||||
const { setTimeout: sleep } = await import("node:timers/promises");
|
||||
await sleep(5);
|
||||
const second = stubDraft();
|
||||
await catalogCallbacks[0](second.draft);
|
||||
if (prevDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = prevDataDir;
|
||||
assert.ok(
|
||||
second.published.has("f2-keep/m1"),
|
||||
"empty models fetch must reuse last-known catalog"
|
||||
);
|
||||
assert.ok(
|
||||
guard.warns.some((w) => w.includes("keeping last-known catalog")),
|
||||
`expected keep-last-known warn, got: ${JSON.stringify(guard.warns)}`
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
guard.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
236
@omniroute/opencode-plugin-v2/tests/nested-combos.test.ts
Normal file
236
@omniroute/opencode-plugin-v2/tests/nested-combos.test.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
|
||||
interface Captured {
|
||||
models: Map<string, ModelV2Info>;
|
||||
draft: CatalogDraft;
|
||||
warns: string[];
|
||||
restore: () => void;
|
||||
}
|
||||
|
||||
function fakeDraft(): Captured {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
const draft = {
|
||||
provider: {
|
||||
list: () => [],
|
||||
get: (id: string) => providers.get(id) as never,
|
||||
update: (id: string, fn: (p: ProviderV2Info) => void) => {
|
||||
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
|
||||
fn(p);
|
||||
providers.set(id, p);
|
||||
},
|
||||
remove: () => {},
|
||||
},
|
||||
model: {
|
||||
get: () => undefined,
|
||||
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
|
||||
const k = pid + "/" + mid;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
|
||||
fn(m);
|
||||
models.set(k, m);
|
||||
},
|
||||
remove: () => {},
|
||||
default: { get: () => undefined, set: () => {} },
|
||||
},
|
||||
} as CatalogDraft;
|
||||
return {
|
||||
models,
|
||||
draft,
|
||||
warns,
|
||||
restore: () => {
|
||||
console.warn = origWarn;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const baseOpts = {
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 300000,
|
||||
usableOnly: false,
|
||||
};
|
||||
|
||||
describe("catalog nested combo refs", () => {
|
||||
it("resolves a forward combo-ref: parent LCD follows the nested combo, not zero", async () => {
|
||||
const c = fakeDraft();
|
||||
try {
|
||||
const res = await publishCatalog(c.draft, baseOpts, {
|
||||
fetcher: async () => [
|
||||
{
|
||||
id: "a",
|
||||
context_length: 100000,
|
||||
max_output_tokens: 4000,
|
||||
capabilities: { tool_calling: true, reasoning: true },
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
context_length: 50000,
|
||||
max_output_tokens: 2000,
|
||||
capabilities: { tool_calling: true, reasoning: true },
|
||||
},
|
||||
],
|
||||
combosFetcher: async () => [
|
||||
{
|
||||
id: "parent",
|
||||
name: "Parent",
|
||||
models: [
|
||||
{ kind: "combo-ref", comboName: "Child" } as never,
|
||||
{ kind: "model", model: "a" } as never,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "child",
|
||||
name: "Child",
|
||||
models: [{ kind: "model", model: "b" } as never],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.deepEqual(res, { models: 2, combos: 2, autoCombos: 0 });
|
||||
const parent = c.models.get("omniroute/parent");
|
||||
assert.ok(parent);
|
||||
assert.equal(parent?.limit.context, 50000);
|
||||
assert.equal(parent?.limit.output, 2000);
|
||||
assert.equal(parent?.capabilities.tools, true);
|
||||
} finally {
|
||||
c.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("drops an unresolvable combo-ref after the fixpoint instead of claiming empty LCD", async () => {
|
||||
const c = fakeDraft();
|
||||
try {
|
||||
const res = await publishCatalog(c.draft, baseOpts, {
|
||||
fetcher: async () => [{ id: "a" }],
|
||||
combosFetcher: async () => [
|
||||
{
|
||||
id: "orphan",
|
||||
name: "Orphan",
|
||||
models: [{ kind: "combo-ref", comboName: "Missing" } as never],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
|
||||
assert.ok(!c.models.has("omniroute/orphan"));
|
||||
assert.ok(c.warns.some((w) => w.includes("could not resolve")));
|
||||
} finally {
|
||||
c.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves a two-level chain within the pass budget", async () => {
|
||||
const c = fakeDraft();
|
||||
try {
|
||||
await publishCatalog(c.draft, baseOpts, {
|
||||
fetcher: async () => [
|
||||
{
|
||||
id: "a",
|
||||
context_length: 80000,
|
||||
max_output_tokens: 3000,
|
||||
capabilities: { tool_calling: true },
|
||||
},
|
||||
],
|
||||
combosFetcher: async () => [
|
||||
{
|
||||
id: "top",
|
||||
name: "Top",
|
||||
models: [{ kind: "combo-ref", comboName: "Mid" } as never],
|
||||
},
|
||||
{
|
||||
id: "mid",
|
||||
name: "Mid",
|
||||
models: [{ kind: "combo-ref", comboName: "Leaf" } as never],
|
||||
},
|
||||
{
|
||||
id: "leaf",
|
||||
name: "Leaf",
|
||||
models: [{ kind: "model", model: "a" } as never],
|
||||
},
|
||||
],
|
||||
});
|
||||
const top = c.models.get("omniroute/top");
|
||||
assert.ok(top);
|
||||
assert.equal(top?.limit.context, 80000);
|
||||
} finally {
|
||||
c.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog collision dedupe", () => {
|
||||
it("warns once per key even across repeated publishes sharing collisionWarned", async () => {
|
||||
const c = fakeDraft();
|
||||
try {
|
||||
const collisionWarned = new Set<string>();
|
||||
const args = {
|
||||
fetcher: async () => [{ id: "dupe" }],
|
||||
combosFetcher: async () => [
|
||||
{ id: "dupe", name: "Dupe Combo", models: [{ kind: "model", model: "dupe" } as never] },
|
||||
],
|
||||
};
|
||||
await publishCatalog(c.draft, { ...baseOpts, collisionWarned }, args);
|
||||
await publishCatalog(c.draft, { ...baseOpts, collisionWarned }, args);
|
||||
const hits = c.warns.filter((w) => w.includes("collides with a model id"));
|
||||
assert.equal(hits.length, 1);
|
||||
} finally {
|
||||
c.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("suppresses the warning for the intentional dedup pattern", async () => {
|
||||
const c = fakeDraft();
|
||||
try {
|
||||
// v1 pattern: /v1/models pre-mirrors the combo under its friendly
|
||||
// NAME and the combo is keyed by that name, so the overwritten model
|
||||
// id equals the combo name exactly and the draft key collides.
|
||||
const res = await publishCatalog(c.draft, baseOpts, {
|
||||
fetcher: async () => [{ id: "omniroute/Mirror Combo" }],
|
||||
combosFetcher: async () => [
|
||||
{
|
||||
id: "Mirror Combo",
|
||||
name: "Mirror Combo",
|
||||
models: [{ kind: "model", model: "omniroute/Mirror Combo" } as never],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.deepEqual(res, { models: 1, combos: 1, autoCombos: 0 });
|
||||
assert.ok(!c.warns.some((w) => w.includes("collides")));
|
||||
assert.equal(c.models.get("omniroute/Mirror Combo")?.name, "Mirror Combo");
|
||||
} finally {
|
||||
c.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("shares the dedupe set when the caller passes collisionWarned", async () => {
|
||||
const c = fakeDraft();
|
||||
try {
|
||||
const collisionWarned = new Set<string>();
|
||||
const args = {
|
||||
fetcher: async () => [{ id: "other" }],
|
||||
combosFetcher: async () => [
|
||||
{
|
||||
id: "other",
|
||||
name: "Other Combo",
|
||||
models: [{ kind: "model", model: "other" } as never],
|
||||
},
|
||||
],
|
||||
};
|
||||
await publishCatalog(c.draft, { ...baseOpts, collisionWarned }, args);
|
||||
await publishCatalog(c.draft, { ...baseOpts, collisionWarned }, args);
|
||||
const hits = c.warns.filter((w) => w.includes("collides with a model id"));
|
||||
assert.equal(hits.length, 1);
|
||||
} finally {
|
||||
c.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
129
@omniroute/opencode-plugin-v2/tests/options.test.ts
Normal file
129
@omniroute/opencode-plugin-v2/tests/options.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
integrationIdFor,
|
||||
parsePluginOptions,
|
||||
PLUGIN_ID,
|
||||
providerIdFor,
|
||||
resolveTimeouts,
|
||||
} from "../src/options.js";
|
||||
|
||||
describe("parsePluginOptions", () => {
|
||||
it("applies defaults for providerId, timeoutMs, usableOnly, enrichment", () => {
|
||||
const opts = parsePluginOptions({ baseURL: "https://gw.example.com" });
|
||||
assert.equal(opts.providerId, "omniroute");
|
||||
assert.equal(opts.timeoutMs, 10000);
|
||||
assert.equal(opts.usableOnly, false);
|
||||
assert.equal(opts.enrichment, true);
|
||||
assert.equal(opts.modelCacheTtlMs, undefined);
|
||||
});
|
||||
it("accepts a positive modelCacheTtlMs (in-memory TTL cache, default 300s)", () => {
|
||||
const opts = parsePluginOptions({ baseURL: "https://gw.example.com", modelCacheTtlMs: 60000 });
|
||||
assert.equal(opts.modelCacheTtlMs, 60000);
|
||||
});
|
||||
it("rejects a non-positive modelCacheTtlMs", () => {
|
||||
assert.throws(() =>
|
||||
parsePluginOptions({ baseURL: "https://gw.example.com", modelCacheTtlMs: 0 })
|
||||
);
|
||||
});
|
||||
it("requires baseURL", () => {
|
||||
assert.throws(() => parsePluginOptions({}), /baseURL/);
|
||||
});
|
||||
it("rejects unknown top-level keys (strict)", () => {
|
||||
assert.throws(() => parsePluginOptions({ baseURL: "https://gw.example.com", bogus: 1 }));
|
||||
});
|
||||
it("rejects unknown apiFormat keys (strict)", () => {
|
||||
assert.throws(() =>
|
||||
parsePluginOptions({
|
||||
baseURL: "https://gw.example.com",
|
||||
apiFormat: { bogus: ["claude"] },
|
||||
})
|
||||
);
|
||||
});
|
||||
it("accepts deprecated anthropicPrefixes (warn at resolve time, not parse time)", () => {
|
||||
const opts = parsePluginOptions({
|
||||
baseURL: "https://gw.example.com",
|
||||
apiFormat: { allowAnthropic: true, anthropicPrefixes: ["cc", "claude"] },
|
||||
});
|
||||
assert.deepEqual(opts.apiFormat, {
|
||||
allowAnthropic: true,
|
||||
anthropicPrefixes: ["cc", "claude"],
|
||||
});
|
||||
});
|
||||
it("passes apiFormat allowlist through (shared enforces semantics)", () => {
|
||||
const opts = parsePluginOptions({
|
||||
baseURL: "https://gw.example.com",
|
||||
apiFormat: { allowAnthropic: true, anthropicModels: ["anthropic/claude-x"] },
|
||||
});
|
||||
assert.deepEqual(opts.apiFormat, {
|
||||
allowAnthropic: true,
|
||||
anthropicModels: ["anthropic/claude-x"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("identity table", () => {
|
||||
it("maps providerId X to provider X and integration X, under one fixed plugin id", () => {
|
||||
assert.equal(providerIdFor("omniroute"), "omniroute");
|
||||
assert.equal(integrationIdFor("omniroute"), "omniroute");
|
||||
assert.equal(providerIdFor("second-gateway"), "second-gateway");
|
||||
// The host reads the plugin id before any option exists, so it never
|
||||
// varies with providerId.
|
||||
assert.equal(PLUGIN_ID, "omniroute-v2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid options say what to fix", () => {
|
||||
it("names an unknown key instead of dumping the validator output", () => {
|
||||
assert.throws(
|
||||
() => parsePluginOptions({ baseURL: "http://gw.example.com", modelCacheTtl: 300000 }),
|
||||
(err: Error) => {
|
||||
assert.match(err.message, /invalid plugin options/);
|
||||
assert.match(err.message, /unknown option "modelCacheTtl"/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("names the offending field for a wrong type", () => {
|
||||
assert.throws(
|
||||
() => parsePluginOptions({ baseURL: 42 }),
|
||||
(err: Error) => {
|
||||
assert.match(err.message, /baseURL/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts the documented option names", () => {
|
||||
const parsed = parsePluginOptions({
|
||||
baseURL: "http://gw.example.com",
|
||||
modelCacheTtlMs: 300000,
|
||||
timeouts: { models: 15000, combos: 8000 },
|
||||
geminiSanitization: false,
|
||||
});
|
||||
assert.equal(parsed.modelCacheTtlMs, 300000);
|
||||
assert.equal(resolveTimeouts(parsed).models, 15000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("providerId is bounded because it reaches a filesystem path", () => {
|
||||
it("rejects a traversal attempt instead of writing outside the snapshot directory", () => {
|
||||
for (const bad of ["../../etc/cron.d/x", "a/b", "..", "."]) {
|
||||
assert.throws(
|
||||
() => parsePluginOptions({ baseURL: "https://gw.example.com", providerId: bad }),
|
||||
/invalid plugin options/,
|
||||
`providerId ${JSON.stringify(bad)} must be rejected`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the ids a user would actually pick", () => {
|
||||
for (const ok of ["omniroute", "omniroute-2", "gw.staging", "gw_prod"]) {
|
||||
assert.equal(
|
||||
parsePluginOptions({ baseURL: "https://gw.example.com", providerId: ok }).providerId,
|
||||
ok
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
224
@omniroute/opencode-plugin-v2/tests/parity.test.ts
Normal file
224
@omniroute/opencode-plugin-v2/tests/parity.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createReadStream } from "node:fs";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
import {
|
||||
mapComboToModelV2 as sharedMapCombo,
|
||||
mapRawModelToModelV2 as sharedMapModel,
|
||||
type OmniRouteCombosFetcher,
|
||||
type OmniRouteModelsFetcher,
|
||||
type OmniRouteRawCombo,
|
||||
type OmniRouteRawModelEntry,
|
||||
} from "../src/shared/index.js";
|
||||
|
||||
interface Fixture {
|
||||
models: OmniRouteRawModelEntry[];
|
||||
combos: OmniRouteRawCombo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* What the v1 plugin produces for the same fixture, recorded in
|
||||
* `fixtures/v1-parity.json`. Running v1 here instead would mean importing its
|
||||
* build output from a sibling package: it only exists on a machine that has
|
||||
* built v1, so the check silently passed locally and could not run in CI at
|
||||
* all. Recording it makes the claim reviewable in the diff and reproducible
|
||||
* anywhere.
|
||||
*/
|
||||
interface V1Parity {
|
||||
v1PluginVersion: string;
|
||||
hookId: string;
|
||||
publishedKeys: string[];
|
||||
comboSlugs: string[];
|
||||
mappedModels: Record<string, unknown>;
|
||||
mappedCombos: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function loadFixture(): Promise<Fixture> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of createReadStream(new URL("./fixtures/catalog.json", import.meta.url))) {
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as Fixture;
|
||||
}
|
||||
|
||||
async function loadV1Parity(): Promise<V1Parity> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of createReadStream(
|
||||
new URL("./fixtures/v1-parity.json", import.meta.url)
|
||||
)) {
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as V1Parity;
|
||||
}
|
||||
|
||||
type ApiAuth = { type: "api"; key: string };
|
||||
|
||||
function fakeDraft() {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
return {
|
||||
providers,
|
||||
models,
|
||||
provider: {
|
||||
list: () => [],
|
||||
get: (id: string) => providers.get(id) as never,
|
||||
update: (id: string, fn: (p: ProviderV2Info) => void) => {
|
||||
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
|
||||
fn(p);
|
||||
providers.set(id, p);
|
||||
},
|
||||
remove: () => {},
|
||||
},
|
||||
model: {
|
||||
get: () => undefined,
|
||||
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
|
||||
const k = pid + "/" + mid;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
|
||||
fn(m);
|
||||
models.set(k, m);
|
||||
},
|
||||
remove: () => {},
|
||||
default: { get: () => undefined, set: () => {} },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const TEST_OPTS = {
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "omniroute",
|
||||
apiKey: "parity-key",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 300000,
|
||||
usableOnly: false as const,
|
||||
};
|
||||
|
||||
describe("v1-vs-v2 catalog parity", () => {
|
||||
it("same fixture models publish the same key set modulo documented exclusions", async () => {
|
||||
const fixture = await loadFixture();
|
||||
assert.equal(fixture.models.length, 5);
|
||||
assert.equal(fixture.combos.length, 2);
|
||||
|
||||
const fetcher: OmniRouteModelsFetcher = async () => fixture.models;
|
||||
const combosFetcher: OmniRouteCombosFetcher = async () => fixture.combos;
|
||||
|
||||
const recorded = await loadV1Parity();
|
||||
assert.equal(recorded.hookId, "opencode-omniroute", "v1 published under its own provider id");
|
||||
|
||||
const stripX = (k: string): string =>
|
||||
k.startsWith("omniroute/") ? k.slice("omniroute/".length) : k;
|
||||
const v1Keys = recorded.publishedKeys;
|
||||
|
||||
const draft = fakeDraft();
|
||||
const counts = await publishCatalog(draft, TEST_OPTS, { fetcher, combosFetcher });
|
||||
assert.equal(counts.models, 5);
|
||||
assert.equal(counts.combos, 2);
|
||||
assert.equal(counts.autoCombos, 0);
|
||||
|
||||
// Final converted ModelV2Info shape (legacy→info boundary in
|
||||
// src/catalog.ts assignModelFields): api resolves to the
|
||||
// openai-compatible AISDK block, capabilities fold tool_calling into
|
||||
// tools, cost is zeroed (pricing lives server-side).
|
||||
const mAlpha = draft.models.get("omniroute/m-alpha");
|
||||
assert.ok(mAlpha, "m-alpha published in v2 draft");
|
||||
assert.equal(mAlpha.api.type, "aisdk");
|
||||
if (mAlpha.api.type !== "aisdk") throw new Error("m-alpha api must be aisdk");
|
||||
assert.equal(mAlpha.api.package, "@ai-sdk/openai-compatible");
|
||||
assert.equal(mAlpha.capabilities.tools, true);
|
||||
assert.equal(mAlpha.cost[0].input, 0);
|
||||
|
||||
// Measured key shapes: v1 namespaces the friendly name
|
||||
// (`Combo Fast` -> `omniroute/combo-fast`) and keys the colliding combo
|
||||
// by its raw id (`good-combo` -> `omniroute/good-combo`, combo wins);
|
||||
// v2 publishes combo ids verbatim (`omniroute/combo-fast`,
|
||||
// `omniroute/good-combo` overwriting the raw model). v1 and v2 therefore
|
||||
// publish the SAME model keys (modulo the slashed-id exclusion above)
|
||||
// and the SAME non-colliding combo key; the colliding `good-combo` combo
|
||||
// overwrites the same-named raw model on BOTH sides (warn asserted below).
|
||||
const v1Combo = v1Keys.filter((k) => k.startsWith("combo-") || k === "good-combo").sort();
|
||||
const v2Combo = [...draft.models.keys()]
|
||||
.map(stripX)
|
||||
.filter((k) => k.startsWith("combo-") || k === "good-combo")
|
||||
.sort();
|
||||
// slug("Combo Fast") = "combo-fast", slug("Good Combo") = "good-combo".
|
||||
assert.deepEqual(v2Combo, ["combo-fast", "good-combo"]);
|
||||
assert.deepEqual(v1Combo, ["combo-fast", "good-combo"]);
|
||||
assert.deepEqual(v2Combo, v1Combo);
|
||||
assert.deepEqual(recorded.comboSlugs, ["combo-fast", "good-combo"]);
|
||||
const v1Models = v1Keys.filter((k) => !k.startsWith("combo-") && k !== "good-combo").sort();
|
||||
// Anchor v1 model keys as literals (modulo the slashed-id exclusion
|
||||
// documented above: the bare `cc/m-gamma` key fails the
|
||||
// `startsWith("omniroute/")` filter and is dropped here).
|
||||
assert.deepEqual(v1Models, ["local-delta", "m-alpha", "m-beta"]);
|
||||
const v2Models = [...draft.models.keys()]
|
||||
.map(stripX)
|
||||
.filter((k) => !k.includes("/") && !k.startsWith("combo-") && k !== "good-combo")
|
||||
.sort();
|
||||
assert.deepEqual(v2Models, v1Models);
|
||||
|
||||
// Collision: the combo whose friendly name collides with a raw model id
|
||||
// wins on both sides (v1 warns via injected logger, v2 via console.warn).
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const draft2 = fakeDraft();
|
||||
await publishCatalog(draft2, TEST_OPTS, { fetcher, combosFetcher });
|
||||
assert.ok(draft2.models.has("omniroute/good-combo"), "colliding combo key wins in v2");
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.ok(warns.some((w) => w.includes("collides with a model id; combo wins")));
|
||||
|
||||
// Mapper-level parity for the slashed-id exclusion: v1 and shared mappers
|
||||
// must produce identical ModelV2 payloads for every fixture entry.
|
||||
// (No apiFormat on either side — the parity scope is key publication,
|
||||
// not the v1-prefix vs v2-allowlist routing rule covered by A2 tests.
|
||||
// The `cc/m-gamma` fixture entry is therefore EXCLUDED from the mapper
|
||||
// comparison: v1 routes it to anthropic via its default
|
||||
// anthropicPrefixes while shared/v2 leave it openai-compatible without
|
||||
// an explicit anthropicModels allowlist.)
|
||||
// Mapper parity for the slashed-id exclusion: shared must still produce
|
||||
// the payloads v1 produced. (`cc/m-gamma` is excluded: v1 routes it to
|
||||
// anthropic through its default prefix list, while v2 leaves it
|
||||
// openai-compatible without an explicit allowlist — covered by the
|
||||
// apiFormat tests.)
|
||||
for (const entry of fixture.models.filter((m) => m.id !== "cc/m-gamma")) {
|
||||
const expected = recorded.mappedModels[entry.id];
|
||||
assert.ok(expected, `v1 output recorded for ${entry.id}`);
|
||||
const viaShared = sharedMapModel(entry, {
|
||||
providerId: "omniroute",
|
||||
baseURL: TEST_OPTS.baseURL,
|
||||
});
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify({ ...viaShared, providerID: undefined })),
|
||||
JSON.parse(JSON.stringify({ ...(expected as object), providerID: undefined })),
|
||||
`model mapper parity for ${entry.id}`
|
||||
);
|
||||
}
|
||||
const byId = new Map(fixture.models.map((m) => [m.id, m]));
|
||||
for (const combo of fixture.combos) {
|
||||
const members = (combo.models ?? [])
|
||||
.filter((s) => s?.kind !== "combo-ref" && typeof s?.model === "string")
|
||||
.map((s) => byId.get(s.model as string))
|
||||
.filter((m): m is OmniRouteRawModelEntry => m !== undefined);
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(sharedMapCombo(combo, members, "omniroute", TEST_OPTS.baseURL))),
|
||||
recorded.mappedCombos[combo.id],
|
||||
`combo mapper parity for ${combo.id}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("fixture is rejected when it drifts from the 5+2 shape (guard against silent shrink)", async () => {
|
||||
const fixture = await loadFixture();
|
||||
assert.ok(fixture.models.length >= 5, "fixture must keep at least 5 models");
|
||||
assert.ok(fixture.combos.length >= 2, "fixture must keep at least 2 combos");
|
||||
});
|
||||
});
|
||||
|
||||
void (0 as unknown as ApiAuth);
|
||||
void (0 as unknown as CatalogDraft);
|
||||
137
@omniroute/opencode-plugin-v2/tests/publish-guard.test.ts
Normal file
137
@omniroute/opencode-plugin-v2/tests/publish-guard.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import plugin from "../src/index.js";
|
||||
|
||||
// Guard around `publishCatalog` in the catalog transform: fetcher-level
|
||||
// fail-open covers fetch rejections, but a mapper throw or a host throw in
|
||||
// `draft.update` would reject the transform callback (unhandled rejection).
|
||||
// The guard must warn + resolve instead.
|
||||
describe("plugin-v2 publish guard (mapper/draft throws)", () => {
|
||||
function isolateDisk(): () => void {
|
||||
const dir = mkdtempSync(join(tmpdir(), "omniroute-guard-"));
|
||||
const prev = process.env.OPENCODE_DATA_DIR;
|
||||
process.env.OPENCODE_DATA_DIR = dir;
|
||||
return () => {
|
||||
if (prev === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = prev;
|
||||
};
|
||||
}
|
||||
function setupCtx(): {
|
||||
catalogCallbacks: Array<(draft: unknown) => Promise<void>>;
|
||||
ctx: Record<string, unknown>;
|
||||
} {
|
||||
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
const ctx = {
|
||||
options: { baseURL: "https://gw.example.com", providerId: "omniroute", apiKey: "k" },
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => Promise<void>) => {
|
||||
catalogCallbacks.push(cb);
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
},
|
||||
integration: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
};
|
||||
return { catalogCallbacks, ctx };
|
||||
}
|
||||
|
||||
function stubFetch(): typeof fetch {
|
||||
return (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/api/combos/auto")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
if (href.includes("/api/combos")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ data: [{ id: "m1" }] }),
|
||||
};
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
async function silenceConsole<T>(fn: () => Promise<T>): Promise<{ result: T; warns: string[] }> {
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
const origLog = console.log;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
console.log = () => {};
|
||||
try {
|
||||
const result = await fn();
|
||||
return { result, warns };
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
console.log = origLog;
|
||||
}
|
||||
}
|
||||
|
||||
it("host throw in draft.model.update: callback resolves + warn, never rejects", async () => {
|
||||
const restoreDisk = isolateDisk();
|
||||
const { catalogCallbacks, ctx } = setupCtx();
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch();
|
||||
try {
|
||||
const { warns } = await silenceConsole(async () => {
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
assert.equal(catalogCallbacks.length, 1);
|
||||
const draft = {
|
||||
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
|
||||
model: {
|
||||
update: () => {
|
||||
throw new Error("host boom");
|
||||
},
|
||||
},
|
||||
};
|
||||
// MUST resolve — without the guard this rejects with "host boom".
|
||||
await catalogCallbacks[0](draft);
|
||||
});
|
||||
assert.ok(
|
||||
warns.some((w) => w.includes("catalog publish failed") && w.includes("host boom")),
|
||||
`expected a publish-guard warn, got: ${JSON.stringify(warns)}`
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
|
||||
it("host throw in draft.provider.update: callback resolves + warn, never rejects", async () => {
|
||||
const restoreDisk = isolateDisk();
|
||||
const { catalogCallbacks, ctx } = setupCtx();
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch();
|
||||
try {
|
||||
const { warns } = await silenceConsole(async () => {
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
const draft = {
|
||||
provider: {
|
||||
update: () => {
|
||||
throw new Error("provider host boom");
|
||||
},
|
||||
},
|
||||
model: {
|
||||
update: (_pid: string, _mid: string, fn: (m: Record<string, unknown>) => void) =>
|
||||
fn({}),
|
||||
},
|
||||
};
|
||||
await catalogCallbacks[0](draft);
|
||||
});
|
||||
assert.ok(
|
||||
warns.some((w) => w.includes("catalog publish failed")),
|
||||
`expected a publish-guard warn, got: ${JSON.stringify(warns)}`
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
});
|
||||
214
@omniroute/opencode-plugin-v2/tests/refresh-failopen.test.ts
Normal file
214
@omniroute/opencode-plugin-v2/tests/refresh-failopen.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import plugin from "../src/index.js";
|
||||
|
||||
// RED: reproduces the PROD unhandled rejection — combos 403 must not escape
|
||||
// the catalog transform. Today `loadSnapshot()` awaits
|
||||
// `Promise.all([models, combos])` with no catch, so a 403 combos fetch
|
||||
// rejects the snapshot promise and the rejection propagates out of the
|
||||
// `ctx.catalog.transform` callback (fail-open in `publishCatalog` is
|
||||
// bypassed because injected fetchers return the already-rejected data).
|
||||
describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
|
||||
let diskSeq = 0;
|
||||
async function isolateDisk(): Promise<() => void> {
|
||||
const { mkdtempSync } = await import("node:fs");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
diskSeq += 1;
|
||||
const dir = mkdtempSync(join(tmpdir(), `omniroute-fo-${diskSeq}-`));
|
||||
const prev = process.env.OPENCODE_DATA_DIR;
|
||||
process.env.OPENCODE_DATA_DIR = dir;
|
||||
return () => {
|
||||
if (prev === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = prev;
|
||||
};
|
||||
}
|
||||
function setupCtx(opts: {
|
||||
combosStatus: number;
|
||||
modelsStatus?: number;
|
||||
reloads: { count: number };
|
||||
}): {
|
||||
catalogCallbacks: Array<(draft: unknown) => Promise<void>>;
|
||||
ctx: Record<string, unknown>;
|
||||
} {
|
||||
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
const ctx = {
|
||||
options: {
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "fo-" + String(opts.combosStatus) + "-" + String(opts.modelsStatus ?? 200),
|
||||
apiKey: "k-fo-" + String(opts.combosStatus),
|
||||
},
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => Promise<void>) => {
|
||||
catalogCallbacks.push(cb);
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
reload: async () => {
|
||||
opts.reloads.count += 1;
|
||||
},
|
||||
},
|
||||
integration: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
};
|
||||
return { catalogCallbacks, ctx };
|
||||
}
|
||||
|
||||
function stubFetch(opts: { combosStatus: number; modelsStatus?: number }): typeof fetch {
|
||||
const modelsStatus = opts.modelsStatus ?? 200;
|
||||
return (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/api/combos/auto")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
if (href.includes("/api/combos")) {
|
||||
return {
|
||||
ok: opts.combosStatus === 200,
|
||||
status: opts.combosStatus,
|
||||
statusText: opts.combosStatus === 403 ? "Forbidden" : "Error",
|
||||
json: async () => ({ combos: [] }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: modelsStatus === 200,
|
||||
status: modelsStatus,
|
||||
statusText: "OK",
|
||||
json: async () => ({ data: [{ id: "m1" }] }),
|
||||
};
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
function stubDraft(): {
|
||||
draft: unknown;
|
||||
published: Map<string, Record<string, unknown>>;
|
||||
} {
|
||||
const published = new Map<string, Record<string, unknown>>();
|
||||
const draft = {
|
||||
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
|
||||
model: {
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => {
|
||||
const entry: Record<string, unknown> = { id: mid, providerID: pid };
|
||||
fn(entry);
|
||||
published.set(pid + "/" + mid, entry);
|
||||
},
|
||||
},
|
||||
};
|
||||
return { draft, published };
|
||||
}
|
||||
|
||||
async function silenceConsole<T>(fn: () => Promise<T>): Promise<{ result: T; warns: string[] }> {
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
const origLog = console.log;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
console.log = () => {};
|
||||
try {
|
||||
const result = await fn();
|
||||
return { result, warns };
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
console.log = origLog;
|
||||
}
|
||||
}
|
||||
|
||||
it("combos 403: catalog callback resolves (models-only + warn), never rejects", async () => {
|
||||
const restoreDisk = await isolateDisk();
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx({ combosStatus: 403, reloads });
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({ combosStatus: 403 });
|
||||
try {
|
||||
const { warns } = await silenceConsole(async () => {
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
assert.equal(catalogCallbacks.length, 1);
|
||||
const { draft, published } = stubDraft();
|
||||
// MUST resolve — today it rejects with the 403 error.
|
||||
await catalogCallbacks[0](draft);
|
||||
const key = [...published.keys()].find((k) => k.endsWith("/m1"));
|
||||
assert.ok(
|
||||
key,
|
||||
`models-only fallback must publish m1, got: ${JSON.stringify([...published.keys()])}`
|
||||
);
|
||||
});
|
||||
assert.ok(
|
||||
warns.some((w) => w.includes("combos") && w.includes("403")),
|
||||
`expected a combos 403 warn, got: ${JSON.stringify(warns)}`
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
|
||||
it("combos 500: catalog callback resolves (models-only + warn), never rejects", async () => {
|
||||
const restoreDisk = await isolateDisk();
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx({ combosStatus: 500, reloads });
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({ combosStatus: 500 });
|
||||
try {
|
||||
const { warns } = await silenceConsole(async () => {
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
const { draft, published } = stubDraft();
|
||||
await catalogCallbacks[0](draft);
|
||||
const key = [...published.keys()].find((k) => k.endsWith("/m1"));
|
||||
assert.ok(
|
||||
key,
|
||||
`models-only fallback must publish m1, got: ${JSON.stringify([...published.keys()])}`
|
||||
);
|
||||
});
|
||||
assert.ok(
|
||||
warns.some((w) => w.includes("combos")),
|
||||
`expected a combos warn, got: ${JSON.stringify(warns)}`
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
|
||||
it("combos timeout (abort): catalog callback resolves, never rejects", async () => {
|
||||
const restoreDisk = await isolateDisk();
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx({ combosStatus: 200, reloads });
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/api/combos/auto")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
if (href.includes("/api/combos")) {
|
||||
const err = new Error("This operation was aborted");
|
||||
err.name = "AbortError";
|
||||
throw err;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ data: [{ id: "m1" }] }),
|
||||
};
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const { warns } = await silenceConsole(async () => {
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
const { draft, published } = stubDraft();
|
||||
await catalogCallbacks[0](draft);
|
||||
const key = [...published.keys()].find((k) => k.endsWith("/m1"));
|
||||
assert.ok(
|
||||
key,
|
||||
`models-only fallback must publish m1, got: ${JSON.stringify([...published.keys()])}`
|
||||
);
|
||||
});
|
||||
assert.ok(
|
||||
warns.some((w) => w.includes("combos")),
|
||||
`expected a combos warn, got: ${JSON.stringify(warns)}`
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mapRawModelToModelV2, resolveApiBlockV2 } from "../src/shared/models-map.js";
|
||||
import { parsePluginOptions } from "../src/options.js";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
|
||||
const GW = "https://gw.example.com";
|
||||
const PREFIXES = ["cc", "claude", "anthropic", "kiro", "kr"];
|
||||
|
||||
describe("deprecated anthropicPrefixes", () => {
|
||||
it("routes a default prefix id to anthropic with a deprecation warning", async () => {
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const out = resolveApiBlockV2("cc/claude-x", GW, {
|
||||
allowAnthropic: true,
|
||||
anthropicPrefixes: [...PREFIXES],
|
||||
});
|
||||
assert.equal(out.id, "anthropic");
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.ok(warns.some((w) => w.includes("deprecated") && w.includes("anthropicModels")));
|
||||
});
|
||||
|
||||
it("warns only once per process for the same prefix list", async () => {
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
resolveApiBlockV2("cc/a", GW, { allowAnthropic: true, anthropicPrefixes: ["cc"] });
|
||||
resolveApiBlockV2("cc/b", GW, { allowAnthropic: true, anthropicPrefixes: ["cc"] });
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.equal(warns.filter((w) => w.includes("deprecated")).length, 1);
|
||||
});
|
||||
|
||||
it("documents the known claude/openai-compatible-model edge: prefix wins", async () => {
|
||||
// v1 parity keeps prefix matching, so a model literally named
|
||||
// `claude/openai-compatible-model` routes to anthropic when its prefix
|
||||
// is listed. Documented (not silently fixed): operators hitting this
|
||||
// rare name should move the id to the allowlist instead.
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const out = resolveApiBlockV2("claude/openai-compatible-model", GW, {
|
||||
allowAnthropic: true,
|
||||
anthropicPrefixes: [...PREFIXES],
|
||||
anthropicModels: ["anthropic/claude-x"],
|
||||
});
|
||||
assert.equal(out.id, "anthropic");
|
||||
const mapped = mapRawModelToModelV2(
|
||||
{ id: "claude/openai-compatible-model" },
|
||||
{
|
||||
providerId: "omniroute",
|
||||
baseURL: GW,
|
||||
apiFormat: {
|
||||
allowAnthropic: true,
|
||||
anthropicPrefixes: [...PREFIXES],
|
||||
anthropicModels: ["anthropic/claude-x"],
|
||||
},
|
||||
}
|
||||
);
|
||||
assert.equal(mapped.api.id, "anthropic");
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
});
|
||||
|
||||
it("prefix OR allowlist routes to anthropic; neither means openai-compatible", async () => {
|
||||
const origWarn = console.warn;
|
||||
console.warn = () => {};
|
||||
try {
|
||||
assert.equal(
|
||||
resolveApiBlockV2("kiro/m", GW, { allowAnthropic: true, anthropicPrefixes: ["kiro"] }).id,
|
||||
"anthropic"
|
||||
);
|
||||
assert.equal(
|
||||
resolveApiBlockV2("anthropic/claude-x", GW, {
|
||||
allowAnthropic: true,
|
||||
anthropicModels: ["anthropic/claude-x"],
|
||||
}).id,
|
||||
"anthropic"
|
||||
);
|
||||
assert.equal(resolveApiBlockV2("gpt-x", GW).id, "openai-compatible");
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
});
|
||||
|
||||
it("copied v1 config routes anthropic and warns deprecation through publishCatalog", async () => {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const parsed = parsePluginOptions({
|
||||
baseURL: GW,
|
||||
apiFormat: { allowAnthropic: true, anthropicPrefixes: [...PREFIXES, "v1copy"] },
|
||||
});
|
||||
const draft = {
|
||||
provider: {
|
||||
list: () => [],
|
||||
get: (id: string) => providers.get(id) as never,
|
||||
update: (id: string, fn: (p: ProviderV2Info) => void) => {
|
||||
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
|
||||
fn(p);
|
||||
providers.set(id, p);
|
||||
},
|
||||
remove: () => {},
|
||||
},
|
||||
model: {
|
||||
get: () => undefined,
|
||||
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
|
||||
const k = pid + "/" + mid;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
|
||||
fn(m);
|
||||
models.set(k, m);
|
||||
},
|
||||
remove: () => {},
|
||||
default: { get: () => undefined, set: () => {} },
|
||||
},
|
||||
} as CatalogDraft;
|
||||
const res = await publishCatalog(
|
||||
draft,
|
||||
{
|
||||
providerId: "omniroute",
|
||||
baseURL: GW,
|
||||
apiKey: "k",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 300000,
|
||||
usableOnly: false,
|
||||
apiFormat: parsed.apiFormat,
|
||||
},
|
||||
{
|
||||
fetcher: async () => [{ id: "cc/claude-x" }],
|
||||
combosFetcher: async () => [],
|
||||
enrichmentFetcher: async () => new Map(),
|
||||
}
|
||||
);
|
||||
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
|
||||
const m = models.get("omniroute/cc/claude-x");
|
||||
assert.ok(m);
|
||||
if (m?.api.type !== "aisdk") throw new Error("model api must be aisdk");
|
||||
assert.equal(m?.api.id, "anthropic");
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.ok(warns.some((w) => w.includes("deprecated") && w.includes("anthropicModels")));
|
||||
});
|
||||
});
|
||||
196
@omniroute/opencode-plugin-v2/tests/shared-auto-combos.test.ts
Normal file
196
@omniroute/opencode-plugin-v2/tests/shared-auto-combos.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
defaultOmniRouteAutoCombosFetcher,
|
||||
mapAutoComboToModelV2,
|
||||
} from "../src/shared/auto-combos.js";
|
||||
|
||||
// A fetch stub that hangs until the caller aborts: proves the AbortController
|
||||
// wiring fires. Rejects with an AbortError like undici does on abort.
|
||||
function hangingFetch(): typeof fetch {
|
||||
return ((url: unknown, init?: { signal?: AbortSignal }) => {
|
||||
return new Promise((_resolve, reject) => {
|
||||
const signal = init?.signal;
|
||||
if (signal?.aborted) {
|
||||
reject(Object.assign(new Error("This operation was aborted"), { name: "AbortError" }));
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", () => {
|
||||
reject(Object.assign(new Error("This operation was aborted"), { name: "AbortError" }));
|
||||
});
|
||||
}) as Promise<Response>;
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function stubFetch(res: { status: number; statusText?: string; body?: unknown }): typeof fetch {
|
||||
return (async () => ({
|
||||
ok: res.status >= 200 && res.status < 300,
|
||||
status: res.status,
|
||||
statusText: res.statusText ?? (res.status === 404 ? "Not Found" : "Error"),
|
||||
json: async () => res.body,
|
||||
})) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function silenceWarn(): { warns: string[]; restore: () => void } {
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
return {
|
||||
warns,
|
||||
restore() {
|
||||
console.warn = origWarn;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("defaultOmniRouteAutoCombosFetcher", () => {
|
||||
it("404 returns [] with a warn (old gateway without the endpoint stays fail-open)", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({ status: 404 });
|
||||
const guard = silenceWarn();
|
||||
try {
|
||||
const res = await defaultOmniRouteAutoCombosFetcher("https://gw.example.com", "k", 5000);
|
||||
assert.deepEqual(res, []);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
guard.restore();
|
||||
}
|
||||
assert.ok(
|
||||
guard.warns.some((w) => w.includes("/api/combos/auto") && w.includes("404")),
|
||||
`expected a 404 warn, got: ${JSON.stringify(guard.warns)}`
|
||||
);
|
||||
});
|
||||
|
||||
it("500 throws (with warn + token-hint report) so the caller keeps last-known", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({ status: 500, statusText: "Internal Server Error" });
|
||||
const guard = silenceWarn();
|
||||
const reported: string[] = [];
|
||||
try {
|
||||
await assert.rejects(
|
||||
defaultOmniRouteAutoCombosFetcher("https://gw.example.com", "k", 5000, undefined, (e, r) =>
|
||||
reported.push(`${e} ${r}`)
|
||||
),
|
||||
/HTTP 500/
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
guard.restore();
|
||||
}
|
||||
assert.ok(
|
||||
guard.warns.some((w) => w.includes("/api/combos/auto") && w.includes("500")),
|
||||
`expected a 500 warn, got: ${JSON.stringify(guard.warns)}`
|
||||
);
|
||||
assert.ok(
|
||||
reported.some((r) => r.includes("/api/combos/auto")),
|
||||
`expected a per-endpoint report, got: ${JSON.stringify(reported)}`
|
||||
);
|
||||
});
|
||||
|
||||
it("a network throw rejects instead of resolving empty", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = hangingFetch();
|
||||
const guard = silenceWarn();
|
||||
try {
|
||||
await assert.rejects(
|
||||
defaultOmniRouteAutoCombosFetcher("https://gw.example.com", "k", 20),
|
||||
/abort|aborted/i
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
guard.restore();
|
||||
}
|
||||
assert.ok(
|
||||
guard.warns.some((w) => w.includes("/api/combos/auto")),
|
||||
`expected an abort warn, got: ${JSON.stringify(guard.warns)}`
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts the {combos:[...]} envelope and filters entries without a string id", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({
|
||||
status: 200,
|
||||
body: { combos: [{ id: "auto/coding", name: "Auto Coding" }, { name: "no-id" }] },
|
||||
});
|
||||
const guard = silenceWarn();
|
||||
try {
|
||||
const res = await defaultOmniRouteAutoCombosFetcher("https://gw.example.com", "k", 5000);
|
||||
assert.deepEqual(res, [{ id: "auto/coding", name: "Auto Coding" }]);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
guard.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapAutoComboToModelV2", () => {
|
||||
it("falls back to 128k context / 8k output when the server omits limits (never 0)", () => {
|
||||
const m = mapAutoComboToModelV2(
|
||||
{ id: "auto/coding", name: "Auto Coding", variant: "coding" },
|
||||
"omniroute",
|
||||
"https://gw.example.com"
|
||||
);
|
||||
assert.equal(m.id, "auto/coding");
|
||||
assert.equal(m.limit.context, 128_000);
|
||||
assert.equal(m.limit.output, 8_192);
|
||||
assert.equal(m.capabilities.toolcall, true);
|
||||
assert.equal(m.capabilities.reasoning, true);
|
||||
});
|
||||
|
||||
it("uses server limits when positive, falls back when zero or negative", () => {
|
||||
const served = mapAutoComboToModelV2(
|
||||
{ id: "auto", name: "Auto", context_length: 200000, max_output_tokens: 16000 },
|
||||
"omniroute",
|
||||
"https://gw.example.com"
|
||||
);
|
||||
assert.equal(served.limit.context, 200000);
|
||||
assert.equal(served.limit.output, 16000);
|
||||
const zeroed = mapAutoComboToModelV2(
|
||||
{ id: "auto", name: "Auto", context_length: 0, max_output_tokens: -1 },
|
||||
"omniroute",
|
||||
"https://gw.example.com"
|
||||
);
|
||||
assert.equal(zeroed.limit.context, 128_000);
|
||||
assert.equal(zeroed.limit.output, 8_192);
|
||||
});
|
||||
|
||||
it("defaults the model id to the auto variant key (auto, auto/coding)", () => {
|
||||
const def = mapAutoComboToModelV2({ id: "whatever" }, "omniroute", "https://gw.example.com");
|
||||
assert.equal(def.id, "auto");
|
||||
const coding = mapAutoComboToModelV2(
|
||||
{ id: "auto/coding", variant: "coding" },
|
||||
"omniroute",
|
||||
"https://gw.example.com"
|
||||
);
|
||||
assert.equal(coding.id, "auto/coding");
|
||||
});
|
||||
|
||||
it("stamps the openai-compatible api block by default", () => {
|
||||
const m = mapAutoComboToModelV2(
|
||||
{ id: "auto", candidateCount: 3 },
|
||||
"omniroute",
|
||||
"https://gw.example.com"
|
||||
);
|
||||
assert.deepEqual(m.api, {
|
||||
id: "openai-compatible",
|
||||
url: "https://gw.example.com/v1",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
});
|
||||
});
|
||||
|
||||
it("routes an allowlisted auto id to anthropic (same rule as models)", () => {
|
||||
const m = mapAutoComboToModelV2(
|
||||
{ id: "auto/coding", variant: "coding" },
|
||||
"omniroute",
|
||||
"https://gw.example.com",
|
||||
{ allowAnthropic: true, anthropicModels: ["auto/coding"] }
|
||||
);
|
||||
assert.deepEqual(m.api, {
|
||||
id: "anthropic",
|
||||
url: "https://gw.example.com",
|
||||
npm: "@ai-sdk/anthropic",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mapComboToModelV2 } from "../src/shared/combos-map.js";
|
||||
|
||||
const member = (id: string, caps = {}) => ({
|
||||
id,
|
||||
context_length: 100000,
|
||||
max_output_tokens: 2000,
|
||||
capabilities: caps,
|
||||
});
|
||||
|
||||
describe("mapComboToModelV2", () => {
|
||||
it("rolls up LCD: min context/output, AND of toolcall", () => {
|
||||
const m = mapComboToModelV2(
|
||||
{
|
||||
id: "combo-a",
|
||||
name: "A",
|
||||
models: [
|
||||
{ kind: "model", model: "a" },
|
||||
{ kind: "model", model: "b" },
|
||||
],
|
||||
},
|
||||
[
|
||||
{
|
||||
...member("a"),
|
||||
context_length: 100000,
|
||||
max_output_tokens: 2000,
|
||||
capabilities: { tool_calling: true },
|
||||
},
|
||||
{
|
||||
...member("b"),
|
||||
context_length: 50000,
|
||||
max_output_tokens: 1000,
|
||||
capabilities: { tool_calling: false },
|
||||
},
|
||||
],
|
||||
"omniroute",
|
||||
"https://gw.example.com"
|
||||
);
|
||||
assert.equal(m.limit.context, 50000);
|
||||
assert.equal(m.limit.output, 1000);
|
||||
assert.equal(m.capabilities.toolcall, false);
|
||||
assert.equal(m.api.id, "openai-compatible");
|
||||
});
|
||||
it("empty members short-circuit to all-false capabilities", () => {
|
||||
const m = mapComboToModelV2({ id: "combo-empty" }, [], "omniroute", "https://gw.example.com");
|
||||
assert.equal(m.capabilities.toolcall, false);
|
||||
assert.equal(m.capabilities.reasoning, false);
|
||||
});
|
||||
it("stamps the api block via resolveApiBlockV2 (openai-compatible by default)", () => {
|
||||
const m = mapComboToModelV2(
|
||||
{ id: "combo-a", models: [{ kind: "model", model: "a" }] },
|
||||
[{ ...member("a") }],
|
||||
"omniroute",
|
||||
"https://gw.example.com"
|
||||
);
|
||||
assert.deepEqual(m.api, {
|
||||
id: "openai-compatible",
|
||||
url: "https://gw.example.com/v1",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
});
|
||||
});
|
||||
it("routes an allowlisted combo id to anthropic even with mixed members", () => {
|
||||
const m = mapComboToModelV2(
|
||||
{ id: "combo-a", models: [{ kind: "model", model: "gpt-x" }] },
|
||||
[{ ...member("gpt-x") }],
|
||||
"omniroute",
|
||||
"https://gw.example.com",
|
||||
{ allowAnthropic: true, anthropicModels: ["combo-a"] }
|
||||
);
|
||||
assert.deepEqual(m.api, {
|
||||
id: "anthropic",
|
||||
url: "https://gw.example.com",
|
||||
npm: "@ai-sdk/anthropic",
|
||||
});
|
||||
});
|
||||
});
|
||||
47
@omniroute/opencode-plugin-v2/tests/shared-enrich.test.ts
Normal file
47
@omniroute/opencode-plugin-v2/tests/shared-enrich.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
applyEnrichment,
|
||||
buildCanonicalToAliasMap,
|
||||
canonicalDedupSet,
|
||||
lookupEnrichment,
|
||||
type OmniRouteEnrichmentMap,
|
||||
} from "../src/shared/enrich.js";
|
||||
import { mapRawModelToModelV2 } from "../src/shared/models-map.js";
|
||||
|
||||
const enrichment: OmniRouteEnrichmentMap = new Map([
|
||||
["cc/model-x", { name: "Model X", providerAlias: "cc", providerCanonical: "claude" }],
|
||||
["model-x", { name: "Model X", providerAlias: "cc", providerCanonical: "claude" }],
|
||||
]);
|
||||
|
||||
describe("lookupEnrichment", () => {
|
||||
it("resolves a canonical id via the alias fallback chain", () => {
|
||||
const canonicalToAlias = buildCanonicalToAliasMap(enrichment);
|
||||
assert.equal(canonicalToAlias.get("claude"), "cc");
|
||||
const found = lookupEnrichment("claude/model-x", enrichment, canonicalToAlias);
|
||||
assert.equal(found?.name, "Model X");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyEnrichment", () => {
|
||||
it("overlays the enrichment display name onto the model", () => {
|
||||
const model = mapRawModelToModelV2(
|
||||
{ id: "cc/model-x" },
|
||||
{ providerId: "omniroute", baseURL: "https://gw.example.com" }
|
||||
);
|
||||
applyEnrichment(model, { name: "Model X" });
|
||||
assert.equal(model.name, "Model X");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonicalDedupSet", () => {
|
||||
it("drops the canonical twin when the alias row exists", () => {
|
||||
const canonicalToAlias = buildCanonicalToAliasMap(enrichment);
|
||||
const drop = canonicalDedupSet(
|
||||
[{ id: "cc/model-x" }, { id: "claude/model-x" }],
|
||||
canonicalToAlias
|
||||
);
|
||||
assert.ok(drop.has("claude/model-x"));
|
||||
assert.ok(!drop.has("cc/model-x"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { defaultOmniRouteEnrichmentFetcher } from "../src/shared/enrich.js";
|
||||
|
||||
function stubFetch(handler: (url: string) => unknown): typeof fetch {
|
||||
return (async (url: unknown) => {
|
||||
const body = handler(String(url));
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => body,
|
||||
};
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe("defaultOmniRouteEnrichmentFetcher", () => {
|
||||
it("merges display names and pricing from the two catalog sources", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch((url) =>
|
||||
url.endsWith("/api/pricing/models")
|
||||
? {
|
||||
cc: {
|
||||
id: "claude",
|
||||
alias: "cc",
|
||||
name: "Claude",
|
||||
models: [{ id: "m1", name: "Model One" }],
|
||||
},
|
||||
}
|
||||
: url.endsWith("/api/pricing")
|
||||
? { cc: { m1: { input: 3, output: 15, cached: 0.3, cache_creation: 3.75 } } }
|
||||
: { perModel: [] }
|
||||
);
|
||||
try {
|
||||
const map = await defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000);
|
||||
const namespaced = map.get("cc/m1");
|
||||
assert.ok(namespaced);
|
||||
assert.equal(namespaced?.name, "Model One");
|
||||
assert.equal(namespaced?.providerAlias, "cc");
|
||||
assert.equal(namespaced?.providerCanonical, "claude");
|
||||
assert.deepEqual(namespaced?.pricing, {
|
||||
input: 3,
|
||||
output: 15,
|
||||
cacheRead: 0.3,
|
||||
cacheWrite: 3.75,
|
||||
});
|
||||
assert.deepEqual(map.get("m1")?.pricing, namespaced?.pricing);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("soft-fails per source: a dead pricing endpoint still returns catalog names", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
if (href.endsWith("/api/pricing")) throw new Error("boom");
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({
|
||||
cc: { id: "claude", models: [{ id: "m1", name: "Model One" }] },
|
||||
}),
|
||||
};
|
||||
}) as unknown as typeof fetch;
|
||||
try {
|
||||
const map = await defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000);
|
||||
assert.equal(map.get("cc/m1")?.name, "Model One");
|
||||
assert.equal(map.get("cc/m1")?.pricing, undefined);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("soft-fails per source: a dead catalog endpoint still returns pricing", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
if (href.endsWith("/api/pricing/models")) {
|
||||
return { ok: false, status: 500, statusText: "err", json: async () => ({}) };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ cc: { m1: { input: 1, output: 2 } } }),
|
||||
};
|
||||
}) as unknown as typeof fetch;
|
||||
try {
|
||||
const map = await defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000);
|
||||
assert.deepEqual(map.get("cc/m1")?.pricing, { input: 1, output: 2 });
|
||||
assert.equal(map.get("cc/m1")?.name, undefined);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("attaches free-tier budgets from the third source", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch((url) =>
|
||||
url.endsWith("/api/pricing/models")
|
||||
? { cc: { id: "claude", models: [{ id: "m1", name: "Model One" }] } }
|
||||
: url.endsWith("/api/pricing")
|
||||
? {}
|
||||
: {
|
||||
perModel: [
|
||||
{ provider: "cc", modelId: "m1", freeType: "monthly", monthlyTokens: 100 },
|
||||
],
|
||||
}
|
||||
);
|
||||
try {
|
||||
const map = await defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000);
|
||||
assert.equal(map.get("cc/m1")?.freeType, "monthly");
|
||||
assert.equal(map.get("cc/m1")?.monthlyTokens, 100);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns an empty map without credentials instead of throwing", async () => {
|
||||
const map = await defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "", 1000);
|
||||
assert.equal(map.size, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { defaultOmniRouteEnrichmentFetcher } from "../src/shared/enrich.js";
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
describe("enrichment source failures are reported", () => {
|
||||
it("names each endpoint a gateway refuses, then throws so the caller keeps last-known", async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response("forbidden", { status: 403 })) as unknown as typeof globalThis.fetch;
|
||||
const seen: Array<[string, string]> = [];
|
||||
await assert.rejects(
|
||||
defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000, (endpoint, reason) =>
|
||||
seen.push([endpoint, reason])
|
||||
),
|
||||
/enrichment (catalog source|sources) failed/
|
||||
);
|
||||
assert.deepEqual(seen.map(([endpoint]) => endpoint).sort(), [
|
||||
"/api/free-tier/summary",
|
||||
"/api/pricing",
|
||||
"/api/pricing/models",
|
||||
]);
|
||||
for (const [, reason] of seen) assert.match(reason, /403/);
|
||||
});
|
||||
|
||||
it("reports a transport failure with its cause, then throws", async () => {
|
||||
globalThis.fetch = (async () => {
|
||||
throw new Error("connect ECONNREFUSED");
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
const seen: string[] = [];
|
||||
await assert.rejects(
|
||||
defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000, (_e, reason) =>
|
||||
seen.push(reason)
|
||||
),
|
||||
/enrichment (catalog source|sources) failed/
|
||||
);
|
||||
assert.equal(seen.length, 3);
|
||||
for (const reason of seen) assert.match(reason, /ECONNREFUSED/);
|
||||
});
|
||||
|
||||
it("stays silent when every source answers", async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response("{}", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as unknown as typeof globalThis.fetch;
|
||||
const seen: string[] = [];
|
||||
await defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000, (e) =>
|
||||
seen.push(e)
|
||||
);
|
||||
assert.deepEqual(seen, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { defaultOmniRouteModelsFetcher } from "../src/shared/models-map.js";
|
||||
import { defaultOmniRouteCombosFetcher } from "../src/shared/combos-map.js";
|
||||
|
||||
// A fetch stub that hangs until the caller aborts: proves the AbortController
|
||||
// wiring fires. Rejects with an AbortError like undici does on abort.
|
||||
function hangingFetch(): typeof fetch {
|
||||
return ((url: unknown, init?: { signal?: AbortSignal }) => {
|
||||
return new Promise((_resolve, reject) => {
|
||||
const signal = init?.signal;
|
||||
if (signal?.aborted) {
|
||||
reject(Object.assign(new Error("This operation was aborted"), { name: "AbortError" }));
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", () => {
|
||||
reject(Object.assign(new Error("This operation was aborted"), { name: "AbortError" }));
|
||||
});
|
||||
}) as Promise<Response>;
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe("shared default fetchers abort on timeout", () => {
|
||||
it("models fetcher aborts a hanging request after timeoutMs", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = hangingFetch();
|
||||
try {
|
||||
await assert.rejects(
|
||||
defaultOmniRouteModelsFetcher("https://gw.example.com", "k", 20),
|
||||
/aborted/i
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("combos fetcher aborts a hanging request after timeoutMs", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = hangingFetch();
|
||||
try {
|
||||
await assert.rejects(
|
||||
defaultOmniRouteCombosFetcher("https://gw.example.com", "k", 20),
|
||||
/aborted/i
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { catalogContentFingerprint, optionalTierFingerprint } from "../src/shared/fingerprint.js";
|
||||
|
||||
describe("catalogContentFingerprint", () => {
|
||||
it("returns the same hash for two identical catalogs", () => {
|
||||
const models = [
|
||||
{ id: "b", release_date: "2026-01-02" },
|
||||
{ id: "a", release_date: "2026-01-01" },
|
||||
];
|
||||
const combos = [{ id: "combo-b" }, { id: "combo-a" }];
|
||||
assert.equal(
|
||||
catalogContentFingerprint(models, combos),
|
||||
catalogContentFingerprint([...models].reverse(), [...combos].reverse())
|
||||
);
|
||||
});
|
||||
it("returns a different hash when one model id changes", () => {
|
||||
const combos = [{ id: "combo-a" }];
|
||||
const before = catalogContentFingerprint([{ id: "a" }], combos);
|
||||
const after = catalogContentFingerprint([{ id: "a2" }], combos);
|
||||
assert.notEqual(before, after);
|
||||
});
|
||||
it("returns a different hash when the auto-combos set changes", () => {
|
||||
const models = [{ id: "a" }];
|
||||
const combos = [{ id: "combo-a" }];
|
||||
const before = catalogContentFingerprint(models, combos, [{ id: "auto" }]);
|
||||
const after = catalogContentFingerprint(models, combos, [
|
||||
{ id: "auto" },
|
||||
{ id: "auto/coding" },
|
||||
]);
|
||||
assert.notEqual(before, after);
|
||||
});
|
||||
});
|
||||
|
||||
describe("optionalTierFingerprint", () => {
|
||||
it("moves on a pricing-only change, so stale prices reach the picker", () => {
|
||||
const priced = (input: number) =>
|
||||
new Map([["cc/m1", { name: "M1", pricing: { input, output: 1 } }]]);
|
||||
assert.notEqual(
|
||||
optionalTierFingerprint([], [], priced(3)),
|
||||
optionalTierFingerprint([], [], priced(99))
|
||||
);
|
||||
});
|
||||
|
||||
it("moves when a combo loses a member without changing id", () => {
|
||||
const one = [{ id: "combo-a", name: "A", models: [{ model: "m1" }] }];
|
||||
const two = [{ id: "combo-a", name: "A", models: [{ model: "m1" }, { model: "m2" }] }];
|
||||
assert.notEqual(
|
||||
optionalTierFingerprint([], [], undefined, one),
|
||||
optionalTierFingerprint([], [], undefined, two)
|
||||
);
|
||||
});
|
||||
|
||||
it("moves when a provider goes quiet or gets renamed", () => {
|
||||
const active = [{ id: "c1", testStatus: "active", isActive: true }];
|
||||
const quiet = [{ id: "c1", testStatus: "active", isActive: false }];
|
||||
assert.notEqual(
|
||||
optionalTierFingerprint([], active, undefined),
|
||||
optionalTierFingerprint([], quiet, undefined)
|
||||
);
|
||||
});
|
||||
});
|
||||
144
@omniroute/opencode-plugin-v2/tests/shared-gemini.test.ts
Normal file
144
@omniroute/opencode-plugin-v2/tests/shared-gemini.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { isGeminiModelId, sanitizeToolInputSchemas } from "../src/shared/gemini.js";
|
||||
|
||||
describe("Gemini tool-schema sanitising", () => {
|
||||
it("recognises the routing forms a Gemini model arrives under", () => {
|
||||
for (const id of [
|
||||
"gemini-2.5-flash",
|
||||
"models/gemini-1.5-pro",
|
||||
"google-vertex/gemini-2.0",
|
||||
"GEMINI-PRO",
|
||||
]) {
|
||||
assert.equal(isGeminiModelId(id), true, id);
|
||||
}
|
||||
for (const id of ["gpt-5", "claude-opus-5", "", "gemma-2-9b"]) {
|
||||
assert.equal(isGeminiModelId(id), false, id);
|
||||
}
|
||||
});
|
||||
|
||||
it("strips the keywords Gemini rejects, however deep they sit", () => {
|
||||
const tools = [
|
||||
{
|
||||
type: "function" as const,
|
||||
name: "edit",
|
||||
inputSchema: {
|
||||
$schema: "https://json-schema.org/draft-07/schema",
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
nested: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
properties: { deep: { type: "string" } },
|
||||
},
|
||||
list: { type: "array", items: [{ type: "object", additionalProperties: false }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
const out = sanitizeToolInputSchemas(tools);
|
||||
assert.notEqual(out, undefined, "a payload that needed cleaning must come back changed");
|
||||
const schema = out![0]!.inputSchema as Record<string, any>;
|
||||
assert.equal("$schema" in schema, false);
|
||||
assert.equal("additionalProperties" in schema, false);
|
||||
assert.equal("additionalProperties" in schema["properties"]["nested"], false);
|
||||
assert.equal("additionalProperties" in schema["properties"]["list"]["items"][0], false);
|
||||
// What the schema means must survive the cleaning.
|
||||
assert.equal(schema["properties"]["path"]["type"], "string");
|
||||
assert.equal(schema["properties"]["nested"]["properties"]["deep"]["type"], "string");
|
||||
});
|
||||
|
||||
it("leaves the caller's tools untouched", () => {
|
||||
const tools = [
|
||||
{
|
||||
type: "function" as const,
|
||||
name: "t",
|
||||
inputSchema: { type: "object", additionalProperties: false },
|
||||
},
|
||||
];
|
||||
sanitizeToolInputSchemas(tools);
|
||||
assert.equal((tools[0]!.inputSchema as Record<string, unknown>)["additionalProperties"], false);
|
||||
});
|
||||
|
||||
it("reports nothing to do rather than cloning a clean payload", () => {
|
||||
assert.equal(
|
||||
sanitizeToolInputSchemas([
|
||||
{ type: "function" as const, name: "t", inputSchema: { type: "object", properties: {} } },
|
||||
]),
|
||||
undefined
|
||||
);
|
||||
assert.equal(sanitizeToolInputSchemas(undefined), undefined);
|
||||
assert.equal(sanitizeToolInputSchemas([]), undefined);
|
||||
});
|
||||
|
||||
it("walks past a provider tool it cannot read without dropping it", () => {
|
||||
const tools = [
|
||||
{ type: "provider" as const, id: "p.search", name: "search", args: {} },
|
||||
{
|
||||
type: "function" as const,
|
||||
name: "t",
|
||||
inputSchema: { type: "object", additionalProperties: false },
|
||||
},
|
||||
];
|
||||
const out = sanitizeToolInputSchemas(tools as never);
|
||||
assert.equal(out?.length, 2);
|
||||
assert.deepEqual(out![0], tools[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sanitiser repairs schemas without mangling them", () => {
|
||||
it("keeps a tool parameter that happens to be named like a keyword", () => {
|
||||
const out = sanitizeToolInputSchemas([
|
||||
{
|
||||
type: "function" as const,
|
||||
name: "checkout",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["ref"],
|
||||
properties: {
|
||||
ref: { type: "string", description: "a git ref" },
|
||||
additionalProperties: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
const schema = out![0]!.inputSchema as Record<string, any>;
|
||||
// The keyword goes; the parameters keep their names, or `required` would
|
||||
// point at a property the model can no longer send.
|
||||
assert.equal("additionalProperties" in schema, false);
|
||||
assert.deepEqual(Object.keys(schema["properties"]).sort(), ["additionalProperties", "ref"]);
|
||||
assert.deepEqual(schema["required"], ["ref"]);
|
||||
});
|
||||
|
||||
it("forwards a $ref schema untouched rather than widening it to anything", () => {
|
||||
assert.equal(
|
||||
sanitizeToolInputSchemas([
|
||||
{ type: "function" as const, name: "t", inputSchema: { $ref: "#/$defs/x" } },
|
||||
]),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it("recognises the Gemini families, and only those", () => {
|
||||
for (const id of [
|
||||
"gemini",
|
||||
"gemini-2.5-flash",
|
||||
"models/gemini-1.5-pro",
|
||||
"google-vertex/gemini-2.0",
|
||||
"GEMINI-3-PRO",
|
||||
]) {
|
||||
assert.equal(isGeminiModelId(id), true, id);
|
||||
}
|
||||
for (const id of [
|
||||
"gemini-compatible-proxy",
|
||||
"my-gemini-wrapper",
|
||||
"openai/gpt-5-gemini-eval",
|
||||
"gemma-2-9b",
|
||||
]) {
|
||||
assert.equal(isGeminiModelId(id), false, id);
|
||||
}
|
||||
});
|
||||
});
|
||||
130
@omniroute/opencode-plugin-v2/tests/shared-logger.test.ts
Normal file
130
@omniroute/opencode-plugin-v2/tests/shared-logger.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createLogger, getLogLevel } from "../src/shared/logger.js";
|
||||
|
||||
function capture(): {
|
||||
warns: string[];
|
||||
errors: string[];
|
||||
restore: () => void;
|
||||
} {
|
||||
const warns: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
const origError = console.error;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(args.map(String).join(" "));
|
||||
};
|
||||
console.error = (...args: unknown[]) => {
|
||||
errors.push(args.map(String).join(" "));
|
||||
};
|
||||
return {
|
||||
warns,
|
||||
errors,
|
||||
restore() {
|
||||
console.warn = origWarn;
|
||||
console.error = origError;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("shared leveled logger (v1 parity)", () => {
|
||||
it("default level is warn", () => {
|
||||
assert.equal(getLogLevel(), "warn");
|
||||
});
|
||||
|
||||
it("warn logger emits error+warn, suppresses info+debug", () => {
|
||||
const log = createLogger("warn");
|
||||
const cap = capture();
|
||||
try {
|
||||
log.error("e");
|
||||
log.warn("w");
|
||||
log.info("i");
|
||||
log.debug("d");
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
assert.equal(cap.errors.length, 1);
|
||||
assert.equal(cap.warns.length, 1);
|
||||
});
|
||||
|
||||
it("error logger emits only error", () => {
|
||||
const log = createLogger("error");
|
||||
const cap = capture();
|
||||
try {
|
||||
log.error("e");
|
||||
log.warn("w");
|
||||
log.info("i");
|
||||
log.debug("d");
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
assert.equal(cap.errors.length, 1);
|
||||
assert.equal(cap.warns.length, 0);
|
||||
});
|
||||
|
||||
it("info logger emits error+warn+info, suppresses debug", () => {
|
||||
const log = createLogger("info");
|
||||
const cap = capture();
|
||||
try {
|
||||
log.error("e");
|
||||
log.warn("w");
|
||||
log.info("i");
|
||||
log.debug("d");
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
assert.equal(cap.errors.length, 1);
|
||||
assert.equal(cap.warns.length, 2);
|
||||
});
|
||||
|
||||
it("debug logger emits everything", () => {
|
||||
const log = createLogger("debug");
|
||||
const cap = capture();
|
||||
try {
|
||||
log.error("e");
|
||||
log.warn("w");
|
||||
log.info("i");
|
||||
log.debug("d");
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
assert.equal(cap.errors.length, 1);
|
||||
assert.equal(cap.warns.length, 3);
|
||||
});
|
||||
|
||||
it("lines carry the namespace tag and the uppercased level", () => {
|
||||
const log = createLogger("debug");
|
||||
const cap = capture();
|
||||
try {
|
||||
log.warn("hello");
|
||||
log.error("boom");
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
assert.match(cap.warns[0], /\[omniroute-plugin\] \[WARN\] hello/);
|
||||
assert.match(cap.errors[0], /\[omniroute-plugin\] \[ERROR\] boom/);
|
||||
});
|
||||
|
||||
it("child loggers append their tag after the namespace", () => {
|
||||
const log = createLogger("debug").child("[v2]");
|
||||
const cap = capture();
|
||||
try {
|
||||
log.info("hello");
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
assert.match(cap.warns[0], /\[omniroute-plugin\]\[v2\] \[INFO\] hello/);
|
||||
});
|
||||
|
||||
it("always() emits regardless of level", () => {
|
||||
const log = createLogger("error");
|
||||
const cap = capture();
|
||||
try {
|
||||
log.always("breadcrumb");
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
assert.equal(cap.warns.length, 1);
|
||||
assert.match(cap.warns[0], /breadcrumb/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mapRawModelToModelV2, resolveApiBlockV2 } from "../src/shared/models-map.js";
|
||||
|
||||
describe("resolveApiBlockV2", () => {
|
||||
it("routes unknown ids to openai-compatible with /v1 suffix", () => {
|
||||
assert.deepEqual(resolveApiBlockV2("gpt-x", "https://gw.example.com"), {
|
||||
id: "openai-compatible",
|
||||
url: "https://gw.example.com/v1",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
});
|
||||
});
|
||||
it("does not double the /v1 suffix", () => {
|
||||
assert.equal(
|
||||
resolveApiBlockV2("gpt-x", "https://gw.example.com/v1").url,
|
||||
"https://gw.example.com/v1"
|
||||
);
|
||||
});
|
||||
it("routes allowlisted full id to anthropic even with claude-like prefix elsewhere", () => {
|
||||
const out = resolveApiBlockV2("anthropic/claude-x", "https://gw.example.com/", {
|
||||
allowAnthropic: true,
|
||||
anthropicModels: ["anthropic/claude-x"],
|
||||
});
|
||||
assert.deepEqual(out, {
|
||||
id: "anthropic",
|
||||
url: "https://gw.example.com",
|
||||
npm: "@ai-sdk/anthropic",
|
||||
});
|
||||
});
|
||||
it("keeps a non-allowlisted id on openai-compatible when prefixes are emptied", () => {
|
||||
const out = resolveApiBlockV2("claude/openai-compatible-model", "https://gw.example.com", {
|
||||
allowAnthropic: true,
|
||||
anthropicModels: ["anthropic/claude-x"],
|
||||
anthropicPrefixes: [],
|
||||
});
|
||||
assert.equal(out.id, "openai-compatible");
|
||||
});
|
||||
it("routes a deprecated-prefix id to anthropic with a deprecation warning", () => {
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
try {
|
||||
const out = resolveApiBlockV2("cc/claude-x", "https://gw.example.com", {
|
||||
allowAnthropic: true,
|
||||
anthropicPrefixes: ["cc"],
|
||||
});
|
||||
assert.equal(out.id, "anthropic");
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.ok(warns.some((w) => w.includes("deprecated")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapRawModelToModelV2", () => {
|
||||
it("prefixes bare ids, keeps slashed ids, maps capabilities", () => {
|
||||
const m = mapRawModelToModelV2(
|
||||
{
|
||||
id: "gpt-x",
|
||||
context_length: 128000,
|
||||
max_output_tokens: 4096,
|
||||
input_modalities: ["text", "image"],
|
||||
capabilities: { tool_calling: true, reasoning: true },
|
||||
},
|
||||
{ providerId: "omniroute", baseURL: "https://gw.example.com" }
|
||||
);
|
||||
assert.equal(m.id, "omniroute/gpt-x");
|
||||
assert.equal(m.providerID, "omniroute");
|
||||
assert.equal(m.capabilities.toolcall, true);
|
||||
assert.equal(m.capabilities.reasoning, true);
|
||||
assert.equal(m.capabilities.input.image, true);
|
||||
assert.equal(m.limit.context, 128000);
|
||||
assert.equal(m.limit.output, 4096);
|
||||
assert.equal(m.api.id, "openai-compatible");
|
||||
assert.equal(m.status, "active");
|
||||
});
|
||||
it("maps effort_tiers to variants, omits key when absent", () => {
|
||||
const withTiers = mapRawModelToModelV2(
|
||||
{ id: "r1", capabilities: { effort_tiers: ["low", "high"] } },
|
||||
{ providerId: "omniroute", baseURL: "https://gw.example.com" }
|
||||
);
|
||||
assert.deepEqual(withTiers.variants, {
|
||||
low: { reasoningEffort: "low" },
|
||||
high: { reasoningEffort: "high" },
|
||||
});
|
||||
const without = mapRawModelToModelV2(
|
||||
{ id: "r2" },
|
||||
{ providerId: "omniroute", baseURL: "https://gw.example.com" }
|
||||
);
|
||||
assert.ok(!("variants" in without));
|
||||
});
|
||||
});
|
||||
75
@omniroute/opencode-plugin-v2/tests/shared-naming.test.ts
Normal file
75
@omniroute/opencode-plugin-v2/tests/shared-naming.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Tests for `formatFreeBudget` (@omniroute/opencode-plugin/src/naming.ts):
|
||||
* formats a free-tier model's budget info into a short human-readable
|
||||
* suffix, branching on `freeType`.
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { formatFreeBudget, type FreeModelFreeType } from "../src/shared/naming.js";
|
||||
|
||||
test("formatFreeBudget: recurring-daily formats tokens/day", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-daily", monthlyTokens: 25_000_000 }),
|
||||
"25M tokens/day"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: recurring-monthly formats tokens/month", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-monthly", monthlyTokens: 1_000_000 }),
|
||||
"1M tokens/month"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: recurring-credit formats credits", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-credit", creditTokens: 10_000_000 }),
|
||||
"10M credits"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: one-time-initial formats credits with (one-time) suffix", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "one-time-initial", creditTokens: 1_000_000 }),
|
||||
"1M credits (one-time)"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: keyless has no token/credit args", () => {
|
||||
assert.equal(formatFreeBudget({ freeType: "keyless" }), "(keyless)");
|
||||
});
|
||||
|
||||
test("formatFreeBudget: discontinued has no token/credit args", () => {
|
||||
assert.equal(formatFreeBudget({ freeType: "discontinued" }), "(discontinued)");
|
||||
});
|
||||
|
||||
test("formatFreeBudget: missing token/credit counts default to 0", () => {
|
||||
assert.equal(formatFreeBudget({ freeType: "recurring-daily" }), "0 tokens/day");
|
||||
});
|
||||
|
||||
test("formatFreeBudget: unrecognised freeType falls through to the default branch", () => {
|
||||
// `freeType` is populated from catalog data at runtime, so a value the
|
||||
// build doesn't know about is reachable even though TypeScript treats the
|
||||
// `default:` arm as dead code for a well-typed caller.
|
||||
assert.equal(formatFreeBudget({ freeType: "some-future-type" as FreeModelFreeType }), "");
|
||||
});
|
||||
|
||||
test("formatFreeBudget: sub-1K token count is not abbreviated", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-daily", monthlyTokens: 500 }),
|
||||
"500 tokens/day"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: the 999_999 rounding wart is fixed — promotes to 1M", () => {
|
||||
// `toFixed(1)` rounds 999999/1e3 up to "1000.0" before the `>= 1e6` threshold
|
||||
// check has a chance to apply. fmtTokens now promotes a rounded-up "1000" in
|
||||
// any unit to the next unit up, so this correctly reads "1M" instead of the
|
||||
// old "1000K" wart.
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-daily", monthlyTokens: 999_999 }),
|
||||
"1M tokens/day"
|
||||
);
|
||||
});
|
||||
248
@omniroute/opencode-plugin-v2/tests/shared-usable.test.ts
Normal file
248
@omniroute/opencode-plugin-v2/tests/shared-usable.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
defaultOmniRouteProvidersFetcher,
|
||||
isUsableCombo,
|
||||
isUsableRawModelId,
|
||||
usableProviderAliasSet,
|
||||
} from "../src/shared/usable.js";
|
||||
import type { OmniRouteEnrichmentMap } from "../src/shared/enrich.js";
|
||||
|
||||
function entry(
|
||||
alias: string,
|
||||
canonical: string
|
||||
): [string, { providerAlias: string; providerCanonical: string }] {
|
||||
return [`${alias}/model-x`, { providerAlias: alias, providerCanonical: canonical }];
|
||||
}
|
||||
|
||||
function enrichmentOf(...pairs: Array<[string, string]>): OmniRouteEnrichmentMap {
|
||||
return new Map(pairs.map(([alias, canonical]) => entry(alias, canonical)));
|
||||
}
|
||||
|
||||
function stubFetch(handler: (url: unknown) => unknown): typeof fetch {
|
||||
return (async (url: unknown) => handler(url)) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe("defaultOmniRouteProvidersFetcher envelopes", () => {
|
||||
it("reads the { connections: [...] } envelope", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
const seen: string[] = [];
|
||||
globalThis.fetch = stubFetch((url) => {
|
||||
seen.push(String(url));
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({
|
||||
connections: [{ id: "c1", provider: "claude", isActive: true, testStatus: "active" }],
|
||||
}),
|
||||
};
|
||||
});
|
||||
try {
|
||||
const out = await defaultOmniRouteProvidersFetcher("https://gw.example.com/v1/", "k");
|
||||
assert.equal(seen[0], "https://gw.example.com/api/providers");
|
||||
assert.deepEqual(
|
||||
out.map((c) => c.provider),
|
||||
["claude"]
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("reads a bare array envelope", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch(() => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => [{ id: "c1", provider: "gemini", isActive: true, testStatus: "active" }],
|
||||
}));
|
||||
try {
|
||||
const out = await defaultOmniRouteProvidersFetcher("https://gw.example.com", "k");
|
||||
assert.deepEqual(
|
||||
out.map((c) => c.provider),
|
||||
["gemini"]
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("reads the { data: [...] } envelope", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch(() => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({
|
||||
data: [{ id: "c1", provider: "kiro", isActive: true, testStatus: "active" }],
|
||||
}),
|
||||
}));
|
||||
try {
|
||||
const out = await defaultOmniRouteProvidersFetcher("https://gw.example.com", "k");
|
||||
assert.deepEqual(
|
||||
out.map((c) => c.provider),
|
||||
["kiro"]
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("throws on non-2xx so the caller keeps last-known instead of unfiltering", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch(() => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Error",
|
||||
json: async () => ({}),
|
||||
}));
|
||||
try {
|
||||
await assert.rejects(
|
||||
defaultOmniRouteProvidersFetcher("https://gw.example.com", "k"),
|
||||
/HTTP 500/
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("throws on network failure so the caller keeps last-known", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch(() => {
|
||||
throw new Error("down");
|
||||
});
|
||||
try {
|
||||
await assert.rejects(defaultOmniRouteProvidersFetcher("https://gw.example.com", "k"), /down/);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("soft-fails to [] without baseURL or apiKey", async () => {
|
||||
assert.deepEqual(await defaultOmniRouteProvidersFetcher("", "k"), []);
|
||||
assert.deepEqual(await defaultOmniRouteProvidersFetcher("https://gw.example.com", ""), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("usableProviderAliasSet", () => {
|
||||
it("maps usable canonicals to aliases via enrichment", () => {
|
||||
const usable = usableProviderAliasSet(
|
||||
[{ id: "c1", provider: "claude", isActive: true, testStatus: "active" }],
|
||||
enrichmentOf(["cc", "claude"])
|
||||
);
|
||||
assert.ok(usable.aliases.has("cc"));
|
||||
assert.ok(usable.aliases.has("claude"));
|
||||
assert.ok(usable.canonicals.has("claude"));
|
||||
assert.ok(usable.knownAliases.has("cc"));
|
||||
});
|
||||
|
||||
it("ignores inactive or unhealthy connections", () => {
|
||||
const usable = usableProviderAliasSet(
|
||||
[
|
||||
{ id: "c1", provider: "claude", isActive: false, testStatus: "active" },
|
||||
{ id: "c2", provider: "gemini", isActive: true, testStatus: "error" },
|
||||
],
|
||||
enrichmentOf(["cc", "claude"], ["gm", "gemini"])
|
||||
);
|
||||
assert.equal(usable.aliases.size, 0);
|
||||
assert.equal(usable.canonicals.size, 0);
|
||||
assert.ok(usable.knownAliases.has("cc"));
|
||||
assert.ok(usable.knownAliases.has("gm"));
|
||||
});
|
||||
|
||||
it("treats a missing toggle as no opinion, not disabled", () => {
|
||||
// Older gateways predate the `isActive` field: reading its absence as a
|
||||
// veto would hide every model behind a filter the operator never asked
|
||||
// to tighten. Only an explicit `false` disables.
|
||||
const usable = usableProviderAliasSet(
|
||||
[{ id: "c1", provider: "claude", testStatus: "active" }],
|
||||
enrichmentOf(["cc", "claude"])
|
||||
);
|
||||
assert.ok(usable.aliases.has("cc"));
|
||||
assert.ok(usable.canonicals.has("claude"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("isUsableRawModelId subtract-filter", () => {
|
||||
const usable = usableProviderAliasSet(
|
||||
[{ id: "c1", provider: "claude", isActive: true, testStatus: "active" }],
|
||||
enrichmentOf(["cc", "claude"], ["dead", "legacy"])
|
||||
);
|
||||
|
||||
it("keeps ids with a usable alias prefix", () => {
|
||||
assert.equal(isUsableRawModelId("cc/claude-opus-4-7", usable), true);
|
||||
});
|
||||
|
||||
it("keeps ids with a usable canonical prefix", () => {
|
||||
assert.equal(isUsableRawModelId("claude/sonnet-4", usable), true);
|
||||
});
|
||||
|
||||
it("keeps ids with an unknown prefix", () => {
|
||||
assert.equal(isUsableRawModelId("agentrouter/mystery", usable), true);
|
||||
});
|
||||
|
||||
it("drops ids with a known-but-not-provisioned prefix", () => {
|
||||
assert.equal(isUsableRawModelId("dead/legacy-model", usable), false);
|
||||
});
|
||||
|
||||
it("keeps ids without a prefix", () => {
|
||||
assert.equal(isUsableRawModelId("bare-model", usable), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isUsableCombo", () => {
|
||||
const usable = usableProviderAliasSet(
|
||||
[{ id: "c1", provider: "claude", isActive: true, testStatus: "active" }],
|
||||
enrichmentOf(["cc", "claude"], ["dead", "legacy"])
|
||||
);
|
||||
|
||||
it("keeps a combo with one usable member", () => {
|
||||
assert.equal(
|
||||
isUsableCombo(
|
||||
{
|
||||
id: "c",
|
||||
models: [
|
||||
{ kind: "model", model: "dead/legacy" },
|
||||
{ kind: "model", model: "cc/x" },
|
||||
],
|
||||
},
|
||||
usable
|
||||
),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("drops a combo whose members are all known-but-not-provisioned", () => {
|
||||
assert.equal(
|
||||
isUsableCombo(
|
||||
{
|
||||
id: "c",
|
||||
models: [
|
||||
{ kind: "model", model: "dead/a" },
|
||||
{ kind: "model", model: "dead/b" },
|
||||
],
|
||||
},
|
||||
usable
|
||||
),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a combo with an unknown-prefix member", () => {
|
||||
assert.equal(
|
||||
isUsableCombo({ id: "c", models: [{ kind: "model", model: "agentrouter/mystery" }] }, usable),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps combos with no resolvable members", () => {
|
||||
assert.equal(isUsableCombo({ id: "c", models: [] }, usable), true);
|
||||
assert.equal(isUsableCombo({ id: "c" }, usable), true);
|
||||
assert.equal(
|
||||
isUsableCombo({ id: "c", models: [{ kind: "combo-ref", comboName: "nested" }] }, usable),
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
93
@omniroute/opencode-plugin-v2/tests/smoke-types.test.ts
Normal file
93
@omniroute/opencode-plugin-v2/tests/smoke-types.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { CatalogDraft, PluginContext } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import plugin from "../src/index.js";
|
||||
|
||||
describe("v2 contract smoke", () => {
|
||||
it("default export has string id and function setup", () => {
|
||||
assert.equal(typeof (plugin as { id: unknown }).id, "string");
|
||||
assert.equal(typeof (plugin as { setup: unknown }).setup, "function");
|
||||
});
|
||||
it("setup registers transforms against a structurally-real ctx", async () => {
|
||||
const seen: string[] = [];
|
||||
const ctx = {
|
||||
options: { baseURL: "https://gw.example.com", providerId: "omniroute" },
|
||||
catalog: {
|
||||
transform: async () => {
|
||||
seen.push("catalog.transform");
|
||||
return { dispose: async () => {} };
|
||||
},
|
||||
reload: async () => {},
|
||||
},
|
||||
integration: {
|
||||
transform: async () => {
|
||||
seen.push("integration.transform");
|
||||
return { dispose: async () => {} };
|
||||
},
|
||||
reload: async () => {},
|
||||
connection: { active: async () => undefined, resolve: async () => undefined },
|
||||
},
|
||||
agent: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
|
||||
command: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
|
||||
reference: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
|
||||
skill: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
|
||||
aisdk: {
|
||||
sdk: async () => ({ dispose: async () => {} }),
|
||||
language: async () => ({ dispose: async () => {} }),
|
||||
},
|
||||
plugin: { add: async () => {}, remove: async () => {} },
|
||||
} satisfies PluginContext;
|
||||
await (plugin as { setup: (c: PluginContext) => Promise<void> }).setup(ctx);
|
||||
assert.deepEqual(seen, ["catalog.transform", "integration.transform"]);
|
||||
});
|
||||
it("publishCatalog writes into a real CatalogDraft without proxy breakage", async () => {
|
||||
const { publishCatalog } = await import("../src/catalog.js");
|
||||
const written: { provider?: string; models: string[] } = { models: [] };
|
||||
const draft: CatalogDraft = {
|
||||
provider: {
|
||||
list: () => [],
|
||||
get: () => undefined,
|
||||
update: (id: string, fn: (p: ProviderV2Info) => void) => {
|
||||
written.provider = id;
|
||||
const p = {
|
||||
id,
|
||||
name: "",
|
||||
api: { type: "aisdk", package: "" },
|
||||
request: { headers: {}, body: {} },
|
||||
} as ProviderV2Info;
|
||||
fn(p);
|
||||
},
|
||||
remove: () => {},
|
||||
},
|
||||
model: {
|
||||
get: () => undefined,
|
||||
update: (providerID: string, modelID: string, fn: (d: ModelV2Info) => void) => {
|
||||
written.models.push(providerID + "/" + modelID);
|
||||
const d = { id: modelID, providerID } as ModelV2Info;
|
||||
fn(d);
|
||||
},
|
||||
remove: () => {},
|
||||
default: { get: () => undefined, set: () => {} },
|
||||
},
|
||||
};
|
||||
const res = await publishCatalog(
|
||||
draft,
|
||||
{
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 300000,
|
||||
usableOnly: false,
|
||||
},
|
||||
{
|
||||
models: async () => [{ id: "m1", context_length: 1000 }],
|
||||
combos: async () => [],
|
||||
}
|
||||
);
|
||||
assert.equal(res.models, 1);
|
||||
assert.equal(written.provider, "omniroute");
|
||||
assert.deepEqual(written.models, ["omniroute/m1"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import plugin from "../src/index.js";
|
||||
import { diskSnapshotPath, snapshotIdentityFingerprint } from "../src/cache.js";
|
||||
import { legacyApiToInfoApi } from "../src/catalog.js";
|
||||
|
||||
function isolateDisk(): { dir: string; restore: () => void } {
|
||||
const dir = mkdtempSync(join(tmpdir(), "omniroute-snapfix-"));
|
||||
const prev = process.env.OPENCODE_DATA_DIR;
|
||||
process.env.OPENCODE_DATA_DIR = dir;
|
||||
return {
|
||||
dir,
|
||||
restore: () => {
|
||||
if (prev === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = prev;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setupCtx(providerId: string): {
|
||||
callbacks: Array<(draft: unknown) => Promise<void>>;
|
||||
ctx: Record<string, unknown>;
|
||||
} {
|
||||
const callbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
const ctx = {
|
||||
options: {
|
||||
providerId,
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k-snapfix",
|
||||
},
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => Promise<void>) => {
|
||||
callbacks.push(cb);
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
reload: async () => {},
|
||||
},
|
||||
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
|
||||
};
|
||||
return { callbacks, ctx };
|
||||
}
|
||||
|
||||
function stubDraft(): { draft: unknown; published: Map<string, Record<string, unknown>> } {
|
||||
const published = new Map<string, Record<string, unknown>>();
|
||||
return {
|
||||
published,
|
||||
draft: {
|
||||
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
|
||||
model: {
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => {
|
||||
const entry: Record<string, unknown> = { id: mid, providerID: pid };
|
||||
fn(entry);
|
||||
published.set(pid + "/" + mid, entry);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function silenceConsole<T>(fn: () => Promise<T>): Promise<{ result: T; warns: string[] }> {
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
const origLog = console.log;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
console.log = () => {};
|
||||
try {
|
||||
const result = await fn();
|
||||
return { result, warns };
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
console.log = origLog;
|
||||
}
|
||||
}
|
||||
|
||||
function downFetch(): typeof fetch {
|
||||
return (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/api/pricing") || href.includes("/api/free-tier")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({}) };
|
||||
}
|
||||
if (href.includes("/api/combos/auto")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
if (href.includes("/api/combos")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
return { ok: false, status: 500, statusText: "Down", json: async () => ({}) };
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
const fingerprint = snapshotIdentityFingerprint("https://gw.example.com", "k-snapfix", "k-snapfix");
|
||||
|
||||
describe("plugin-v2 snapshot stale-entry filter", () => {
|
||||
it("snapshot with 2 entries without api block + 1 valid: only the valid one is published + warn emitted", async () => {
|
||||
const disk = isolateDisk();
|
||||
const providerId = "snapfix-mixed";
|
||||
mkdirSync(join(disk.dir, "plugins"), { recursive: true });
|
||||
writeFileSync(
|
||||
diskSnapshotPath(providerId),
|
||||
JSON.stringify({
|
||||
v: 2,
|
||||
identityFingerprint: fingerprint,
|
||||
// Two pre-mapped entries with a broken api block (missing npm) plus
|
||||
// one plain raw entry (no api block: synthesized at publish time).
|
||||
models: [
|
||||
{ id: "stale-a", api: {} },
|
||||
{ id: "stale-b", api: { npm: "" } },
|
||||
{ id: "good-1", context_length: 128000 },
|
||||
],
|
||||
combos: [],
|
||||
autoCombos: [],
|
||||
providers: [],
|
||||
writtenAt: Date.now(),
|
||||
})
|
||||
);
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = downFetch();
|
||||
try {
|
||||
const { callbacks, ctx } = setupCtx(providerId);
|
||||
const { warns } = await silenceConsole(async () => {
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
const { draft, published } = stubDraft();
|
||||
await callbacks[0](draft);
|
||||
assert.ok(
|
||||
published.has(`${providerId}/good-1`),
|
||||
`valid entry must be published, got: ${JSON.stringify([...published.keys()])}`
|
||||
);
|
||||
assert.ok(
|
||||
![...published.keys()].some((k) => k.includes("stale")),
|
||||
`stale entries must be dropped, got: ${JSON.stringify([...published.keys()])}`
|
||||
);
|
||||
});
|
||||
assert.ok(
|
||||
warns.some((w) => w.includes("dropping 2 stale snapshot entries without api block")),
|
||||
`expected stale-drop warn, got: ${JSON.stringify(warns)}`
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
disk.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("snapshot without version (v1 format): ignored entirely, fail-open to fresh fetch", async () => {
|
||||
const disk = isolateDisk();
|
||||
const providerId = "snapfix-unversioned";
|
||||
mkdirSync(join(disk.dir, "plugins"), { recursive: true });
|
||||
writeFileSync(
|
||||
diskSnapshotPath(providerId),
|
||||
JSON.stringify({
|
||||
identityFingerprint: "whatever",
|
||||
rawModels: [{ id: "ancient" }],
|
||||
rawCombos: [],
|
||||
writtenAt: Date.now(),
|
||||
})
|
||||
);
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/api/pricing") || href.includes("/api/free-tier")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({}) };
|
||||
}
|
||||
if (href.includes("/api/combos/auto")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
if (href.includes("/api/combos")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ data: [{ id: "fresh-1" }] }),
|
||||
};
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const { callbacks, ctx } = setupCtx(providerId);
|
||||
await silenceConsole(async () => {
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
const { draft, published } = stubDraft();
|
||||
await callbacks[0](draft);
|
||||
assert.ok(
|
||||
published.has(`${providerId}/fresh-1`),
|
||||
`fresh fetch must win over unversioned snapshot, got: ${JSON.stringify([...published.keys()])}`
|
||||
);
|
||||
assert.ok(
|
||||
![...published.keys()].some((k) => k.includes("ancient")),
|
||||
`unversioned snapshot must be ignored, got: ${JSON.stringify([...published.keys()])}`
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
disk.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("legacyApiToInfoApi throws on missing api.npm (fail-fast, publish guard turns it into a warn)", () => {
|
||||
assert.throws(
|
||||
() => legacyApiToInfoApi(undefined as unknown as { id: string; npm: string; url: string }),
|
||||
/without an api block/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
legacyApiToInfoApi({ id: "openai-compatible", url: "https://x/v1" } as unknown as {
|
||||
id: string;
|
||||
npm: string;
|
||||
url: string;
|
||||
}),
|
||||
/without an api block/
|
||||
);
|
||||
// Sanity: sha256 helper used above matches the plugin identity scheme.
|
||||
assert.equal(createHash("sha256").update("x").digest("hex").length, 64);
|
||||
});
|
||||
});
|
||||
452
@omniroute/opencode-plugin-v2/tests/staged-refresh.test.ts
Normal file
452
@omniroute/opencode-plugin-v2/tests/staged-refresh.test.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import plugin from "../src/index.js";
|
||||
|
||||
// A catalog that only appears once the slowest optional source has answered is
|
||||
// a catalog that never appears at all on a host that exits first: an
|
||||
// unreachable `/api/combos/auto` kept models, combos and everything else
|
||||
// unpublished until its own timeout fired. Models and combos must reach the
|
||||
// draft as soon as they are known; the optional sources upgrade the snapshot
|
||||
// when they land.
|
||||
describe("plugin-v2 staged refresh: optional sources never gate the publish", () => {
|
||||
let seq = 0;
|
||||
|
||||
async function isolateDisk(): Promise<() => void> {
|
||||
const { mkdtempSync } = await import("node:fs");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
seq += 1;
|
||||
const dir = mkdtempSync(join(tmpdir(), `omniroute-staged-${seq}-`));
|
||||
const prev = process.env.OPENCODE_DATA_DIR;
|
||||
process.env.OPENCODE_DATA_DIR = dir;
|
||||
return () => {
|
||||
if (prev === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = prev;
|
||||
};
|
||||
}
|
||||
|
||||
function setupCtx(
|
||||
providerId: string,
|
||||
reloads: { count: number }
|
||||
): {
|
||||
catalogCallbacks: Array<(draft: unknown) => Promise<void>>;
|
||||
ctx: Record<string, unknown>;
|
||||
} {
|
||||
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
const ctx = {
|
||||
options: { baseURL: "https://gw.example.com", providerId, apiKey: "k-" + providerId },
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => Promise<void>) => {
|
||||
catalogCallbacks.push(cb);
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
reload: async () => {
|
||||
reloads.count += 1;
|
||||
},
|
||||
},
|
||||
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
|
||||
};
|
||||
return { catalogCallbacks, ctx };
|
||||
}
|
||||
|
||||
function stubDraft(): { draft: unknown; published: Map<string, Record<string, unknown>> } {
|
||||
const published = new Map<string, Record<string, unknown>>();
|
||||
const draft = {
|
||||
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
|
||||
model: {
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => {
|
||||
const entry: Record<string, unknown> = { id: mid, providerID: pid };
|
||||
fn(entry);
|
||||
published.set(pid + "/" + mid, entry);
|
||||
},
|
||||
},
|
||||
};
|
||||
return { draft, published };
|
||||
}
|
||||
|
||||
/**
|
||||
* `/api/combos/auto` never answers and never honours the abort signal — the
|
||||
* shape of a gateway that accepts the connection and then goes quiet.
|
||||
*/
|
||||
function stubFetch(opts: {
|
||||
autoCombosHangs: boolean;
|
||||
combosHangs?: boolean;
|
||||
enrichmentDelayMs?: number;
|
||||
}): typeof fetch {
|
||||
return (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
const ok = (body: unknown) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => body,
|
||||
});
|
||||
if (href.includes("/api/combos/auto")) {
|
||||
if (opts.autoCombosHangs) return await new Promise(() => {});
|
||||
return ok({ combos: [] });
|
||||
}
|
||||
if (href.includes("/api/combos")) {
|
||||
// A gateway that accepts the connection and then goes quiet on the
|
||||
// combos endpoint: models must still publish without waiting for it.
|
||||
if (opts.combosHangs) return await new Promise(() => {});
|
||||
return ok({ combos: [] });
|
||||
}
|
||||
if (href.includes("/api/pricing/models")) {
|
||||
if (opts.enrichmentDelayMs !== undefined) {
|
||||
await new Promise((r) => setTimeout(r, opts.enrichmentDelayMs));
|
||||
}
|
||||
return ok({
|
||||
omni: {
|
||||
id: "omni",
|
||||
alias: "omni",
|
||||
name: "Omni",
|
||||
models: [{ id: "m1", name: "Model One" }],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (href.includes("/api/pricing")) return ok({});
|
||||
if (href.includes("/api/free-tier/summary")) return ok({});
|
||||
return ok({ data: [{ id: "m1" }] });
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
async function withSilentConsole<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const warn = console.warn;
|
||||
const log = console.log;
|
||||
console.warn = () => {};
|
||||
console.log = () => {};
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
console.log = log;
|
||||
}
|
||||
}
|
||||
|
||||
it("publishes models while an optional source is still hanging", async () => {
|
||||
const restoreDisk = await isolateDisk();
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({ autoCombosHangs: true });
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx("staged-hang", reloads);
|
||||
try {
|
||||
await withSilentConsole(async () => {
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
const { draft, published } = stubDraft();
|
||||
const done = catalogCallbacks[0]!(draft);
|
||||
const raced = await Promise.race([
|
||||
done.then(() => "published" as const),
|
||||
new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 1500)),
|
||||
]);
|
||||
assert.equal(
|
||||
raced,
|
||||
"published",
|
||||
"the publish must not wait on a source that never answers"
|
||||
);
|
||||
assert.ok([...published.keys()].some((k) => k.endsWith("/m1")));
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
|
||||
it("applies an optional source that lands after the publish, on the next transform", async () => {
|
||||
const restoreDisk = await isolateDisk();
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({ autoCombosHangs: false, enrichmentDelayMs: 120 });
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx("staged-late", reloads);
|
||||
try {
|
||||
await withSilentConsole(async () => {
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
const first = stubDraft();
|
||||
await catalogCallbacks[0]!(first.draft);
|
||||
const early = [...first.published.values()].find((m) => m["id"] === "m1");
|
||||
assert.ok(early, "models publish before the slow enrichment");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const second = stubDraft();
|
||||
await catalogCallbacks[0]!(second.draft);
|
||||
const late = [...second.published.values()].find((m) => m["id"] === "m1");
|
||||
// The overlay is rendered, not just stored: the provider label the
|
||||
// gateway ships alongside the display name reaches the picker.
|
||||
assert.equal(
|
||||
late?.["name"],
|
||||
"Omni - Model One",
|
||||
"the late enrichment must reach the catalog, provider tag included"
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not ask the host to reload when the overlay came back identical", async () => {
|
||||
const restoreDisk = await isolateDisk();
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({ autoCombosHangs: false });
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx("staged-stable", reloads);
|
||||
try {
|
||||
await withSilentConsole(async () => {
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const d = stubDraft();
|
||||
await catalogCallbacks[0]!(d.draft);
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
});
|
||||
assert.ok(
|
||||
reloads.count <= 1,
|
||||
`an unchanged overlay must not trigger a reload per refresh, got ${reloads.count}`
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
|
||||
it("a refresh after the TTL keeps the overlay instead of downgrading the picker", async () => {
|
||||
const restoreDisk = await isolateDisk();
|
||||
const origFetch = globalThis.fetch;
|
||||
// Enrichment answers once, then goes away: the second refresh must not
|
||||
// strip the names the first one obtained.
|
||||
let enrichCalls = 0;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
const ok = (body: unknown) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => body,
|
||||
});
|
||||
if (href.includes("/api/combos/auto")) return ok({ combos: [] });
|
||||
if (href.includes("/api/combos")) return ok({ combos: [] });
|
||||
if (href.includes("/api/pricing/models")) {
|
||||
enrichCalls += 1;
|
||||
if (enrichCalls > 1) {
|
||||
return { ok: false, status: 503, statusText: "Unavailable", json: async () => ({}) };
|
||||
}
|
||||
return ok({
|
||||
omni: {
|
||||
id: "omni",
|
||||
alias: "omni",
|
||||
name: "Omni",
|
||||
models: [{ id: "m1", name: "Model One" }],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (href.includes("/api/pricing")) return ok({});
|
||||
if (href.includes("/api/free-tier/summary")) return ok({});
|
||||
return ok({ data: [{ id: "m1" }] });
|
||||
}) as unknown as typeof fetch;
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx("staged-ttl", reloads);
|
||||
(ctx["options"] as Record<string, unknown>)["modelCacheTtlMs"] = 1;
|
||||
try {
|
||||
await withSilentConsole(async () => {
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
await catalogCallbacks[0]!(stubDraft().draft);
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const second = stubDraft();
|
||||
await catalogCallbacks[0]!(second.draft);
|
||||
const m1 = [...second.published.values()].find((m) => m["id"] === "m1");
|
||||
assert.equal(
|
||||
m1?.["name"],
|
||||
"Omni - Model One",
|
||||
"the second refresh must keep the name the first one resolved"
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes models while a hanging /api/combos holds nothing back", async () => {
|
||||
// Combos used to sit on the critical path (Promise.all with models), so a
|
||||
// gateway slow on /api/combos held the whole picker back. Regression pin:
|
||||
// models publish even when combos never answers.
|
||||
const restoreDisk = await isolateDisk();
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({ autoCombosHangs: false, combosHangs: true });
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx("staged-combos-hang", reloads);
|
||||
try {
|
||||
await withSilentConsole(async () => {
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
const { draft, published } = stubDraft();
|
||||
const done = catalogCallbacks[0]!(draft);
|
||||
const raced = await Promise.race([
|
||||
done.then(() => "published" as const),
|
||||
new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 1500)),
|
||||
]);
|
||||
assert.equal(
|
||||
raced,
|
||||
"published",
|
||||
"models must publish without waiting for a hanging /api/combos"
|
||||
);
|
||||
assert.ok([...published.keys()].some((k) => k.endsWith("/m1")));
|
||||
// "staged-combos-hang" contains "combo" as a substring — filter on the
|
||||
// model id suffix instead: no published model id may start with a
|
||||
// combo prefix.
|
||||
assert.equal(
|
||||
[...published.keys()].filter((k) => /\/combo/i.test(k)).length,
|
||||
0,
|
||||
`no combos known yet — models-only on the first publish is correct, got ${JSON.stringify([...published.keys()])}`
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps names/pricing on the third transform when enrichment stays down", async () => {
|
||||
// The old all-empty guard is gone by design (it also blocked genuine
|
||||
// removals); the per-source failure signal replaces it. A persistent 503
|
||||
// on the overlay must keep last-known names on EVERY later transform,
|
||||
// not just the second one — this is the regression pin for the throw
|
||||
// instead of soft-fail change.
|
||||
const restoreDisk = await isolateDisk();
|
||||
const origFetch = globalThis.fetch;
|
||||
let enrichCalls = 0;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
const ok = (body: unknown) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => body,
|
||||
});
|
||||
if (href.includes("/api/combos/auto")) return ok({ combos: [] });
|
||||
if (href.includes("/api/combos")) return ok({ combos: [] });
|
||||
if (href.includes("/api/pricing/models")) {
|
||||
enrichCalls += 1;
|
||||
if (enrichCalls > 1) {
|
||||
return { ok: false, status: 503, statusText: "Unavailable", json: async () => ({}) };
|
||||
}
|
||||
return ok({
|
||||
omni: {
|
||||
id: "omni",
|
||||
alias: "omni",
|
||||
name: "Omni",
|
||||
models: [{ id: "m1", name: "Model One" }],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (href.includes("/api/pricing")) return ok({});
|
||||
if (href.includes("/api/free-tier/summary")) return ok({});
|
||||
return ok({ data: [{ id: "m1" }] });
|
||||
}) as unknown as typeof fetch;
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx("staged-enrich-down", reloads);
|
||||
(ctx["options"] as Record<string, unknown>)["modelCacheTtlMs"] = 1;
|
||||
try {
|
||||
await withSilentConsole(async () => {
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
await catalogCallbacks[0]!(stubDraft().draft);
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
await catalogCallbacks[0]!(stubDraft().draft);
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const third = stubDraft();
|
||||
await catalogCallbacks[0]!(third.draft);
|
||||
const m1 = [...third.published.values()].find((m) => m["id"] === "m1");
|
||||
assert.equal(
|
||||
m1?.["name"],
|
||||
"Omni - Model One",
|
||||
`a persistently failing overlay must not wipe names, got ${JSON.stringify(m1?.["name"])}`
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
|
||||
it("serves last-known without refetching inside the unreachable window", async () => {
|
||||
// A gateway that answers nothing at all gets a short breather instead of
|
||||
// a full fetch suite on every transform: the cooldown arms only once a
|
||||
// total failure is confirmed (no models AND a prior entry exists to
|
||||
// serve), and transforms inside the window must not issue new requests.
|
||||
const restoreDisk = await isolateDisk();
|
||||
const origFetch = globalThis.fetch;
|
||||
let modelCalls = 0;
|
||||
let down = false;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
const ok = (body: unknown) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => body,
|
||||
});
|
||||
if (href.includes("/api/combos/auto")) return ok({ combos: [] });
|
||||
if (href.includes("/api/combos")) return ok({ combos: [] });
|
||||
if (href.includes("/api/pricing") || href.includes("/api/free-tier")) return ok({});
|
||||
modelCalls += 1;
|
||||
if (down) {
|
||||
return { ok: false, status: 500, statusText: "Down", json: async () => ({}) };
|
||||
}
|
||||
return ok({ data: [{ id: "m1" }] });
|
||||
}) as unknown as typeof fetch;
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx("staged-unreachable", reloads);
|
||||
(ctx["options"] as Record<string, unknown>)["modelCacheTtlMs"] = 1;
|
||||
try {
|
||||
await withSilentConsole(async () => {
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
// First transform: gateway healthy, entry stored.
|
||||
await catalogCallbacks[0]!(stubDraft().draft);
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
// Gateway goes down only now: the next transform fails totally while
|
||||
// a prior entry exists, arming the cooldown.
|
||||
down = true;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await catalogCallbacks[0]!(stubDraft().draft);
|
||||
const afterArming = modelCalls;
|
||||
assert.ok(afterArming >= 2, "the failing transform tries the network once");
|
||||
await catalogCallbacks[0]!(stubDraft().draft);
|
||||
assert.equal(modelCalls, afterArming, "a transform inside the cooldown must not refetch");
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
|
||||
it("throws on a synchronously refusing integration hook, catalog intact", async () => {
|
||||
// A host whose integration.transform throws while registering must cost
|
||||
// the plugin the connect action only — setup resolves, the catalog
|
||||
// callback is registered, and the throw is warned, not propagated.
|
||||
const restoreDisk = await isolateDisk();
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({ autoCombosHangs: false });
|
||||
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
const ctx = {
|
||||
options: { baseURL: "https://gw.example.com", providerId: "staged-integ", apiKey: "k" },
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => Promise<void>) => {
|
||||
catalogCallbacks.push(cb);
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
},
|
||||
integration: {
|
||||
transform: () => {
|
||||
throw new Error("host says no");
|
||||
},
|
||||
},
|
||||
};
|
||||
try {
|
||||
await withSilentConsole(async () => {
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
assert.equal(catalogCallbacks.length, 1, "the catalog still registers");
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
});
|
||||
161
@omniroute/opencode-plugin-v2/tests/timeouts-logger.test.ts
Normal file
161
@omniroute/opencode-plugin-v2/tests/timeouts-logger.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parsePluginOptions, resolveTimeouts } from "../src/options.js";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
|
||||
function fakeDraft(): CatalogDraft {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
return {
|
||||
provider: {
|
||||
list: () => [],
|
||||
get: (id: string) => providers.get(id) as never,
|
||||
update: (id: string, fn: (p: ProviderV2Info) => void) => {
|
||||
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
|
||||
fn(p);
|
||||
providers.set(id, p);
|
||||
},
|
||||
remove: () => {},
|
||||
},
|
||||
model: {
|
||||
get: () => undefined,
|
||||
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
|
||||
const k = pid + "/" + mid;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
|
||||
fn(m);
|
||||
models.set(k, m);
|
||||
},
|
||||
remove: () => {},
|
||||
default: { get: () => undefined, set: () => {} },
|
||||
},
|
||||
} as CatalogDraft;
|
||||
}
|
||||
|
||||
const BER = "https://gw.example.com";
|
||||
|
||||
describe("plugin-v2 P2 parity: per-endpoint timeouts", () => {
|
||||
it("timeoutMs default is 10s (v1 parity over the old 30s)", () => {
|
||||
const opts = parsePluginOptions({ baseURL: BER });
|
||||
assert.equal(opts.timeoutMs, 10000);
|
||||
});
|
||||
|
||||
it("endpoint timeouts default to the global timeout", () => {
|
||||
const opts = parsePluginOptions({ baseURL: BER });
|
||||
assert.deepEqual(resolveTimeouts(opts), {
|
||||
models: 10000,
|
||||
combos: 10000,
|
||||
autoCombos: 5000,
|
||||
enrichment: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
it("endpoint overrides win over the global timeout", () => {
|
||||
const opts = parsePluginOptions({
|
||||
baseURL: BER,
|
||||
timeoutMs: 3000,
|
||||
timeouts: { models: 1111, combos: 2222, autoCombos: 3333, enrichment: 4444 },
|
||||
});
|
||||
assert.deepEqual(resolveTimeouts(opts), {
|
||||
models: 1111,
|
||||
combos: 2222,
|
||||
autoCombos: 3333,
|
||||
enrichment: 4444,
|
||||
});
|
||||
});
|
||||
|
||||
it("publishCatalog forwards per-endpoint timeouts (slow models stub aborts at 20ms)", async () => {
|
||||
const seen: number[] = [];
|
||||
const t0 = Date.now();
|
||||
const slowModels = async (_base: string, _key: string, timeoutMs?: number) => {
|
||||
seen.push(timeoutMs ?? -1);
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(Object.assign(new Error("This operation was aborted"), { name: "AbortError" }));
|
||||
}, timeoutMs ?? 10000);
|
||||
});
|
||||
return [{ id: "m1" }];
|
||||
};
|
||||
const draft = fakeDraft();
|
||||
const origWarn = console.warn;
|
||||
console.warn = () => {};
|
||||
try {
|
||||
const res = await publishCatalog(
|
||||
draft,
|
||||
{
|
||||
providerId: "omniroute",
|
||||
baseURL: BER,
|
||||
apiKey: "k",
|
||||
// A per-endpoint value must win over the global one: the 20ms
|
||||
// models budget is what this test asserts, not the 10s fallback.
|
||||
timeoutMs: 10000,
|
||||
timeouts: { models: 20, combos: 10000 },
|
||||
modelCacheTtlMs: 300000,
|
||||
usableOnly: false,
|
||||
},
|
||||
{ fetcher: slowModels, combosFetcher: async () => [] }
|
||||
);
|
||||
assert.deepEqual(res, { models: 0, combos: 0, autoCombos: 0 });
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
assert.deepEqual(seen, [20]);
|
||||
assert.ok(Date.now() - t0 < 2000, "slow fetcher must abort near 20ms, not hang");
|
||||
});
|
||||
|
||||
it("logger option accepts logLevel plus startupDebug (default warn)", () => {
|
||||
assert.equal(parsePluginOptions({ baseURL: BER }).logLevel, undefined);
|
||||
assert.equal(parsePluginOptions({ baseURL: BER }).startupDebug, undefined);
|
||||
assert.equal(parsePluginOptions({ baseURL: BER, logLevel: "debug" }).logLevel, "debug");
|
||||
assert.equal(parsePluginOptions({ baseURL: BER, startupDebug: true }).startupDebug, true);
|
||||
assert.throws(() => parsePluginOptions({ baseURL: BER, logLevel: "verbose" }));
|
||||
});
|
||||
|
||||
it("startupDebug silences the boot line at default, shows it with startupDebug", async () => {
|
||||
const plugin = (await import("../src/index.js")).default as unknown as {
|
||||
setup: (ctx: unknown) => Promise<void>;
|
||||
};
|
||||
async function bootLogLines(options: Record<string, unknown>): Promise<string[]> {
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
const ctx = {
|
||||
options,
|
||||
catalog: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
integration: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
};
|
||||
try {
|
||||
await plugin.setup(ctx);
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
return warns;
|
||||
}
|
||||
const quiet = await bootLogLines({
|
||||
baseURL: BER,
|
||||
providerId: "p2-quiet",
|
||||
apiKey: "k",
|
||||
});
|
||||
assert.ok(
|
||||
!quiet.some((line) => line.includes("init providerId=")),
|
||||
`boot line must stay silent by default, got: ${JSON.stringify(quiet)}`
|
||||
);
|
||||
const loud = await bootLogLines({
|
||||
baseURL: BER,
|
||||
providerId: "p2-loud",
|
||||
apiKey: "k",
|
||||
startupDebug: true,
|
||||
});
|
||||
assert.ok(
|
||||
loud.some((line) => line.includes("init providerId=p2-loud")),
|
||||
`boot line must show with startupDebug, got: ${JSON.stringify(loud)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
222
@omniroute/opencode-plugin-v2/tests/usable-only.test.ts
Normal file
222
@omniroute/opencode-plugin-v2/tests/usable-only.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
import { parsePluginOptions } from "../src/options.js";
|
||||
|
||||
function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft } {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
const draft = {
|
||||
provider: {
|
||||
list: () => [],
|
||||
get: (id: string) => providers.get(id) as never,
|
||||
update: (id: string, fn: (p: ProviderV2Info) => void) => {
|
||||
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
|
||||
fn(p);
|
||||
providers.set(id, p);
|
||||
},
|
||||
remove: () => {},
|
||||
},
|
||||
model: {
|
||||
get: () => undefined,
|
||||
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
|
||||
const k = pid + "/" + mid;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
|
||||
fn(m);
|
||||
models.set(k, m);
|
||||
},
|
||||
remove: () => {},
|
||||
default: { get: () => undefined, set: () => {} },
|
||||
},
|
||||
} as CatalogDraft;
|
||||
return { models, draft };
|
||||
}
|
||||
|
||||
function silence() {
|
||||
const origWarn = console.warn;
|
||||
const origLog = console.log;
|
||||
console.warn = () => {};
|
||||
console.log = () => {};
|
||||
return {
|
||||
restore() {
|
||||
console.warn = origWarn;
|
||||
console.log = origLog;
|
||||
},
|
||||
};
|
||||
}
|
||||
function enrichmentOf(...pairs: Array<[string, string]>) {
|
||||
return new Map(
|
||||
pairs.map(([alias, canonical]) => [
|
||||
`${alias}/model-x`,
|
||||
{ providerAlias: alias, providerCanonical: canonical },
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
const baseOpts = {
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 300000,
|
||||
};
|
||||
|
||||
describe("usableOnly option default", () => {
|
||||
it("defaults to false", () => {
|
||||
const opts = parsePluginOptions({ baseURL: "https://gw.example.com" });
|
||||
assert.equal(opts.usableOnly, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog usableOnly gating", () => {
|
||||
const stubModels = async () => [
|
||||
{ id: "cc/keep-me" },
|
||||
{ id: "dead/drop-me" },
|
||||
{ id: "agentrouter/mystery" },
|
||||
];
|
||||
|
||||
it("usableOnly=false performs zero providers fetches", async () => {
|
||||
const { draft, models } = fakeDraft();
|
||||
let providersCalls = 0;
|
||||
const res = await publishCatalog(
|
||||
draft,
|
||||
{ ...baseOpts, usableOnly: false },
|
||||
{
|
||||
fetcher: stubModels,
|
||||
combosFetcher: async () => [],
|
||||
providersFetcher: async () => {
|
||||
providersCalls += 1;
|
||||
return [];
|
||||
},
|
||||
}
|
||||
);
|
||||
assert.equal(providersCalls, 0);
|
||||
assert.equal(res.models, 3);
|
||||
assert.ok(models.has("omniroute/cc/keep-me"));
|
||||
assert.ok(models.has("omniroute/dead/drop-me"));
|
||||
});
|
||||
|
||||
it("usableOnly=true drops known-but-not-provisioned models, keeps unknown prefixes", async () => {
|
||||
const { draft, models } = fakeDraft();
|
||||
const res = await publishCatalog(
|
||||
draft,
|
||||
{
|
||||
...baseOpts,
|
||||
usableOnly: true,
|
||||
enrichment: enrichmentOf(["cc", "claude"], ["dead", "legacy"]),
|
||||
},
|
||||
{
|
||||
fetcher: stubModels,
|
||||
combosFetcher: async () => [],
|
||||
providersFetcher: async () => [
|
||||
{ id: "c1", provider: "claude", isActive: true, testStatus: "active" },
|
||||
],
|
||||
}
|
||||
);
|
||||
assert.equal(res.models, 2);
|
||||
assert.ok(models.has("omniroute/cc/keep-me"));
|
||||
assert.ok(!models.has("omniroute/dead/drop-me"));
|
||||
assert.ok(models.has("omniroute/agentrouter/mystery"));
|
||||
});
|
||||
|
||||
it("usableOnly=true with providers soft-fail keeps the full catalog", async () => {
|
||||
const { draft, models } = fakeDraft();
|
||||
const res = await publishCatalog(
|
||||
draft,
|
||||
{ ...baseOpts, usableOnly: true },
|
||||
{
|
||||
fetcher: stubModels,
|
||||
combosFetcher: async () => [],
|
||||
providersFetcher: async () => {
|
||||
throw new Error("down");
|
||||
},
|
||||
}
|
||||
);
|
||||
assert.equal(res.models, 3);
|
||||
assert.ok(models.has("omniroute/dead/drop-me"));
|
||||
});
|
||||
|
||||
it("usableOnly=true filters combos by member usability", async () => {
|
||||
const { draft, models } = fakeDraft();
|
||||
const res = await publishCatalog(
|
||||
draft,
|
||||
{
|
||||
...baseOpts,
|
||||
usableOnly: true,
|
||||
enrichment: enrichmentOf(["cc", "claude"], ["dead", "legacy"]),
|
||||
},
|
||||
{
|
||||
fetcher: async () => [{ id: "cc/x" }, { id: "dead/y" }],
|
||||
combosFetcher: async () => [
|
||||
{ id: "good", name: "Good", models: [{ kind: "model", model: "cc/x" }] },
|
||||
{ id: "bad", name: "Bad", models: [{ kind: "model", model: "dead/y" }] },
|
||||
],
|
||||
providersFetcher: async () => [
|
||||
{ id: "c1", provider: "claude", isActive: true, testStatus: "active" },
|
||||
],
|
||||
}
|
||||
);
|
||||
assert.equal(res.combos, 1);
|
||||
assert.ok(models.has("omniroute/good"));
|
||||
assert.ok(!models.has("omniroute/bad"));
|
||||
});
|
||||
|
||||
it("usableOnly=false issues no providers request through setup (gating)", async () => {
|
||||
const seen: string[] = [];
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
seen.push(href);
|
||||
if (href.includes("/api/combos/auto")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
if (href.includes("/api/combos")) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ data: [{ id: "m1" }] }),
|
||||
};
|
||||
}) as typeof fetch;
|
||||
const guard = silence();
|
||||
try {
|
||||
const plugin = (await import("../src/index.js")).default as unknown as {
|
||||
setup: (ctx: unknown) => Promise<void>;
|
||||
};
|
||||
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
await plugin.setup({
|
||||
options: { baseURL: "https://gw.example.com", providerId: "usable-gate" },
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => Promise<void>) => {
|
||||
catalogCallbacks.push(cb);
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
},
|
||||
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
|
||||
});
|
||||
const { mkdtempSync } = await import("node:fs");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const prevDataDir = process.env.OPENCODE_DATA_DIR;
|
||||
process.env.OPENCODE_DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-usable-"));
|
||||
try {
|
||||
const { draft } = fakeDraft();
|
||||
await catalogCallbacks[0](draft as never);
|
||||
} finally {
|
||||
if (prevDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = prevDataDir;
|
||||
}
|
||||
assert.ok(
|
||||
!seen.some((href) => href.includes("/api/providers")),
|
||||
`no providers fetch expected, got: ${JSON.stringify(seen)}`
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
guard.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import plugin from "../src/index.js";
|
||||
import { snapshotIdentityFingerprint, writeDiskSnapshot } from "../src/cache.js";
|
||||
|
||||
/**
|
||||
* The recommended setup stores the gateway key in the host's credential store,
|
||||
* so the key the plugin ends up using is not the one its options carry. The
|
||||
* disk snapshot is keyed by that credential: reading it before the credential
|
||||
* is resolved looks up the wrong identity and throws away a usable catalog —
|
||||
* exactly when it is needed, on a cold start against an unreachable gateway.
|
||||
*/
|
||||
describe("warm snapshot is read under the credential actually in use", () => {
|
||||
it("serves the snapshot written for the host credential, gateway down", async () => {
|
||||
const { mkdtempSync } = await import("node:fs");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const dir = mkdtempSync(join(tmpdir(), "omniroute-warm-id-"));
|
||||
const prevDir = process.env.OPENCODE_DATA_DIR;
|
||||
process.env.OPENCODE_DATA_DIR = dir;
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () => {
|
||||
throw new Error("gateway unreachable");
|
||||
}) as unknown as typeof fetch;
|
||||
const warn = console.warn;
|
||||
const log = console.log;
|
||||
console.warn = () => {};
|
||||
console.log = () => {};
|
||||
try {
|
||||
const baseURL = "https://gw.example.com";
|
||||
const hostKey = "key-from-the-host-store";
|
||||
await writeDiskSnapshot(
|
||||
"warmid",
|
||||
{
|
||||
models: [{ id: "m-snap" }],
|
||||
combos: [],
|
||||
autoCombos: [],
|
||||
providers: [],
|
||||
fetchedAt: Date.now(),
|
||||
} as never,
|
||||
snapshotIdentityFingerprint(baseURL, hostKey, hostKey)
|
||||
);
|
||||
|
||||
const published = new Map<string, Record<string, unknown>>();
|
||||
const callbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
const registration = Promise.resolve({ dispose: async () => {} });
|
||||
const ctx = {
|
||||
options: { baseURL, providerId: "warmid", apiKey: "key-written-in-the-config" },
|
||||
catalog: {
|
||||
transform: (cb: (d: unknown) => Promise<void>) => {
|
||||
callbacks.push(cb);
|
||||
return registration;
|
||||
},
|
||||
reload: async () => {},
|
||||
},
|
||||
integration: {
|
||||
transform: () => registration,
|
||||
connection: {
|
||||
active: async () => ({ type: "credential", id: "c", label: "l" }),
|
||||
resolve: async () => ({ type: "key", key: hostKey }),
|
||||
},
|
||||
},
|
||||
};
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
const draft = {
|
||||
provider: { update: (_i: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
|
||||
model: {
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => {
|
||||
const e: Record<string, unknown> = { id: mid, providerID: pid };
|
||||
fn(e);
|
||||
published.set(`${pid}/${mid}`, e);
|
||||
},
|
||||
},
|
||||
};
|
||||
await callbacks[0]!(draft);
|
||||
assert.ok(
|
||||
[...published.keys()].some((k) => k.endsWith("/m-snap")),
|
||||
`the snapshot must survive the credential switch, published: ${JSON.stringify([...published.keys()])}`
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
console.warn = warn;
|
||||
console.log = log;
|
||||
if (prevDir === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = prevDir;
|
||||
}
|
||||
});
|
||||
});
|
||||
24
@omniroute/opencode-plugin-v2/tsconfig.json
Normal file
24
@omniroute/opencode-plugin-v2/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"lib": [
|
||||
"ES2022"
|
||||
],
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
}
|
||||
16
@omniroute/opencode-plugin-v2/tsup.config.ts
Normal file
16
@omniroute/opencode-plugin-v2/tsup.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: false,
|
||||
splitting: false,
|
||||
treeshake: false,
|
||||
target: "node22",
|
||||
outDir: "dist",
|
||||
minify: false,
|
||||
cjsInterop: false,
|
||||
noExternal: ["zod"],
|
||||
});
|
||||
6
@omniroute/opencode-plugin/package-lock.json
generated
6
@omniroute/opencode-plugin/package-lock.json
generated
@@ -1745,9 +1745,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/toml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
|
||||
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
|
||||
"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": {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts tests/log-level.test.ts tests/effort-tier-variants.test.ts tests/naming.test.ts tests/free-budget-magnitude.test.ts",
|
||||
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/telemetry.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts tests/log-level.test.ts tests/effort-tier-variants.test.ts tests/naming.test.ts tests/free-budget-magnitude.test.ts tests/models-fetcher.test.ts",
|
||||
"prepublishOnly": "npm run clean && npm run build && npm test"
|
||||
},
|
||||
"keywords": [
|
||||
@@ -68,6 +68,7 @@
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "^0.28.1"
|
||||
"esbuild": "^0.28.1",
|
||||
"toml": "^4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ import {
|
||||
AUTO_VARIANT_DESCRIPTIONS,
|
||||
type FreeModelFreeType,
|
||||
} from "./naming.js";
|
||||
import { applyOmniRouteInferenceTelemetry } from "./telemetry.js";
|
||||
|
||||
/**
|
||||
* Minimal leveled logger sink accepted by the default fetchers and the static
|
||||
@@ -1199,7 +1200,7 @@ export type OmniRouteModelsFetcher = (
|
||||
export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async (
|
||||
baseURL,
|
||||
apiKey,
|
||||
timeoutMs = 10_000
|
||||
timeoutMs = 30_000
|
||||
) => {
|
||||
if (!apiKey) throw new Error("@omniroute/opencode-plugin: apiKey required to fetch /v1/models");
|
||||
if (!baseURL) throw new Error("@omniroute/opencode-plugin: baseURL required to fetch /v1/models");
|
||||
@@ -1221,9 +1222,12 @@ export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async (
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
const err = new Error(
|
||||
`@omniroute/opencode-plugin: GET ${url} failed: ${res.status} ${res.statusText}`
|
||||
);
|
||||
) as Error & { statusCode: number; status: number };
|
||||
err.statusCode = res.status;
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
const body = (await res.json()) as unknown;
|
||||
const rawList: unknown[] = Array.isArray(body)
|
||||
@@ -3766,6 +3770,8 @@ export function createOmniRouteFetchInterceptor(config: {
|
||||
baseOrigin = baseUrl.origin;
|
||||
const basePath = ensureV1Suffix(baseUrl.pathname);
|
||||
inferencePaths.add(`${basePath}/chat/completions`);
|
||||
inferencePaths.add(`${basePath}/responses`);
|
||||
inferencePaths.add(`${basePath}/messages`);
|
||||
inferencePaths.add(`${basePath}/models`);
|
||||
} catch {
|
||||
// Credential-attached base URLs are not schema-validated. A malformed
|
||||
@@ -3809,7 +3815,7 @@ export function createOmniRouteFetchInterceptor(config: {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
return fetch(input, { ...init, headers });
|
||||
return applyOmniRouteInferenceTelemetry(await fetch(input, { ...init, headers }));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5398,7 +5404,7 @@ export function createOmniRouteConfigHook(
|
||||
// exact warn message so per-endpoint fallbacks are preserved.
|
||||
const doModels = async (): Promise<void> => {
|
||||
try {
|
||||
localRawModels = await fetcher(baseURL, apiKey, 10_000);
|
||||
localRawModels = await fetcher(baseURL, apiKey, 30_000);
|
||||
} catch (err) {
|
||||
logAt(
|
||||
"error",
|
||||
|
||||
249
@omniroute/opencode-plugin/src/telemetry.ts
Normal file
249
@omniroute/opencode-plugin/src/telemetry.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Map gateway-reported OmniRoute inference telemetry onto the JSON/SSE
|
||||
* payload OpenCode already consumes. Prefer headers / usage fields from the
|
||||
* gateway. Never invent tok/s from tokens / latency (that includes TTFT).
|
||||
*/
|
||||
export type OmniRouteInferenceTelemetry = {
|
||||
costUsd?: number;
|
||||
tokensIn?: number;
|
||||
tokensOut?: number;
|
||||
tokensPerSecond?: number;
|
||||
ttftMs?: number;
|
||||
latencyMs?: number;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
};
|
||||
|
||||
const HEADER = {
|
||||
cost: "x-omniroute-response-cost",
|
||||
tokensIn: "x-omniroute-tokens-in",
|
||||
tokensOut: "x-omniroute-tokens-out",
|
||||
tokensPerSecond: "x-omniroute-tokens-per-second",
|
||||
ttftMs: "x-omniroute-ttft-ms",
|
||||
latencyMs: "x-omniroute-latency-ms",
|
||||
model: "x-omniroute-model",
|
||||
provider: "x-omniroute-provider",
|
||||
} as const;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readFiniteNumber(raw: string | null): number | undefined {
|
||||
if (raw == null) return undefined;
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === "") return undefined;
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function readPositiveNumber(raw: string | null): number | undefined {
|
||||
const parsed = readFiniteNumber(raw);
|
||||
if (parsed === undefined || parsed <= 0) return undefined;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readNonNegativeInt(raw: string | null): number | undefined {
|
||||
const parsed = readFiniteNumber(raw);
|
||||
if (parsed === undefined || parsed < 0) return undefined;
|
||||
return Math.round(parsed);
|
||||
}
|
||||
|
||||
function readToken(raw: string | null): string | undefined {
|
||||
if (raw == null) return undefined;
|
||||
const trimmed = raw.trim();
|
||||
return trimmed === "" ? undefined : trimmed;
|
||||
}
|
||||
|
||||
export function parseOmniRouteInferenceTelemetry(headers: Headers): OmniRouteInferenceTelemetry {
|
||||
const out: OmniRouteInferenceTelemetry = {};
|
||||
const cost = readFiniteNumber(headers.get(HEADER.cost));
|
||||
if (cost !== undefined && cost >= 0) out.costUsd = cost;
|
||||
const tokensIn = readNonNegativeInt(headers.get(HEADER.tokensIn));
|
||||
if (tokensIn !== undefined) out.tokensIn = tokensIn;
|
||||
const tokensOut = readNonNegativeInt(headers.get(HEADER.tokensOut));
|
||||
if (tokensOut !== undefined) out.tokensOut = tokensOut;
|
||||
const tps = readPositiveNumber(headers.get(HEADER.tokensPerSecond));
|
||||
if (tps !== undefined) out.tokensPerSecond = tps;
|
||||
const ttft = readPositiveNumber(headers.get(HEADER.ttftMs));
|
||||
if (ttft !== undefined) out.ttftMs = ttft;
|
||||
const latency = readPositiveNumber(headers.get(HEADER.latencyMs));
|
||||
if (latency !== undefined) out.latencyMs = latency;
|
||||
const model = readToken(headers.get(HEADER.model));
|
||||
if (model) out.model = model;
|
||||
const provider = readToken(headers.get(HEADER.provider));
|
||||
if (provider) out.provider = provider;
|
||||
return out;
|
||||
}
|
||||
|
||||
function telemetryFromUsage(usage: Record<string, unknown>): OmniRouteInferenceTelemetry {
|
||||
const out: OmniRouteInferenceTelemetry = {};
|
||||
const tps = usage.tokens_per_second;
|
||||
if (typeof tps === "number" && Number.isFinite(tps) && tps > 0) {
|
||||
out.tokensPerSecond = tps;
|
||||
}
|
||||
const ttft = usage.ttft_ms;
|
||||
if (typeof ttft === "number" && Number.isFinite(ttft) && ttft > 0) {
|
||||
out.ttftMs = ttft;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mergeTelemetry(
|
||||
base: OmniRouteInferenceTelemetry,
|
||||
extra: OmniRouteInferenceTelemetry,
|
||||
): OmniRouteInferenceTelemetry {
|
||||
return {
|
||||
...base,
|
||||
...Object.fromEntries(Object.entries(extra).filter(([, value]) => value !== undefined)),
|
||||
};
|
||||
}
|
||||
|
||||
function isInferencePayload(payload: Record<string, unknown>): boolean {
|
||||
return (
|
||||
isRecord(payload.usage) ||
|
||||
Array.isArray(payload.choices) ||
|
||||
payload.object === "chat.completion" ||
|
||||
payload.object === "response" ||
|
||||
payload.type === "message" ||
|
||||
Array.isArray(payload.output)
|
||||
);
|
||||
}
|
||||
|
||||
function attachToUsage(
|
||||
usage: Record<string, unknown>,
|
||||
telemetry: OmniRouteInferenceTelemetry,
|
||||
): Record<string, unknown> {
|
||||
const next = { ...usage };
|
||||
if (
|
||||
telemetry.tokensPerSecond !== undefined &&
|
||||
(typeof next.tokens_per_second !== "number" || next.tokens_per_second <= 0)
|
||||
) {
|
||||
next.tokens_per_second = telemetry.tokensPerSecond;
|
||||
}
|
||||
if (telemetry.ttftMs !== undefined && (typeof next.ttft_ms !== "number" || next.ttft_ms <= 0)) {
|
||||
next.ttft_ms = telemetry.ttftMs;
|
||||
}
|
||||
if (telemetry.costUsd !== undefined && typeof next.cost !== "number") {
|
||||
next.cost = telemetry.costUsd;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function attachOmniRouteTelemetryToPayload(
|
||||
payload: unknown,
|
||||
telemetry: OmniRouteInferenceTelemetry,
|
||||
): unknown {
|
||||
if (!isRecord(payload) || !isInferencePayload(payload)) {
|
||||
return payload;
|
||||
}
|
||||
const next: Record<string, unknown> = { ...payload };
|
||||
if (telemetry.model) {
|
||||
next.model = telemetry.model;
|
||||
}
|
||||
if (isRecord(next.usage)) {
|
||||
next.usage = attachToUsage(next.usage, mergeTelemetry(telemetry, telemetryFromUsage(next.usage)));
|
||||
}
|
||||
if (isRecord(next.response) && isRecord(next.response.usage)) {
|
||||
next.response = {
|
||||
...next.response,
|
||||
usage: attachToUsage(
|
||||
next.response.usage,
|
||||
mergeTelemetry(telemetry, telemetryFromUsage(next.response.usage)),
|
||||
),
|
||||
};
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function attachOmniRouteTelemetryToSseLine(
|
||||
line: string,
|
||||
telemetry: OmniRouteInferenceTelemetry,
|
||||
): string {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("data:")) {
|
||||
return line;
|
||||
}
|
||||
const jsonText = trimmed.slice("data:".length).trim();
|
||||
if (!jsonText.startsWith("{")) {
|
||||
return line;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText) as unknown;
|
||||
const updated = attachOmniRouteTelemetryToPayload(parsed, telemetry);
|
||||
if (updated === parsed) {
|
||||
return line;
|
||||
}
|
||||
const prefix = line.slice(0, line.indexOf(jsonText));
|
||||
const suffix = line.endsWith("\r") ? "\r" : "";
|
||||
return `${prefix}${JSON.stringify(updated)}${suffix}`;
|
||||
} catch {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyOmniRouteInferenceTelemetry(response: Response): Promise<Response> {
|
||||
const telemetry = parseOmniRouteInferenceTelemetry(response.headers);
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("text/event-stream") && response.body) {
|
||||
return new Response(mapSseBody(response.body, telemetry), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
if (!contentType.includes("json")) {
|
||||
return response;
|
||||
}
|
||||
const text = await response.text();
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return new Response(text, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
const next = attachOmniRouteTelemetryToPayload(parsed, telemetry);
|
||||
if (next === parsed) {
|
||||
return new Response(text, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify(next), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
|
||||
function mapSseBody(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
telemetry: OmniRouteInferenceTelemetry,
|
||||
): ReadableStream<Uint8Array> {
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
let pending = "";
|
||||
let live = { ...telemetry };
|
||||
return body.pipeThrough(
|
||||
new TransformStream<Uint8Array, Uint8Array>({
|
||||
transform(chunk, controller) {
|
||||
pending += decoder.decode(chunk, { stream: true });
|
||||
const lines = pending.split("\n");
|
||||
pending = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
controller.enqueue(encoder.encode(`${attachOmniRouteTelemetryToSseLine(line, live)}\n`));
|
||||
}
|
||||
},
|
||||
flush(controller) {
|
||||
if (pending.length > 0) {
|
||||
controller.enqueue(encoder.encode(attachOmniRouteTelemetryToSseLine(pending, live)));
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
45
@omniroute/opencode-plugin/tests/models-fetcher.test.ts
Normal file
45
@omniroute/opencode-plugin/tests/models-fetcher.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { defaultOmniRouteModelsFetcher } from "../src/index.js";
|
||||
|
||||
test("defaultOmniRouteModelsFetcher attaches statusCode on HTTP 401", async () => {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ error: "authentication expired" }), {
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
})) as typeof fetch;
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => defaultOmniRouteModelsFetcher("https://gateway.example/v1", "test-key"),
|
||||
(err: unknown) => {
|
||||
assert.ok(err instanceof Error);
|
||||
const rec = err as Error & { statusCode?: number; status?: number };
|
||||
assert.equal(rec.statusCode, 401);
|
||||
assert.equal(rec.status, 401);
|
||||
assert.match(rec.message, /401/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("defaultOmniRouteModelsFetcher default timeout is 30s", async () => {
|
||||
const original = globalThis.fetch;
|
||||
let signal: AbortSignal | undefined;
|
||||
globalThis.fetch = (async (_input, init) => {
|
||||
signal = init?.signal ?? undefined;
|
||||
return new Response(JSON.stringify({ object: "list", data: [] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await defaultOmniRouteModelsFetcher("https://gateway.example/v1", "test-key");
|
||||
assert.equal(signal instanceof AbortSignal, true);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
103
@omniroute/opencode-plugin/tests/telemetry.test.ts
Normal file
103
@omniroute/opencode-plugin/tests/telemetry.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
applyOmniRouteInferenceTelemetry,
|
||||
attachOmniRouteTelemetryToPayload,
|
||||
attachOmniRouteTelemetryToSseLine,
|
||||
parseOmniRouteInferenceTelemetry,
|
||||
} from "../src/telemetry.js";
|
||||
|
||||
test("parseOmniRouteInferenceTelemetry: copies cost, tokens, tok/s, winning model", () => {
|
||||
const headers = new Headers({
|
||||
"X-OmniRoute-Response-Cost": "0.0123",
|
||||
"X-OmniRoute-Tokens-In": "10",
|
||||
"X-OmniRoute-Tokens-Out": "200",
|
||||
"X-OmniRoute-Tokens-Per-Second": "100.5",
|
||||
"X-OmniRoute-Ttft-Ms": "300",
|
||||
"X-OmniRoute-Latency-Ms": "2300",
|
||||
"X-OmniRoute-Model": "winner-model",
|
||||
"X-OmniRoute-Provider": "openai",
|
||||
});
|
||||
const got = parseOmniRouteInferenceTelemetry(headers);
|
||||
assert.equal(got.costUsd, 0.0123);
|
||||
assert.equal(got.tokensIn, 10);
|
||||
assert.equal(got.tokensOut, 200);
|
||||
assert.equal(got.tokensPerSecond, 100.5);
|
||||
assert.equal(got.ttftMs, 300);
|
||||
assert.equal(got.model, "winner-model");
|
||||
assert.equal(got.provider, "openai");
|
||||
});
|
||||
|
||||
test("parseOmniRouteInferenceTelemetry: omits tok/s when header missing (do not invent from latency)", () => {
|
||||
const headers = new Headers({
|
||||
"X-OmniRoute-Tokens-Out": "200",
|
||||
"X-OmniRoute-Latency-Ms": "2000",
|
||||
});
|
||||
const got = parseOmniRouteInferenceTelemetry(headers);
|
||||
assert.equal(got.tokensPerSecond, undefined);
|
||||
assert.equal(got.tokensOut, 200);
|
||||
const payload = attachOmniRouteTelemetryToPayload(
|
||||
{ object: "chat.completion", usage: { prompt_tokens: 10, completion_tokens: 200 } },
|
||||
got,
|
||||
) as { usage: { tokens_per_second?: number } };
|
||||
assert.equal(payload.usage.tokens_per_second, undefined);
|
||||
});
|
||||
|
||||
test("attachOmniRouteTelemetryToPayload: writes usage.tokens_per_second and winning model", () => {
|
||||
const got = attachOmniRouteTelemetryToPayload(
|
||||
{
|
||||
object: "chat.completion",
|
||||
model: "combo/auto",
|
||||
usage: { prompt_tokens: 10, completion_tokens: 200 },
|
||||
},
|
||||
{ tokensPerSecond: 80, ttftMs: 250, costUsd: 0, model: "gpt-winner" },
|
||||
) as {
|
||||
model: string;
|
||||
usage: { tokens_per_second: number; ttft_ms: number; cost: number };
|
||||
};
|
||||
assert.equal(got.model, "gpt-winner");
|
||||
assert.equal(got.usage.tokens_per_second, 80);
|
||||
assert.equal(got.usage.ttft_ms, 250);
|
||||
assert.equal(got.usage.cost, 0);
|
||||
});
|
||||
|
||||
test("attachOmniRouteTelemetryToPayload: does not mutate /v1/models catalog JSON", () => {
|
||||
const catalog = { object: "list", data: [{ id: "m1" }] };
|
||||
const got = attachOmniRouteTelemetryToPayload(catalog, {
|
||||
tokensPerSecond: 99,
|
||||
model: "should-not-apply",
|
||||
});
|
||||
assert.deepEqual(got, catalog);
|
||||
});
|
||||
|
||||
test("attachOmniRouteTelemetryToSseLine: patches terminal usage data line", () => {
|
||||
const line =
|
||||
'data: {"object":"chat.completion.chunk","usage":{"completion_tokens":200}}';
|
||||
const got = attachOmniRouteTelemetryToSseLine(line, { tokensPerSecond: 50 });
|
||||
assert.match(got, /"tokens_per_second":50/);
|
||||
assert.match(got, /^data: /);
|
||||
});
|
||||
|
||||
test("applyOmniRouteInferenceTelemetry: JSON response gets header tok/s", async () => {
|
||||
const response = new Response(
|
||||
JSON.stringify({
|
||||
object: "chat.completion",
|
||||
model: "combo/auto",
|
||||
usage: { prompt_tokens: 1, completion_tokens: 20 },
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-OmniRoute-Tokens-Per-Second": "40",
|
||||
"X-OmniRoute-Model": "winner",
|
||||
},
|
||||
},
|
||||
);
|
||||
const next = await applyOmniRouteInferenceTelemetry(response);
|
||||
const body = JSON.parse(await next.text()) as {
|
||||
model: string;
|
||||
usage: { tokens_per_second: number };
|
||||
};
|
||||
assert.equal(body.model, "winner");
|
||||
assert.equal(body.usage.tokens_per_second, 40);
|
||||
});
|
||||
13
Dockerfile
13
Dockerfile
@@ -331,7 +331,18 @@ RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,targe
|
||||
&& git config --system url."https://github.com/".insteadOf "ssh://git@github.com/"
|
||||
|
||||
# Install CLI tools globally. Separate layer from apt for better cache reuse.
|
||||
# Pinned to exact versions per Diego's diagnosis in #12576 — floating
|
||||
# `@latest` causes two CI failures:
|
||||
# 1. `openclaw` ships a breaking major ~weekly; overnight builds silently
|
||||
# advance to a version that no longer matches the tested combo stack.
|
||||
# 2. `codex` / `claude-code` dev pre-releases (`@next`, dist-tags) mutate
|
||||
# API surface without notice; reproducible builds need a SHA-pinned dev
|
||||
# build, not the floating `@latest`.
|
||||
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,target=/root/.npm \
|
||||
npm install -g --no-audit --no-fund @openai/codex @anthropic-ai/claude-code droid openclaw@latest
|
||||
npm install -g --no-audit --no-fund \
|
||||
@openai/codex@0.153.2 \
|
||||
@anthropic-ai/claude-code@2.1.260 \
|
||||
droid@0.212.0 \
|
||||
openclaw@2026.9.1
|
||||
|
||||
USER node
|
||||
|
||||
14
README.md
14
README.md
@@ -7,19 +7,19 @@
|
||||
|
||||
# 🚀 OmniRoute — The Free AI Gateway
|
||||
|
||||
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 356 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 356 AI providers · 150+ free tiers · ~1.48B free tokens/mo · 19 routing strategies · $0 to start."/>
|
||||
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 356 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 356 AI providers · 150+ free tiers · ~1.47B free tokens/mo · 19 routing strategies · $0 to start."/>
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
## 💰 ~1.48B Free Tokens / Month
|
||||
## 💰 ~1.47B Free Tokens / Month
|
||||
|
||||
</div>
|
||||
|
||||
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **437 free-tier entries across 37 recurring pool keys** and computes the token headline from the **20 pools with a published positive monthly budget**, deduplicated by shared pool. The result stays visible on the dashboard (`/dashboard/free-tiers`).
|
||||
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **444 free-tier entries across 34 recurring pool keys** and computes the token headline from the **16 pools with a published positive monthly budget plus five per-model Groq caps**, deduplicated by shared pool. Quotas that only open after a regional identity check (today: ModelScope) are shown apart, +~6M behind regional identity verification, and never summed into the headline. The result stays visible on the dashboard (`/dashboard/free-tiers`).
|
||||
|
||||
<img src="./docs/diagrams/free-tier-budget.svg" width="100%" alt="OmniRoute free-tier budget card: ~1.48B free tokens per month steady, up to ~2.10B in the first month with signup credits, from 37 documented recurring pool keys covering 437 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 20 recurring pools with a published positive monthly token budget; 13 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, LLM7 150M, Nara 150M, Gemini 60M and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers."/>
|
||||
<img src="./docs/diagrams/free-tier-budget.svg" width="100%" alt="OmniRoute free-tier budget card: ~1.47B free tokens per month steady, up to ~2.10B in the first month with signup credits, from 34 documented recurring pool keys covering 444 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 16 recurring pools with a published positive monthly token budget plus five per-model Groq caps; 13 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, Nara 210M, LLM7 150M, Groq 30M (five per-model caps) and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers."/>
|
||||
|
||||
> Animated summary of the live `/dashboard/free-tiers` page. Full methodology (pool dedupe, credit tiers, provider terms): **[docs/reference/FREE_TIERS.md](docs/reference/FREE_TIERS.md)**.
|
||||
>
|
||||
@@ -518,7 +518,7 @@ Pix copia-e-cola:
|
||||
|
||||
## 📡 OmniRoute Radar
|
||||
|
||||
The main free-tier headline remains **~1.48B tokens/month** from the documented,
|
||||
The main free-tier headline remains **~1.47B tokens/month** from the documented,
|
||||
pool-deduplicated catalog above. Temporary provider signup credits can separately lift the first
|
||||
month to **~2.10B**. Radar is an optional, signed catalog overlay for people who want fresher
|
||||
free-model availability between OmniRoute releases; the community catalog and every existing free
|
||||
@@ -648,7 +648,7 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
||||
|
||||
</div>
|
||||
|
||||
> **352 registered providers** across the canonical chat, media, search, local, cloud-agent and system collections, including **152 carrying `hasFree: true` discovery metadata**. The chat model registry covers **229 providers / 2,554 distinct provider-model pairs / 1,283 raw model IDs**; the separate free-budget catalog has **437 per-model rows**, **37 recurring pools** and **52 recurring/keyless free-forever providers**. These are different denominators by design; definitions and pool-deduped calculations live in the [Provider Reference](docs/reference/PROVIDER_REFERENCE.md) and [Free Tiers](docs/reference/FREE_TIERS.md).
|
||||
> **352 registered providers** across the canonical chat, media, search, local, cloud-agent and system collections, including **152 carrying `hasFree: true` discovery metadata**. The chat model registry covers **229 providers / 2,554 distinct provider-model pairs / 1,283 raw model IDs**; the separate free-budget catalog has **444 per-model rows**, **34 recurring pools** and **52 recurring/keyless free-forever providers**. These are different denominators by design; definitions and pool-deduped calculations live in the [Provider Reference](docs/reference/PROVIDER_REFERENCE.md) and [Free Tiers](docs/reference/FREE_TIERS.md).
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -1307,7 +1307,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
|
||||
<tr><td nowrap><b><a href="docs/architecture/RESILIENCE_GUIDE.md">Resilience Guide</a></b></td><td>Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/routing/AUTO-COMBO.md">Auto-Combo Engine</a></b></td><td>16-factor scoring, mode packs, self-healing</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/ops/PROXY_GUIDE.md">Proxy Guide</a></b></td><td>3-level proxy system, 1proxy marketplace, registry CRUD</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/reference/FREE_TIERS.md">Free Tiers</a></b></td><td>Consolidated directory: 37 documented recurring pools / 437 cataloged free-tier entries</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/reference/FREE_TIERS.md">Free Tiers</a></b></td><td>Consolidated directory: 34 documented recurring pools / 444 cataloged free-tier entries</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/guides/FEATURES.md">Features Gallery</a></b></td><td>Visual dashboard tour with screenshots</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/architecture/CODEBASE_DOCUMENTATION.md">Codebase Documentation</a></b></td><td>Beginner-friendly codebase walkthrough</td></tr>
|
||||
</table>
|
||||
|
||||
@@ -224,6 +224,14 @@ features (MITM, Zed import, Cloud Sync, embedded service supervisor) — ends
|
||||
up in `.next/server/*.js` minified chunks. Heuristic supply-chain scanners
|
||||
frequently pattern-match those chunks against malware signatures.
|
||||
|
||||
The scanner configuration we use lives at [`socket.yml`](socket.yml) in the
|
||||
repo root (Socket.dev GitHub App format v2 — see
|
||||
<https://docs.socket.dev/docs/socket-yml>). It explicitly excludes
|
||||
non-shipped directories (`tests/`, `_tasks/`, `_references/`, `_ideia/`,
|
||||
`_mono_repo/`, `docs/`, etc.) so the scanner only reports on code paths that
|
||||
actually reach published users — the scan itself is driven by the Socket
|
||||
GitHub App reading that file, not by a workflow in this repository.
|
||||
|
||||
For each finding category we maintain a per-finding maintainer attestation:
|
||||
|
||||
- **[`docs/security/SOCKET_DEV_FINDINGS.md`](docs/security/SOCKET_DEV_FINDINGS.md)** —
|
||||
|
||||
@@ -16,6 +16,29 @@ function ensureBackup(configPath) {
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
function mergeClaudeSettings(existingContent, generatedContent) {
|
||||
const generated = JSON.parse(generatedContent);
|
||||
let current = {};
|
||||
if (existingContent && existingContent.trim()) {
|
||||
current = JSON.parse(existingContent);
|
||||
if (!current || typeof current !== "object" || Array.isArray(current)) current = {};
|
||||
}
|
||||
return JSON.stringify(
|
||||
{
|
||||
...current,
|
||||
...generated,
|
||||
env: {
|
||||
...(current.env && typeof current.env === "object" && !Array.isArray(current.env)
|
||||
? current.env
|
||||
: {}),
|
||||
...(generated.env || {}),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
async function runConfigListCommand(opts = {}) {
|
||||
const { detectAllTools } = await import("../../../src/lib/cli-helper/tool-detector.ts");
|
||||
const tools = await detectAllTools();
|
||||
@@ -120,7 +143,12 @@ async function runConfigSetCommand(toolId, opts = {}) {
|
||||
const backupPath = ensureBackup(result.configPath);
|
||||
if (backupPath) printInfo(`Backup saved to: ${backupPath}`);
|
||||
|
||||
fs.writeFileSync(result.configPath, result.content, "utf-8");
|
||||
let content = result.content;
|
||||
if (toolId === "claude" && fs.existsSync(result.configPath)) {
|
||||
content = mergeClaudeSettings(fs.readFileSync(result.configPath, "utf-8"), result.content);
|
||||
}
|
||||
|
||||
fs.writeFileSync(result.configPath, content, "utf-8");
|
||||
printSuccess(`Config written to ${result.configPath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export function registerTunnel(program) {
|
||||
});
|
||||
|
||||
tunnel
|
||||
.command("create [type]")
|
||||
.command("create")
|
||||
.description(t("tunnel.createDescription"))
|
||||
.addArgument(
|
||||
new Argument("[type]", "Tunnel type").choices(VALID_TUNNEL_TYPES).default("cloudflare")
|
||||
|
||||
1
changelog.d/features/12473-github-live-catalog-filter.md
Normal file
1
changelog.d/features/12473-github-live-catalog-filter.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(providers):** skip GitHub combo members missing from the live synced catalog, and drop Copilot models that are policy-disabled or hidden from the model picker ([#12473](https://github.com/diegosouzapw/OmniRoute/pull/12473)) — thanks @RaviTharuma
|
||||
1
changelog.d/features/12616-tokens-per-second.md
Normal file
1
changelog.d/features/12616-tokens-per-second.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(api):** Emit gateway-measured `tokens_per_second` (TTFT excluded) on streaming usage and `X-OmniRoute-Tokens-Per-Second` when first-token latency is known ([#12616](https://github.com/diegosouzapw/OmniRoute/issues/12616))
|
||||
@@ -0,0 +1 @@
|
||||
- **feat(opencode-plugin): map gateway cost/usage/tok/s onto OpenCode inference payloads** — the official plugin copies `X-OmniRoute-Response-Cost`, token counts, `X-OmniRoute-Tokens-Per-Second` / `usage.tokens_per_second`, TTFT, and the winning `X-OmniRoute-Model` onto the JSON/SSE body OpenCode already consumes. Missing tok/s is left unset (never `tokens / latency`). (#12636)
|
||||
1
changelog.d/features/12789-quota-weighted-routing.md
Normal file
1
changelog.d/features/12789-quota-weighted-routing.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(combo):** add combo strategy `quota-weighted`: skip exhausted accounts, then weighted-draw by leftover / in-flight load; existing conversations stay pinned until the account is empty ([#12789](https://github.com/diegosouzapw/OmniRoute/pull/12789))
|
||||
2
changelog.d/features/12866-live-catalog-authority.md
Normal file
2
changelog.d/features/12866-live-catalog-authority.md
Normal file
@@ -0,0 +1,2 @@
|
||||
- **feat(models):** Account live listings become the chat catalog source for Claude, Codex, Copilot, and AGY; public metadata only fills prices on IDs those accounts already list. ([#12866](https://github.com/diegosouzapw/OmniRoute/pull/12866))
|
||||
- **fix(models):** Union `agy` and `antigravity` live catalogs so `agy/gemini-3.8-flash-high` is not rejected after the prefix folds to `antigravity`. ([#12866](https://github.com/diegosouzapw/OmniRoute/pull/12866))
|
||||
1
changelog.d/features/12870-opencode-plugin-v2.md
Normal file
1
changelog.d/features/12870-opencode-plugin-v2.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(opencode):** Add `@omniroute/opencode-plugin-v2`, an opencode v2 plugin that gives a v2 install a route to an OmniRoute gateway: it publishes models, combos and auto-combos into the host catalog, refreshes them lazily behind a 300s TTL, and keeps serving the last known catalog from an on-disk snapshot when the gateway is unreachable. The v1 plugin (`@omniroute/opencode-plugin`) is untouched and keeps working as before. The plugin adapts to the host instead of assuming one shape: it reads the shape the host seeds into the catalog draft and publishes the `api` block, the pre-`api` top-level fields, or both. The gateway key comes from the host credential store when one is connected, so no secret needs to sit in a config file; a source the gateway refuses is named in a warning instead of silently degrading the catalog to raw model ids; and tool schemas bound for a Gemini model are stripped of the JSON-Schema keywords Gemini rejects, so tool calling survives that route ([#12870](https://github.com/diegosouzapw/OmniRoute/pull/12870)) — thanks @maxmad64bis
|
||||
1
changelog.d/fixes/0000-claude-extra-usage-preflight.md
Normal file
1
changelog.d/fixes/0000-claude-extra-usage-preflight.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(claude):** `blockExtraUsage: false` no longer lets 5h quota preflight skip the account; extra usage is billed after the session bar is gone, so the request must reach Anthropic
|
||||
1
changelog.d/fixes/0000-tunnel-create-crash.md
Normal file
1
changelog.d/fixes/0000-tunnel-create-crash.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(cli):** `omniroute tunnel create` no longer crashes with `Cannot read properties of undefined (reading optsWithGlobals)` — removed the duplicate positional argument that caused Commander.js to misalign the action callback parameters ([#12295](https://github.com/diegosouzapw/OmniRoute/issues/12295))
|
||||
1
changelog.d/fixes/12326-combo-delete-lkgp-cleanup.md
Normal file
1
changelog.d/fixes/12326-combo-delete-lkgp-cleanup.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(combos):** deleting a combo now clears its persisted LKGP pins instead of leaving unreachable `key_value` rows behind ([#12326](https://github.com/diegosouzapw/OmniRoute/issues/12326))
|
||||
1
changelog.d/fixes/12368-tunnel-create-crash.md
Normal file
1
changelog.d/fixes/12368-tunnel-create-crash.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(cli):** `omniroute tunnel create` no longer crashes with `Cannot read properties of undefined (reading optsWithGlobals)` — removed the duplicate positional argument that caused Commander.js to misalign the action callback parameters ([#12295](https://github.com/diegosouzapw/OmniRoute/issues/12295), [#12368](https://github.com/diegosouzapw/OmniRoute/pull/12368))
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(i18n):** wrap `ccOnboardingKeyPlaceholder` in ICU single quotes across all 43 locales so angle brackets render literally instead of being parsed as rich-text tags, which crashed the Claude Code onboarding block with `INVALID_MESSAGE: INVALID_TAG` ([#12302](https://github.com/diegosouzapw/OmniRoute/issues/12302))
|
||||
1
changelog.d/fixes/12371-kimi-k3-effort-tiers.md
Normal file
1
changelog.d/fixes/12371-kimi-k3-effort-tiers.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(models):** publish `effort_tiers` on Kimi K3's synced base-model entries (`kmca/k3`, `kmca/k3-256k`) so catalog-only clients (OpenCode, plain SDK pickers) can see and select the reasoning tiers (`low`/`high`/`max`) the synced metadata already carried — the `isSkippedEffortProvider` gate no longer suppresses tier visibility on those base entries, while synthetic `<id>-<tier>` variant generation stays prevented and Codex/GLM base models remain excluded unchanged ([#12299](https://github.com/diegosouzapw/OmniRoute/issues/12299))
|
||||
1
changelog.d/fixes/12417-cli-version-env.md
Normal file
1
changelog.d/fixes/12417-cli-version-env.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(providers):** add `CLAUDE_CODE_CLIENT_VERSION` and `GITHUB_COPILOT_CLI_VERSION` env overrides so Anthropic/Copilot client-version gates can be unblocked without a rebuild ([#12417](https://github.com/diegosouzapw/OmniRoute/issues/12417))
|
||||
1
changelog.d/fixes/12470-last-ping-at-backfill.md
Normal file
1
changelog.d/fixes/12470-last-ping-at-backfill.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(db):** back-fill `last_ping_at` and `last_pinged_reset_key` on `provider_connections` during schema reconciliation so divergent lineages that skipped `123_quota_auto_ping` still accept quota auto-ping writes ([#12470](https://github.com/diegosouzapw/OmniRoute/pull/12470) — thanks @KooshaPari)
|
||||
@@ -0,0 +1 @@
|
||||
- OpenCode plugin `/v1/models` catalog fetch now waits 30s by default and attaches HTTP `statusCode` on 401/5xx so host fallback plugins can hop instead of seeing an untyped AbortError/UnknownError.
|
||||
1
changelog.d/fixes/12605-openapi-tiers-public-creds.md
Normal file
1
changelog.d/fixes/12605-openapi-tiers-public-creds.md
Normal file
@@ -0,0 +1 @@
|
||||
- **CI:** the OpenAPI security-tier gate now mirrors `isAlwaysProtectedPath()` in full — it also reads `ALWAYS_PROTECTED_API_PATTERNS`, so the pattern-gated credential routes (`/api/providers/{id}/{claude,codex}-auth/{export,apply-local}`, GHSA-5926-2w35-7h4q) no longer report as unannotated. (#12605)
|
||||
1
changelog.d/fixes/12627-catalog-inflight-timeout.md
Normal file
1
changelog.d/fixes/12627-catalog-inflight-timeout.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(api):** GET /v1/models no longer waits forever on a hung coalesced catalog rebuild; cold-path waits are bounded (`CATALOG_BUILD_TIMEOUT_MS`, default 8s) and a last-good 200 is served when the rebuild times out ([#12627](https://github.com/diegosouzapw/OmniRoute/issues/12627)).
|
||||
1
changelog.d/fixes/12689-cursor-toolcalls-guard.md
Normal file
1
changelog.d/fixes/12689-cursor-toolcalls-guard.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(cursor):** a non-array `tool_calls` on an assistant message no longer crashes the cursor request translator with a `TypeError`; both loops now require an array ([#12689](https://github.com/diegosouzapw/OmniRoute/issues/12689))
|
||||
1
changelog.d/fixes/12696-server-owned-tool-loop.md
Normal file
1
changelog.d/fixes/12696-server-owned-tool-loop.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(chat):** Chat Completions no longer return empty `content` after a server-owned memory or skills tool; the first provider send and account/model recovery now share one pipeline so a follow-up round-trip can fill the reply ([#12696](https://github.com/diegosouzapw/OmniRoute/issues/12696)) — thanks @HouMinXi
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user