chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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.
|
||||
@@ -0,0 +1,296 @@
|
||||
# @omniroute/opencode-plugin
|
||||
|
||||
> **Recommended way to use OmniRoute with OpenCode.** Pulls a live model catalog from `/v1/models` (including `-low`/`-medium`/`-high`/`-thinking` variants as first-class IDs), aggregates combos via `/api/combos` using a least-common-denominator capability/limit join, sanitizes Gemini tool schemas in flight, and supports multiple side-by-side OmniRoute instances out of the box.
|
||||
|
||||
## Why this and not `@omniroute/opencode-provider`?
|
||||
|
||||
`@omniroute/opencode-provider` is the legacy config-generator package — it writes a frozen `provider.omniroute` block into `opencode.json` with a **hardcoded list of 8 models** ([`OMNIROUTE_DEFAULT_OPENCODE_MODELS`](https://github.com/diegosouzapw/OmniRoute/blob/main/%40omniroute/opencode-provider/src/index.ts#L48-L56)). It works on the CLI but in the **OpenCode Desktop / Web** builds (Tauri / Electron) the runtime re-runs the model picker and the static block surfaces only a few of those — and they drift behind the live OmniRoute catalog.
|
||||
|
||||
This plugin solves that by:
|
||||
|
||||
- Fetching `/v1/models` and `/api/combos` **at OpenCode startup, in Node.js** — no CORS, no WebView restrictions
|
||||
- Emitting the provider block **dynamically** in the plugin's `config`/`provider` hook — so `opencode.json` only needs the plugin entry, not a static `provider.omniroute`
|
||||
- Re-fetching on a configurable TTL (default 5 min), so new models / combo changes in the OmniRoute UI appear without restarting OpenCode
|
||||
- Computing `limit.context` for combos as `min(member.context_length)` from the live catalog (no more `null` values that cause 4K-token truncation)
|
||||
- **Auto-pickup of `interleaved` capability** for thinking models (merged via PR #3138)
|
||||
|
||||
**If you only have the legacy `opencode-provider` block in your `opencode.json`, replace it with a single plugin entry.** No other config changes required — the same `auth.json` API key works.
|
||||
|
||||
## Install
|
||||
|
||||
The plugin ships **pre-built inside the `omniroute` npm package** since v3.8.23.
|
||||
If you have OmniRoute installed, the plugin is already on disk:
|
||||
|
||||
```sh
|
||||
# 1. One command — copy the plugin into OpenCode and update opencode.json
|
||||
omniroute setup opencode --auth
|
||||
|
||||
# 2. Follow the interactive prompt to enter your OmniRoute API key
|
||||
# 3. Restart OpenCode — /models lists the full live catalog
|
||||
```
|
||||
|
||||
The `--auth` flag runs `opencode auth login --provider omniroute` automatically.
|
||||
Use `--base-url` to point at a non-default OmniRoute address:
|
||||
|
||||
```sh
|
||||
omniroute setup opencode --base-url https://or.example.com --auth
|
||||
```
|
||||
|
||||
### What it does
|
||||
|
||||
1. Locates the bundled plugin inside the omniroute installation
|
||||
2. Copies `dist/` + `package.json` to `~/.config/opencode/plugins/omniroute/`
|
||||
3. Writes/updates `opencode.json` with the plugin entry (idempotent, replaces legacy entries)
|
||||
4. (With `--auth`) runs `opencode auth login` so the API key is stored
|
||||
|
||||
Re-run any time to update the plugin or change the base URL. Older entries for
|
||||
`@omniroute/opencode-provider` or the legacy `opencode-omniroute-auth` package are
|
||||
automatically cleaned up.
|
||||
|
||||
### Manual install (without omniroute CLI)
|
||||
|
||||
If you cannot run `omniroute setup opencode` (local dev, CI, air-gapped), reference
|
||||
the built artifact directly:
|
||||
|
||||
```sh
|
||||
cd @omniroute/opencode-plugin && npm run build && npm pack
|
||||
# then extract into ~/.config/opencode/plugins/omniroute-opencode-plugin/
|
||||
```
|
||||
|
||||
And add the entry to `opencode.json` manually (see Quick Start below).
|
||||
|
||||
Peer dep: `@opencode-ai/plugin` (managed by your OpenCode install).
|
||||
|
||||
## Quick start (single instance, manual)
|
||||
|
||||
```jsonc
|
||||
// opencode.json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"plugin": [
|
||||
[
|
||||
"./plugins/omniroute-opencode-plugin/dist/index.js",
|
||||
{
|
||||
"providerId": "omniroute",
|
||||
"baseURL": "https://or.example.com",
|
||||
},
|
||||
],
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
```sh
|
||||
opencode auth login --provider omniroute
|
||||
# prompts for the OmniRoute API key, writes to ~/.local/share/opencode/auth.json
|
||||
```
|
||||
|
||||
> ⚠ Use the `--provider` flag explicitly. `opencode auth login omniroute` is parsed as a positional `url` argument by current OC releases (≤1.15.5) and fails with `fetch() URL is invalid`. Tracked upstream.
|
||||
|
||||
Restart OpenCode. `/models` lists the full live catalog. Variants (`-low`, `-medium`, `-high`, `-thinking`) and combos appear as first-class IDs — OmniRoute is the source of truth, no client-side synthesis.
|
||||
|
||||
## Multi-instance (prod + preprod side-by-side)
|
||||
|
||||
> ⚠ OC ≤1.15.5 dedupes plugin loads by absolute module path. Two `plugin:` entries pointing at the same `dist/index.js` collapse into one (last-listed options win). Workaround: install the plugin twice into separate directories so each entry resolves to a distinct module file. v0.2.x will introduce an `instances: [...]` shape that registers N providers from a single load.
|
||||
|
||||
### Dual-install workaround (works today on OC ≤1.15.5)
|
||||
|
||||
Pack the plugin once, extract it twice into named directories, then point each `plugin:` entry at its own copy:
|
||||
|
||||
```sh
|
||||
# 1. Build + pack the plugin (run from the plugin worktree)
|
||||
cd /path/to/OmniRoute/@omniroute/opencode-plugin
|
||||
npm run build
|
||||
npm pack
|
||||
# produces omniroute-opencode-plugin-0.1.0.tgz
|
||||
|
||||
# 2. Extract one copy per OmniRoute endpoint
|
||||
mkdir -p ~/.config/opencode/plugins/omniroute-opencode-plugin-prod
|
||||
mkdir -p ~/.config/opencode/plugins/omniroute-opencode-plugin-preprod
|
||||
tar -xzf omniroute-opencode-plugin-0.1.0.tgz -C ~/.config/opencode/plugins/omniroute-opencode-plugin-prod --strip-components=1
|
||||
tar -xzf omniroute-opencode-plugin-0.1.0.tgz -C ~/.config/opencode/plugins/omniroute-opencode-plugin-preprod --strip-components=1
|
||||
```
|
||||
|
||||
Then in `~/.config/opencode/opencode.json` reference each directory by absolute path:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"plugin": [
|
||||
[
|
||||
"./plugins/omniroute-opencode-plugin-prod/dist/index.js",
|
||||
{
|
||||
"providerId": "omniroute",
|
||||
"displayName": "OmniRoute",
|
||||
"baseURL": "https://or.example.com",
|
||||
},
|
||||
],
|
||||
[
|
||||
"./plugins/omniroute-opencode-plugin-preprod/dist/index.js",
|
||||
{
|
||||
"providerId": "omniroute-preprod",
|
||||
"displayName": "OmniRoute Preprod",
|
||||
"baseURL": "https://or-preprod.example.com",
|
||||
},
|
||||
],
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
Paths are relative to `~/.config/opencode/`. Each entry now resolves to a distinct module file, so OC loads them as two separate plugin instances. Authenticate each:
|
||||
|
||||
```sh
|
||||
opencode auth login --provider omniroute
|
||||
opencode auth login --provider omniroute-preprod
|
||||
```
|
||||
|
||||
Each entry gets its own provider id, its own model picker entry, its own slot in `auth.json`, and its own TTL cache. Closures are isolated per plugin instance — no cross-talk.
|
||||
|
||||
### After publish (`@omniroute/opencode-plugin` npm)
|
||||
|
||||
Once the package is published, the dual-install becomes two `npm install --prefix` commands instead of `tar -xzf`:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.config/opencode/plugins/omniroute-opencode-plugin-prod
|
||||
mkdir -p ~/.config/opencode/plugins/omniroute-opencode-plugin-preprod
|
||||
npm install --prefix ~/.config/opencode/plugins/omniroute-opencode-plugin-prod @omniroute/opencode-plugin
|
||||
npm install --prefix ~/.config/opencode/plugins/omniroute-opencode-plugin-preprod @omniroute/opencode-plugin
|
||||
```
|
||||
|
||||
`opencode.json` paths become `./plugins/omniroute-opencode-plugin-prod/node_modules/@omniroute/opencode-plugin/dist/index.js` (and the preprod equivalent).
|
||||
|
||||
## Features
|
||||
|
||||
| Feature | What it does | Hook |
|
||||
| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
|
||||
| Dynamic `/v1/models` | Pulls live catalog (455+ entries on prod) on each refresh, TTL-cached | `provider.models` |
|
||||
| Variants pass-through | `-low`/`-medium`/`-high`/`-thinking` ship as first-class IDs from OmniRoute (no client synthesis) | `provider.models` |
|
||||
| Combo LCD aggregation | Combos appear with intersected capabilities + min context/output across members | `provider.models` + `config` |
|
||||
| `combo/<slug>` namespace + `Combo: ` prefix | Combos surface under `combo/claude-primary` (not the upstream UUID) and the picker shows `Combo: claude-primary` so they stand apart from raw provider/model pairs | both hooks |
|
||||
| Nice names + cost | `/api/pricing/models` display names AND `/api/pricing` per-million-token cost overlaid onto the live catalog | both hooks |
|
||||
| Canonical-twin dedup + alias-fallback | `/v1/models` exposes the same upstream model under both short alias (`cc/claude-opus-4-7`) and canonical name (`claude/claude-opus-4-7`); the plugin drops the canonical twin when an alias twin exists (no duplicate rows in the picker) and reverse-maps canonical → alias to pick up enrichment for short aliases (`dg/nova-3 → Deepgram - Nova 3`) that `/api/pricing/models` only indexes by canonical | both hooks |
|
||||
| Compression pipeline tags | Combo names get tagged with their compression pipeline (e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`) when `features.compressionMetadata: true`. Intensity tokens render as a traffic-light emoji: 🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra | both hooks |
|
||||
| Provider-tag prefix | Prepend short upstream-provider label to enriched names (e.g. `Claude - Claude Opus 4.7` vs `Kiro - Claude Opus 4.7`, `GHM - GPT 5`) so same-id models routed via different upstream connections group visibly in the picker (default-on, opt-out via `features.providerTag: false`) | both hooks |
|
||||
| Usable-only filter | Filter to providers with at least one healthy connection in `/api/providers` (opt-in via `features.usableOnly`) | both hooks |
|
||||
| Disk-cache fallback | Last-known-good catalog persisted to disk; hydrates on a cold start when `/v1/models` is unreachable (default-on, opt-out via `features.diskCache: false`) | `config` |
|
||||
| Bearer injection + suffix-spoof guard | Adds `Authorization` on baseURL-matched requests only | `auth.loader.fetch` |
|
||||
| Gemini schema sanitization | Strips `$schema`/`$ref`/`additionalProperties` for `gemini-*`/`google-vertex-gemini/*` | `auth.loader.fetch` wrap |
|
||||
| Multi-instance | Each plugin entry binds to its own `providerId`; closures isolated | factory |
|
||||
| Config-hook shim | OC ≤1.15.5 fallback: writes static catalog into `config.provider[id]` (config hook is the only one that fires in `serve` mode on these versions) | `config` |
|
||||
|
||||
## Plugin options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| --------------- | -------- | ------------------------------------------ | ---------------------------------------------------------- |
|
||||
| `providerId` | `string` | `"omniroute"` | OpenCode provider id; must be unique across plugin entries |
|
||||
| `displayName` | `string` | `"OmniRoute"` or `OmniRoute (<id>)` | Label in the OC UI |
|
||||
| `modelCacheTtl` | `number` | `300000` (5 min) | `/v1/models` TTL in ms |
|
||||
| `baseURL` | `string` | resolved from `auth.json` after `/connect` | Override OmniRoute base URL |
|
||||
| `features` | `object` | see below | Feature toggles (all opt-in/out, defaults preserve v0.1.0) |
|
||||
|
||||
### `features` block
|
||||
|
||||
Every field is optional. Defaults mirror v0.1.0 behaviour so existing `opencode.json` files do not need to change.
|
||||
|
||||
| Feature | Type | Default | What it does |
|
||||
| --------------------- | --------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `combos` | `boolean` | `true` | Discover `/api/combos` and surface them as pseudo-models with LCD capabilities. Combos are keyed under the `combo/<slug>` namespace and labelled `Combo: <name>` in the model picker so they're distinguishable from raw provider/model pairs. |
|
||||
| `enrichment` | `boolean` | `true` | Pull display names from `/api/pricing/models` AND per-million-token pricing (`input`, `output`, `cached` → `cacheRead`, `cache_creation` → `cacheWrite`) from `/api/pricing`, then overlay both onto the live catalog (so the UI shows `Claude 4.7 Opus` with `cost.input: 5`, `cost.output: 25` instead of raw IDs and zeroed cost). |
|
||||
| `compressionMetadata` | `boolean` | `false` | Pull `/api/context/combos` so combo names get tagged with their compression pipeline, e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`. Intensity tokens render as traffic-light emoji (🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra) so the picker advertises "how compressed" each combo is at a glance. |
|
||||
| `providerTag` | `boolean` | `true` | Prepend a short upstream-provider label to the enriched display name with `" - "` separator, so `cc/claude-opus-4-7 → Claude - Claude Opus 4.7` differs visibly from `kr/claude-opus-4-7 → Kiro - Claude Opus 4.7` in the OC TUI model picker. Label resolution: use `/api/pricing/models[<alias>].name` verbatim when ≤8 chars (e.g. `Claude`, `Kiro`, `Codex`, `Qwen`), otherwise fall back to `UPPER(alias)` (e.g. `GitHub Models` → `GHM`, `Gemini` → `GEMINI`). Idempotent. Combos intentionally skipped (the `Combo: ` prefix already conveys multi-upstream). |
|
||||
| `usableOnly` | `boolean` | `false` | Read `/api/providers` and filter the catalog to providers that have at least one connection with `isActive: true` AND `testStatus: 'active'`. Subtract-filter semantics: providers unknown to BOTH the pricing-models catalog AND the connection table pass through (so synthetic prefixes like `agentrouter/*` survive). On fetch failure the filter is disabled for the refresh — never hides the whole catalog. |
|
||||
| `diskCache` | `boolean` | `true` | Persist the last successful `/v1/models` + `/api/combos` + enrichment + connections + compression snapshot to `${OPENCODE_DATA_DIR ?? ~/.local/share/opencode}/plugins/omniroute-<providerId>.json`. On a subsequent cold start where `/v1/models` throws (network down / IP whitelist drop / 5xx) the static block hydrates from the snapshot so OC's model picker survives offline. Soft-fail on read/write — never blocks publishing. |
|
||||
| `geminiSanitization` | `boolean` | `true` | Strip `$schema`/`$ref`/`additionalProperties` from tool params when the model id matches `gemini` |
|
||||
| `mcpAutoEmit` | `boolean` | `false` | Auto-write an `mcp.<providerId>` remote entry into the OC config pointing at `<baseURL>/api/mcp/stream` with the resolved Bearer token |
|
||||
| `mcpToken` | `string` | _unset_ | Optional separate Bearer for the auto-emitted MCP entry. Falls back to the provider's `apiKey` (from `auth.json`) when unset |
|
||||
| `fetchInterceptor` | `boolean` | `true` | Inject `Authorization: Bearer` + default `Content-Type` on every outbound request targeting `baseURL` (suffix-spoof guarded) |
|
||||
|
||||
#### Example — enrichment + compression tags + MCP auto-emit
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"plugin": [
|
||||
[
|
||||
"@omniroute/opencode-plugin",
|
||||
{
|
||||
"providerId": "omniroute",
|
||||
"baseURL": "https://or.example.com",
|
||||
"features": {
|
||||
"combos": true,
|
||||
"enrichment": true,
|
||||
"compressionMetadata": true,
|
||||
"mcpAutoEmit": true,
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
With `mcpAutoEmit: true`, the plugin synthesises an `mcp.omniroute` entry equivalent to a manual:
|
||||
|
||||
```jsonc
|
||||
"mcp": {
|
||||
"omniroute": {
|
||||
"type": "remote",
|
||||
"url": "https://or.example.com/api/mcp/stream",
|
||||
"enabled": true,
|
||||
"headers": { "Authorization": "Bearer <apiKey-from-auth.json>" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you want a narrower-scoped Bearer for MCP (different from the chat/inference key), set `features.mcpToken`. Operator overrides win: if you already set `mcp.omniroute` in `opencode.json`, the plugin will not overwrite it.
|
||||
|
||||
#### Example — production-leaning defaults (clean picker, offline resilience)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"plugin": [
|
||||
[
|
||||
"@omniroute/opencode-plugin",
|
||||
{
|
||||
"providerId": "omniroute",
|
||||
"baseURL": "https://or.example.com",
|
||||
"features": {
|
||||
"combos": true,
|
||||
"enrichment": true,
|
||||
"compressionMetadata": true,
|
||||
"usableOnly": true,
|
||||
"diskCache": true,
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
- `usableOnly: true` drops models whose canonical provider has no healthy connection in your OmniRoute instance — your `/models` picker stays focused on what you can actually call.
|
||||
- `diskCache: true` (default) writes a snapshot to `${OPENCODE_DATA_DIR}/plugins/omniroute-<providerId>.json` on every healthy refresh. On a cold start where `/v1/models` is unreachable (laptop offline, IP whitelist drop), the snapshot hydrates the static block so OC still shows the catalog instead of a stub.
|
||||
- `compressionMetadata: true` annotates combo display names with their pipeline using traffic-light emoji for intensity (e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`) so the picker advertises which compression each combo applies and how heavy it is at a glance. Palette: 🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra. Unknown intensities fall through to raw text (`[rtk:custom-thing]`) so the plugin never hides a value OmniRoute knows but the plugin doesn't.
|
||||
- `providerTag: true` (default) prepends a short upstream-provider label so the picker shows `Claude - Claude Opus 4.7` for `cc/claude-opus-4-7`, `Kiro - Claude Opus 4.7` for `kr/claude-opus-4-7`, and `GHM - GPT 5` for `ghm/gpt-5` (slot.name `GitHub Models` > 8 chars → abbreviated). Critical when the same model id is sold through multiple upstream connections with different cost/auth/rate-limit profiles. Set to `false` to keep the pre-v3.8.3 unsuffixed format.
|
||||
|
||||
## Comparison vs `@omniroute/opencode-provider`
|
||||
|
||||
[`@omniroute/opencode-provider`](https://github.com/diegosouzapw/OmniRoute/tree/main/%40omniroute/opencode-provider) is the existing config-generator package — it writes a frozen `provider.<id>` block into `opencode.json` at build time. This plugin is the runtime integration.
|
||||
|
||||
| | `@omniroute/opencode-plugin` (this) | `@omniroute/opencode-provider` |
|
||||
| ----------------- | ----------------------------------- | --------------------------------- |
|
||||
| Type | OC plugin | Config generator (CLI/build-time) |
|
||||
| Models | Live from `/v1/models` | Frozen at scaffold |
|
||||
| Combos | LCD-aggregated live | None |
|
||||
| Gemini sanitize | Yes | N/A |
|
||||
| OC UI integration | `/connect`, `/models` | None |
|
||||
| Multi-instance | Native | Manual |
|
||||
|
||||
Both can coexist; pick the one that fits your environment.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node `>=22.22.3` (per `engines.node`); tested on Node 22 and 24.
|
||||
- OpenCode: verified end-to-end against `opencode@1.15.5` with `@opencode-ai/plugin@1.15.6`.
|
||||
- OC plugin peer (`@opencode-ai/plugin`) `>=1.14.49` for the full feature set (provider hook surfaces models in `/models`). On `<=1.14.48`, the plugin falls back to its `config` hook, writing a static catalog snapshot into `config.provider[id]` so models still appear.
|
||||
- The plugin uses the OC v1 plugin shape (`default: { id, server }`) — older OC releases that only walk named exports will reject it. Stay on OC ≥1.15.
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](./LICENSE).
|
||||
+1930
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"name": "@omniroute/opencode-plugin",
|
||||
"version": "0.2.0",
|
||||
"description": "OpenCode plugin for the OmniRoute AI Gateway. Drives dynamic model discovery, /connect auth flow, and multi-instance OmniRoute providers via the official @opencode-ai/plugin contract.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./runtime": {
|
||||
"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/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/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts",
|
||||
"prepublishOnly": "npm run clean && npm run build && npm test"
|
||||
},
|
||||
"keywords": [
|
||||
"omniroute",
|
||||
"opencode",
|
||||
"opencode-plugin",
|
||||
"ai-sdk",
|
||||
"openai-compatible",
|
||||
"provider",
|
||||
"gemini",
|
||||
"combos",
|
||||
"mcp"
|
||||
],
|
||||
"author": "OmniRoute contributors",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/diegosouzapw/OmniRoute.git",
|
||||
"directory": "@omniroute/opencode-plugin"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/diegosouzapw/OmniRoute/issues"
|
||||
},
|
||||
"homepage": "https://github.com/diegosouzapw/OmniRoute/tree/main/%40omniroute/opencode-plugin#readme",
|
||||
"engines": {
|
||||
"node": ">=22.22.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/plugin": "*"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/plugin": "^1.15.6",
|
||||
"@types/node": "^22.19.19",
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.22.3",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "^0.28.1"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Structured logger for the OmniRoute plugin.
|
||||
*
|
||||
* Levels: error < warn < info < debug
|
||||
* Default: warn (matches current console.warn behavior)
|
||||
* Set via features.logLevel in plugin options.
|
||||
*/
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
error(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(_level, "error")) console.error(fmt("error", msg), ...args);
|
||||
},
|
||||
warn(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(_level, "warn")) console.warn(fmt("warn", msg), ...args);
|
||||
},
|
||||
info(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(_level, "info")) console.warn(fmt("info", msg), ...args);
|
||||
},
|
||||
debug(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(_level, "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);
|
||||
},
|
||||
|
||||
// ── Tagged child loggers ──────────────────────────────────────────────
|
||||
child(tag: string) {
|
||||
return {
|
||||
error: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(_level, "error") &&
|
||||
console.error(fmt("error", msg, tag), ...args),
|
||||
warn: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(_level, "warn") &&
|
||||
console.warn(fmt("warn", msg, tag), ...args),
|
||||
info: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(_level, "info") &&
|
||||
console.warn(fmt("info", msg, tag), ...args),
|
||||
debug: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(_level, "debug") &&
|
||||
console.warn(fmt("debug", msg, tag), ...args),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* 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 ────────────────────────────────────────────────
|
||||
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, "") + "B";
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, "") + "M";
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, "") + "K";
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* T-02 auth-hook contract tests.
|
||||
*
|
||||
* Covers the `createOmniRouteAuthHook(opts)` factory and its loader behaviour
|
||||
* against every Auth flavor (`api`, `oauth`, null, empty key). Validates the
|
||||
* multi-instance fix: provider id flows from plugin options, not a module
|
||||
* constant.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createOmniRouteAuthHook } from "../src/index.js";
|
||||
|
||||
test("createOmniRouteAuthHook: default providerId is 'omniroute'", () => {
|
||||
const hook = createOmniRouteAuthHook();
|
||||
assert.equal(hook.provider, "opencode-omniroute");
|
||||
});
|
||||
|
||||
test("createOmniRouteAuthHook: custom providerId binds to hook.provider (multi-instance)", () => {
|
||||
const hook = createOmniRouteAuthHook({ providerId: "omniroute-preprod" });
|
||||
assert.equal(hook.provider, "opencode-omniroute-preprod");
|
||||
});
|
||||
|
||||
test("createOmniRouteAuthHook: methods[0] is type 'api' with label including displayName", () => {
|
||||
const hook = createOmniRouteAuthHook();
|
||||
assert.equal(Array.isArray(hook.methods), true);
|
||||
assert.equal(hook.methods.length, 1);
|
||||
const m = hook.methods[0];
|
||||
assert.equal(m.type, "api");
|
||||
assert.equal(m.label, "OmniRoute API Key");
|
||||
|
||||
const custom = createOmniRouteAuthHook({ providerId: "omniroute-preprod" });
|
||||
assert.equal(custom.methods[0].label, "OmniRoute (opencode-omniroute-preprod) API Key");
|
||||
});
|
||||
|
||||
test("createOmniRouteAuthHook: prompts[0] uses key='apiKey' per @opencode-ai/plugin contract", () => {
|
||||
// NOTE: spec referenced `name: "apiKey"`; the official
|
||||
// @opencode-ai/plugin@1.15.6 prompt shape uses `key` + `message` (no
|
||||
// `name`/`label`/`mask` fields). Asserting against the real type contract.
|
||||
const hook = createOmniRouteAuthHook();
|
||||
const m = hook.methods[0];
|
||||
assert.equal(m.type, "api");
|
||||
// narrow: api method may carry prompts
|
||||
const prompts = "prompts" in m ? m.prompts : undefined;
|
||||
assert.ok(Array.isArray(prompts) && prompts.length === 1, "expected one prompt");
|
||||
const p = prompts![0];
|
||||
assert.equal(p.type, "text");
|
||||
assert.equal((p as { key: string }).key, "apiKey");
|
||||
assert.ok(
|
||||
typeof (p as { message: string }).message === "string" &&
|
||||
(p as { message: string }).message.includes("omniroute"),
|
||||
"prompt message should mention provider id"
|
||||
);
|
||||
});
|
||||
|
||||
test("loader: valid api auth → {apiKey} when no baseURL option (T-04: fetch omitted)", async () => {
|
||||
// T-04 changed the loader return shape: without a resolvable baseURL the
|
||||
// interceptor cannot gate-keep requests, so the loader falls back to
|
||||
// apiKey-only and the AI-SDK uses its default fetch. See fetch-interceptor
|
||||
// tests for the wired-fetch branches.
|
||||
const hook = createOmniRouteAuthHook();
|
||||
assert.ok(hook.loader, "loader must be defined");
|
||||
const result = await hook.loader!(
|
||||
async () => ({ type: "api", key: "sk-test" }) as never,
|
||||
{} as never
|
||||
);
|
||||
assert.deepEqual(result, { apiKey: "sk-test" });
|
||||
});
|
||||
|
||||
test("loader: valid api auth → {apiKey, baseURL, fetch} when baseURL option set (T-04)", async () => {
|
||||
const hook = createOmniRouteAuthHook({ baseURL: "https://or.example.com/v1" });
|
||||
const result = await hook.loader!(
|
||||
async () => ({ type: "api", key: "sk-x" }) as never,
|
||||
{} as never
|
||||
);
|
||||
assert.equal((result as { apiKey: string }).apiKey, "sk-x");
|
||||
assert.equal((result as { baseURL: string }).baseURL, "https://or.example.com/v1");
|
||||
assert.equal(
|
||||
typeof (result as { fetch?: unknown }).fetch,
|
||||
"function",
|
||||
"T-04: loader must wire fetch interceptor when baseURL resolves"
|
||||
);
|
||||
});
|
||||
|
||||
test("loader: features.fetchInterceptor=false AND geminiSanitization=false → no custom fetch (flags honored)", async () => {
|
||||
// Regression: both fetch-layer flags were documented + schema-validated but
|
||||
// silently ignored. Disabling both must fall back to the SDK default fetch.
|
||||
const hook = createOmniRouteAuthHook({
|
||||
baseURL: "https://or.example.com/v1",
|
||||
features: { fetchInterceptor: false, geminiSanitization: false },
|
||||
});
|
||||
const result = await hook.loader!(
|
||||
async () => ({ type: "api", key: "sk-x" }) as never,
|
||||
{} as never
|
||||
);
|
||||
assert.deepEqual(result, { apiKey: "sk-x", baseURL: "https://or.example.com/v1" });
|
||||
assert.equal(
|
||||
(result as { fetch?: unknown }).fetch,
|
||||
undefined,
|
||||
"both flags off must omit the custom fetch"
|
||||
);
|
||||
});
|
||||
|
||||
test("loader: features.fetchInterceptor=false but geminiSanitization=true → fetch still wired (sanitizer only)", async () => {
|
||||
const hook = createOmniRouteAuthHook({
|
||||
baseURL: "https://or.example.com/v1",
|
||||
features: { fetchInterceptor: false, geminiSanitization: true },
|
||||
});
|
||||
const result = await hook.loader!(
|
||||
async () => ({ type: "api", key: "sk-x" }) as never,
|
||||
{} as never
|
||||
);
|
||||
assert.equal(
|
||||
typeof (result as { fetch?: unknown }).fetch,
|
||||
"function",
|
||||
"geminiSanitization alone must still provide a fetch wrapper"
|
||||
);
|
||||
});
|
||||
|
||||
test("loader: null/undefined auth → {} (no creds yet, OC surfaces /connect)", async () => {
|
||||
const hook = createOmniRouteAuthHook();
|
||||
const r1 = await hook.loader!(async () => null as never, {} as never);
|
||||
assert.deepEqual(r1, {});
|
||||
const r2 = await hook.loader!(async () => undefined as never, {} as never);
|
||||
assert.deepEqual(r2, {});
|
||||
});
|
||||
|
||||
test("loader: oauth-flavored auth → {} (wrong method type, ignored)", async () => {
|
||||
const hook = createOmniRouteAuthHook();
|
||||
const result = await hook.loader!(
|
||||
async () =>
|
||||
({
|
||||
type: "oauth",
|
||||
refresh: "r",
|
||||
access: "a",
|
||||
expires: 0,
|
||||
}) as never,
|
||||
{} as never
|
||||
);
|
||||
assert.deepEqual(result, {});
|
||||
});
|
||||
|
||||
test("loader: api auth with empty key → {} (empty creds rejected)", async () => {
|
||||
const hook = createOmniRouteAuthHook();
|
||||
const result = await hook.loader!(async () => ({ type: "api", key: "" }) as never, {} as never);
|
||||
assert.deepEqual(result, {});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* TDD regression — auto combos must never advertise `limit.context: 0`.
|
||||
*
|
||||
* opencode's overflow guard (packages/opencode/src/session/overflow.ts)
|
||||
* short-circuits when `model.limit.context === 0`:
|
||||
*
|
||||
* if (input.model.limit.context === 0) return false // never overflow
|
||||
*
|
||||
* so a zero context silently DISABLES opencode's smart auto-compaction for
|
||||
* auto combos. The session then grows unbounded until OmniRoute's
|
||||
* server-side purifyHistory() destructively drops old messages — the
|
||||
* "coding agent keeps forgetting things" bug.
|
||||
*
|
||||
* Fix under test: mapAutoComboToStaticEntry consumes the context_length /
|
||||
* max_output_tokens now served by GET /api/combos/auto, and falls back to a
|
||||
* safe positive default (128000 / 8192) for older servers that do not send
|
||||
* the fields yet.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { mapAutoComboToStaticEntry } from "../src/index.ts";
|
||||
import type { OmniRouteRawAutoCombo } from "../src/index.ts";
|
||||
|
||||
test("uses server-provided context_length and max_output_tokens", () => {
|
||||
const raw = {
|
||||
id: "auto/coding",
|
||||
name: "Auto Coding",
|
||||
variant: "coding",
|
||||
candidateCount: 5,
|
||||
context_length: 1048576,
|
||||
max_output_tokens: 65536,
|
||||
} as OmniRouteRawAutoCombo;
|
||||
|
||||
const entry = mapAutoComboToStaticEntry(raw);
|
||||
assert.equal(entry.limit?.context, 1048576);
|
||||
assert.equal(entry.limit?.output, 65536);
|
||||
});
|
||||
|
||||
test("falls back to a safe positive default when the server omits limits (old servers)", () => {
|
||||
const raw = {
|
||||
id: "auto",
|
||||
name: "Auto",
|
||||
candidateCount: 3,
|
||||
} as OmniRouteRawAutoCombo;
|
||||
|
||||
const entry = mapAutoComboToStaticEntry(raw);
|
||||
assert.ok(
|
||||
typeof entry.limit?.context === "number" && entry.limit.context > 0,
|
||||
`context must be a positive number (never 0 — zero disables opencode auto-compaction), got ${entry.limit?.context}`
|
||||
);
|
||||
assert.ok(
|
||||
typeof entry.limit?.output === "number" && entry.limit.output > 0,
|
||||
`output must be a positive number, got ${entry.limit?.output}`
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores non-positive server values and keeps the safe fallback", () => {
|
||||
const raw = {
|
||||
id: "auto/fast",
|
||||
name: "Auto Fast",
|
||||
variant: "fast",
|
||||
candidateCount: 2,
|
||||
context_length: 0,
|
||||
max_output_tokens: -1,
|
||||
} as OmniRouteRawAutoCombo;
|
||||
|
||||
const entry = mapAutoComboToStaticEntry(raw);
|
||||
assert.ok(
|
||||
typeof entry.limit?.context === "number" && entry.limit.context > 0,
|
||||
"zero/negative server values must not propagate"
|
||||
);
|
||||
assert.ok(typeof entry.limit?.output === "number" && entry.limit.output > 0);
|
||||
});
|
||||
@@ -0,0 +1,711 @@
|
||||
/**
|
||||
* T-05 combo-discovery contract tests.
|
||||
*
|
||||
* Covers:
|
||||
* - `defaultOmniRouteCombosFetcher(baseURL, apiKey, timeoutMs?)`
|
||||
* — envelope tolerance (`{combos: [...]}` and bare array), non-2xx errors.
|
||||
* - `mapComboToModelV2(combo, members, providerId, baseURL)`
|
||||
* — LCD policy across capabilities, limits, modalities; defensive
|
||||
* posture on empty members; nice-name preference.
|
||||
* - `createOmniRouteProviderHook(opts, deps)` extension
|
||||
* — combos merged into the models map; collision resolution (combo
|
||||
* wins, warn-once); soft-fail when the combos fetcher throws;
|
||||
* combos cached + reused under the same TTL key as models.
|
||||
*
|
||||
* Mocking strategy mirrors `provider.test.ts`: both fetchers are
|
||||
* dependency-injected at hook construction, no `fetch` monkey-patch.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
createOmniRouteProviderHook,
|
||||
defaultOmniRouteCombosFetcher,
|
||||
mapComboToModelV2,
|
||||
type OmniRouteCombosFetcher,
|
||||
type OmniRouteModelsFetcher,
|
||||
type OmniRouteRawCombo,
|
||||
type OmniRouteRawModelEntry,
|
||||
} from "../src/index.js";
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Fixtures
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const MODEL_PRIMARY: OmniRouteRawModelEntry = {
|
||||
id: "claude-primary",
|
||||
capabilities: {
|
||||
tool_calling: true,
|
||||
reasoning: true,
|
||||
vision: true,
|
||||
thinking: true,
|
||||
temperature: true,
|
||||
},
|
||||
context_length: 200_000,
|
||||
max_output_tokens: 64_000,
|
||||
max_input_tokens: 180_000,
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
};
|
||||
|
||||
const MODEL_SECONDARY: OmniRouteRawModelEntry = {
|
||||
id: "claude-secondary",
|
||||
capabilities: {
|
||||
tool_calling: true,
|
||||
reasoning: false,
|
||||
vision: true,
|
||||
thinking: false,
|
||||
temperature: true,
|
||||
},
|
||||
context_length: 100_000,
|
||||
max_output_tokens: 32_000,
|
||||
max_input_tokens: 96_000,
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
};
|
||||
|
||||
const MODEL_NO_TOOLS: OmniRouteRawModelEntry = {
|
||||
id: "gemini-3-flash",
|
||||
capabilities: { tool_calling: false, reasoning: false, vision: false, thinking: false },
|
||||
context_length: 1_000_000,
|
||||
max_output_tokens: 8_192,
|
||||
input_modalities: ["text"],
|
||||
output_modalities: ["text"],
|
||||
};
|
||||
|
||||
const COMBO_CLAUDE_TIER: OmniRouteRawCombo = {
|
||||
id: "combo-claude-tier",
|
||||
name: "Claude Tier",
|
||||
strategy: "priority",
|
||||
models: [
|
||||
{ id: "s1", kind: "model", model: "claude-primary", weight: 100 },
|
||||
{ id: "s2", kind: "model", model: "claude-secondary", weight: 80 },
|
||||
],
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function stubModelsFetcher(
|
||||
payload: OmniRouteRawModelEntry[]
|
||||
): OmniRouteModelsFetcher & { callCount: () => number } {
|
||||
let n = 0;
|
||||
const f: OmniRouteModelsFetcher = async () => {
|
||||
n++;
|
||||
return payload;
|
||||
};
|
||||
return Object.assign(f, { callCount: () => n });
|
||||
}
|
||||
|
||||
function stubCombosFetcher(
|
||||
payload: OmniRouteRawCombo[]
|
||||
): OmniRouteCombosFetcher & { callCount: () => number; callsBy: () => Array<[string, string]> } {
|
||||
let n = 0;
|
||||
const calls: Array<[string, string]> = [];
|
||||
const f: OmniRouteCombosFetcher = async (baseURL, apiKey) => {
|
||||
n++;
|
||||
calls.push([baseURL, apiKey]);
|
||||
return payload;
|
||||
};
|
||||
return Object.assign(f, {
|
||||
callCount: () => n,
|
||||
callsBy: () => calls,
|
||||
});
|
||||
}
|
||||
|
||||
function failingCombosFetcher(
|
||||
err = new Error("boom")
|
||||
): OmniRouteCombosFetcher & { callCount: () => number } {
|
||||
let n = 0;
|
||||
const f: OmniRouteCombosFetcher = async () => {
|
||||
n++;
|
||||
throw err;
|
||||
};
|
||||
return Object.assign(f, { callCount: () => n });
|
||||
}
|
||||
|
||||
const apiAuth = (key: string): unknown => ({ type: "api", key });
|
||||
|
||||
// Capture console.warn invocations for the duration of a callback, then
|
||||
// restore the original. Needed because the collision + soft-fail paths
|
||||
// emit warnings we want to assert on.
|
||||
async function withWarnCapture<T>(
|
||||
fn: (warnings: Array<{ args: unknown[] }>) => Promise<T>
|
||||
): Promise<{ result: T; warnings: Array<{ args: unknown[] }> }> {
|
||||
const original = console.warn;
|
||||
const warnings: Array<{ args: unknown[] }> = [];
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warnings.push({ args });
|
||||
};
|
||||
try {
|
||||
const result = await fn(warnings);
|
||||
return { result, warnings };
|
||||
} finally {
|
||||
console.warn = original;
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// defaultOmniRouteCombosFetcher — envelope tolerance + error surfacing
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("defaultOmniRouteCombosFetcher: parses {combos:[…]} envelope", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: unknown) => {
|
||||
const url = typeof input === "string" ? input : (input as { url: string }).url;
|
||||
assert.equal(url, "https://or.example.com/api/combos");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
combos: [
|
||||
{ id: "c1", name: "Combo One", strategy: "priority", models: [] },
|
||||
{ id: "c2", name: "Combo Two", strategy: "weighted", models: [] },
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const combos = await defaultOmniRouteCombosFetcher("https://or.example.com", "sk-test");
|
||||
assert.equal(combos.length, 2);
|
||||
assert.equal(combos[0].id, "c1");
|
||||
assert.equal(combos[1].id, "c2");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("defaultOmniRouteCombosFetcher: parses bare array envelope", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () => {
|
||||
return new Response(JSON.stringify([{ id: "c1" }, { id: "c2" }, { not_an_id: 42 }]), {
|
||||
status: 200,
|
||||
});
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const combos = await defaultOmniRouteCombosFetcher("https://or.example.com/v1", "sk-test");
|
||||
// Strip /v1 before /api/combos, AND filter out entries with no string id.
|
||||
assert.equal(combos.length, 2);
|
||||
assert.equal(combos[0].id, "c1");
|
||||
assert.equal(combos[1].id, "c2");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("defaultOmniRouteCombosFetcher: strips trailing /v1 before /api/combos", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let observedUrl = "";
|
||||
globalThis.fetch = (async (input: unknown) => {
|
||||
observedUrl = typeof input === "string" ? input : (input as { url: string }).url;
|
||||
return new Response(JSON.stringify({ combos: [] }), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await defaultOmniRouteCombosFetcher("https://or.example.com/v1/", "sk-test");
|
||||
assert.equal(observedUrl, "https://or.example.com/api/combos");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("defaultOmniRouteCombosFetcher: throws on non-2xx with status code in message", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () => {
|
||||
return new Response(JSON.stringify({ error: "Invalid token" }), {
|
||||
status: 403,
|
||||
statusText: "Forbidden",
|
||||
});
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await defaultOmniRouteCombosFetcher("https://or.example.com", "sk-bad");
|
||||
},
|
||||
(err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
assert.match(msg, /403/, "status code must appear in message");
|
||||
assert.match(msg, /\/api\/combos/, "url must appear in message");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("defaultOmniRouteCombosFetcher: throws when apiKey missing", async () => {
|
||||
await assert.rejects(
|
||||
async () => defaultOmniRouteCombosFetcher("https://or.example.com", ""),
|
||||
/apiKey required/
|
||||
);
|
||||
});
|
||||
|
||||
test("defaultOmniRouteCombosFetcher: throws when baseURL missing", async () => {
|
||||
await assert.rejects(
|
||||
async () => defaultOmniRouteCombosFetcher("", "sk-test"),
|
||||
/baseURL required/
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// mapComboToModelV2 — LCD semantics
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("mapComboToModelV2: empty members → capabilities all false (defensive)", () => {
|
||||
const m = mapComboToModelV2(
|
||||
{ id: "combo-empty", name: "Empty Combo" },
|
||||
[],
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
assert.equal(m.id, "combo-empty");
|
||||
assert.equal(m.name, "Empty Combo");
|
||||
assert.equal(m.capabilities.temperature, false);
|
||||
assert.equal(m.capabilities.reasoning, false);
|
||||
assert.equal(m.capabilities.attachment, false);
|
||||
assert.equal(m.capabilities.toolcall, false);
|
||||
assert.equal(m.capabilities.input.text, false);
|
||||
assert.equal(m.capabilities.output.text, false);
|
||||
assert.equal(m.limit.context, 0);
|
||||
assert.equal(m.limit.output, 0);
|
||||
assert.equal(m.limit.input, undefined);
|
||||
assert.deepEqual(m.cost, { input: 0, output: 0, cache: { read: 0, write: 0 } });
|
||||
});
|
||||
|
||||
test("mapComboToModelV2: all members reasoning=true → combo reasoning=true", () => {
|
||||
const m = mapComboToModelV2(
|
||||
{ id: "c", models: [] },
|
||||
[
|
||||
MODEL_PRIMARY,
|
||||
{
|
||||
...MODEL_PRIMARY,
|
||||
id: "p2",
|
||||
capabilities: { ...MODEL_PRIMARY.capabilities, thinking: false, reasoning: true },
|
||||
},
|
||||
],
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
assert.equal(m.capabilities.reasoning, true);
|
||||
});
|
||||
|
||||
test("mapComboToModelV2: any member reasoning=false → combo reasoning=false", () => {
|
||||
const m = mapComboToModelV2(
|
||||
{ id: "c", models: [] },
|
||||
[MODEL_PRIMARY, MODEL_NO_TOOLS], // gemini-3-flash has reasoning:false, thinking:false
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
assert.equal(m.capabilities.reasoning, false);
|
||||
});
|
||||
|
||||
test("mapComboToModelV2: limit.context is min of members'", () => {
|
||||
const m = mapComboToModelV2(
|
||||
{ id: "c", models: [] },
|
||||
[MODEL_PRIMARY, MODEL_SECONDARY, MODEL_NO_TOOLS],
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
// min(200_000, 100_000, 1_000_000) = 100_000
|
||||
assert.equal(m.limit.context, 100_000);
|
||||
// min(64_000, 32_000, 8_192) = 8_192
|
||||
assert.equal(m.limit.output, 8_192);
|
||||
});
|
||||
|
||||
test("mapComboToModelV2: limit.input only emitted when EVERY member declares one", () => {
|
||||
const m1 = mapComboToModelV2(
|
||||
{ id: "c", models: [] },
|
||||
[MODEL_PRIMARY, MODEL_SECONDARY],
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
// Both declare max_input_tokens → limit.input = min(180000, 96000)
|
||||
assert.equal(m1.limit.input, 96_000);
|
||||
|
||||
const m2 = mapComboToModelV2(
|
||||
{ id: "c", models: [] },
|
||||
[MODEL_PRIMARY, MODEL_NO_TOOLS], // gemini-3-flash doesn't declare max_input_tokens
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
assert.equal(m2.limit.input, undefined);
|
||||
});
|
||||
|
||||
test("mapComboToModelV2: nice name preferred from combo.name", () => {
|
||||
const m1 = mapComboToModelV2(
|
||||
{ id: "combo-x", name: "Pretty Name" },
|
||||
[MODEL_PRIMARY],
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
assert.equal(m1.name, "Pretty Name");
|
||||
|
||||
// Falls back to id when name is absent or empty.
|
||||
const m2 = mapComboToModelV2(
|
||||
{ id: "combo-y" },
|
||||
[MODEL_PRIMARY],
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
assert.equal(m2.name, "combo-y");
|
||||
|
||||
const m3 = mapComboToModelV2(
|
||||
{ id: "combo-z", name: " " },
|
||||
[MODEL_PRIMARY],
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
assert.equal(m3.name, "combo-z");
|
||||
});
|
||||
|
||||
test("mapComboToModelV2: attachment AND vision flag both honored across members", () => {
|
||||
// MODEL_PRIMARY: vision=true; MODEL_SECONDARY: vision=true → combo attachment=true
|
||||
const yes = mapComboToModelV2(
|
||||
{ id: "c1", models: [] },
|
||||
[MODEL_PRIMARY, MODEL_SECONDARY],
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
assert.equal(yes.capabilities.attachment, true);
|
||||
|
||||
// Add a member with no vision/attachment → AND collapses to false
|
||||
const no = mapComboToModelV2(
|
||||
{ id: "c2", models: [] },
|
||||
[MODEL_PRIMARY, MODEL_NO_TOOLS],
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
assert.equal(no.capabilities.attachment, false);
|
||||
});
|
||||
|
||||
test("mapComboToModelV2: modalities AND'd across members", () => {
|
||||
const m = mapComboToModelV2(
|
||||
{ id: "c", models: [] },
|
||||
[MODEL_PRIMARY, MODEL_SECONDARY], // both have text+image
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
assert.equal(m.capabilities.input.text, true);
|
||||
assert.equal(m.capabilities.input.image, true);
|
||||
assert.equal(m.capabilities.input.audio, false);
|
||||
|
||||
// Add a text-only member → image collapses to false.
|
||||
const m2 = mapComboToModelV2(
|
||||
{ id: "c", models: [] },
|
||||
[MODEL_PRIMARY, MODEL_NO_TOOLS],
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
assert.equal(m2.capabilities.input.text, true);
|
||||
assert.equal(m2.capabilities.input.image, false);
|
||||
});
|
||||
|
||||
test("mapComboToModelV2: api block matches providerId + baseURL", () => {
|
||||
const m = mapComboToModelV2(
|
||||
{ id: "c" },
|
||||
[MODEL_PRIMARY],
|
||||
"omniroute-preprod",
|
||||
"https://or-preprod.example.com/v1"
|
||||
);
|
||||
assert.equal(m.providerID, "omniroute-preprod");
|
||||
assert.equal(m.api.id, "openai-compatible");
|
||||
assert.equal(m.api.url, "https://or-preprod.example.com/v1");
|
||||
assert.equal(m.api.npm, "@ai-sdk/openai-compatible");
|
||||
assert.equal(m.status, "active");
|
||||
});
|
||||
|
||||
test("mapComboToModelV2: explicit member temperature=false drops combo temperature=false", () => {
|
||||
const tempFalse: OmniRouteRawModelEntry = {
|
||||
id: "no-temp",
|
||||
capabilities: { tool_calling: true, temperature: false },
|
||||
context_length: 100_000,
|
||||
max_output_tokens: 8_000,
|
||||
input_modalities: ["text"],
|
||||
output_modalities: ["text"],
|
||||
};
|
||||
const m = mapComboToModelV2(
|
||||
{ id: "c" },
|
||||
[MODEL_PRIMARY, tempFalse],
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
assert.equal(m.capabilities.temperature, false);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// createOmniRouteProviderHook — combos merge + collision + soft-fail + cache
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("models() returns combo entries merged into the map", async () => {
|
||||
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY, MODEL_SECONDARY, MODEL_NO_TOOLS]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
|
||||
// 3 raw models + 1 combo = 4 entries
|
||||
assert.equal(Object.keys(out).length, 4);
|
||||
assert.ok(out["opencode-omniroute/claude-primary"]);
|
||||
assert.ok(out["opencode-omniroute/claude-secondary"]);
|
||||
assert.ok(out["opencode-omniroute/gemini-3-flash"]);
|
||||
assert.ok(out["opencode-omniroute/claude-tier"]);
|
||||
|
||||
const combo = out["opencode-omniroute/claude-tier"];
|
||||
assert.equal(combo.name, "Claude Tier");
|
||||
assert.equal(combo.providerID, "opencode-omniroute");
|
||||
// LCD over claude-primary (200k, reasoning) + claude-secondary (100k, no reasoning)
|
||||
assert.equal(combo.limit.context, 100_000);
|
||||
assert.equal(combo.capabilities.reasoning, false);
|
||||
assert.equal(combo.capabilities.toolcall, true);
|
||||
});
|
||||
|
||||
test("models(): combo with unknown member ids degrades to all-false LCD posture", async () => {
|
||||
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY]); // catalog only has claude-primary
|
||||
const combosFetcher = stubCombosFetcher([
|
||||
{
|
||||
id: "phantom",
|
||||
name: "Phantom Combo",
|
||||
models: [
|
||||
{ id: "s1", kind: "model", model: "does-not-exist-1", weight: 50 },
|
||||
{ id: "s2", kind: "model", model: "does-not-exist-2", weight: 50 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.ok(out["opencode-omniroute/phantom-combo"]);
|
||||
// With zero resolvable members, LCD = all-false (defensive posture).
|
||||
assert.equal(out["opencode-omniroute/phantom-combo"].capabilities.toolcall, false);
|
||||
assert.equal(out["opencode-omniroute/phantom-combo"].capabilities.reasoning, false);
|
||||
assert.equal(out["opencode-omniroute/phantom-combo"].limit.context, 0);
|
||||
});
|
||||
|
||||
test("models(): hidden combos are excluded from the map", async () => {
|
||||
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY]);
|
||||
const combosFetcher = stubCombosFetcher([
|
||||
{
|
||||
id: "visible",
|
||||
name: "Visible",
|
||||
models: [{ id: "s1", kind: "model", model: "claude-primary", weight: 100 }],
|
||||
},
|
||||
{
|
||||
id: "hidden",
|
||||
name: "Hidden",
|
||||
isHidden: true,
|
||||
models: [{ id: "s1", kind: "model", model: "claude-primary", weight: 100 }],
|
||||
},
|
||||
]);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.ok(out["opencode-omniroute/visible"]);
|
||||
assert.ok(!out["opencode-omniroute/hidden"], "hidden combo must be omitted");
|
||||
});
|
||||
|
||||
test("models(): combo name exactly matches raw model id → raw deleted, raw deleted, no warn", async () => {
|
||||
// Combo.name === raw model id triggers the dedup deletion. This mirrors
|
||||
// the real OmniRoute payload where /v1/models pre-mirrors combos as
|
||||
// no-slash raw entries whose ids match /api/combos friendly names.
|
||||
const colliderCombo: OmniRouteRawCombo = {
|
||||
id: "uuid-collider",
|
||||
name: "claude-primary", // EXACT match to MODEL_PRIMARY.id
|
||||
models: [{ id: "s1", kind: "model", model: "claude-secondary", weight: 100 }],
|
||||
};
|
||||
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY, MODEL_SECONDARY]);
|
||||
const combosFetcher = stubCombosFetcher([colliderCombo]);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
|
||||
const { result: out, warnings } = await withWarnCapture(async (_w) => {
|
||||
return hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
});
|
||||
|
||||
// Raw model replaced by combo of the same key; combo now lives at the bare slug.
|
||||
assert.ok(out["opencode-omniroute/claude-primary"], "combo surfaces under prefixed key");
|
||||
assert.equal(out["opencode-omniroute/claude-primary"].name, "claude-primary");
|
||||
|
||||
// No collision warning fires — dedup makes keys disjoint.
|
||||
const collisionWarns = warnings.filter((w) => {
|
||||
const msg = w.args[0];
|
||||
return typeof msg === "string" && msg.includes("collides");
|
||||
});
|
||||
assert.equal(collisionWarns.length, 0, "no collision warn after dedup");
|
||||
});
|
||||
|
||||
test("models(): two combos with same slug → second gets disambiguator suffix", async () => {
|
||||
// Both combos slug to `claude` — second must get `claude-<id-prefix>`.
|
||||
const combos: OmniRouteRawCombo[] = [
|
||||
{
|
||||
id: "uuid-a",
|
||||
name: "Claude",
|
||||
models: [{ id: "s", kind: "model", model: "claude-primary", weight: 1 }],
|
||||
},
|
||||
{
|
||||
id: "uuid-b",
|
||||
name: "Claude",
|
||||
models: [{ id: "s", kind: "model", model: "claude-secondary", weight: 1 }],
|
||||
},
|
||||
];
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{
|
||||
fetcher: stubModelsFetcher([MODEL_PRIMARY, MODEL_SECONDARY]),
|
||||
combosFetcher: stubCombosFetcher(combos),
|
||||
}
|
||||
);
|
||||
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
// First combo gets the bare slug; second gets disambiguated.
|
||||
assert.ok(out["opencode-omniroute/claude"], "first combo at prefixed slug");
|
||||
assert.ok(out["opencode-omniroute/claude-uuid"], "second combo disambiguated by id prefix");
|
||||
});
|
||||
|
||||
test("models(): combos fetch fails → falls back to models-only, warn emitted, no throw", async () => {
|
||||
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY, MODEL_SECONDARY]);
|
||||
const combosFetcher = failingCombosFetcher(new Error("ECONNRESET"));
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
|
||||
const { result: out, warnings } = await withWarnCapture(async () => {
|
||||
return hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
});
|
||||
|
||||
// Catalog includes the models but NOT any combo entries.
|
||||
assert.equal(Object.keys(out).length, 2);
|
||||
assert.ok(out["opencode-omniroute/claude-primary"]);
|
||||
assert.ok(out["opencode-omniroute/claude-secondary"]);
|
||||
|
||||
// Soft-fail warning surfaced.
|
||||
const softFail = warnings.find((w) => {
|
||||
const msg = w.args[0];
|
||||
return typeof msg === "string" && msg.includes("combos fetch failed");
|
||||
});
|
||||
assert.ok(softFail, "soft-fail warning must be emitted on combos fetch error");
|
||||
assert.equal(combosFetcher.callCount(), 1);
|
||||
});
|
||||
|
||||
test("models(): combos cached + reused within TTL (one combo fetch per TTL window)", async () => {
|
||||
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY, MODEL_SECONDARY]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
let nowMs = 1_000_000;
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1", modelCacheTtl: 60_000 },
|
||||
{ fetcher: modelsFetcher, combosFetcher, now: () => nowMs }
|
||||
);
|
||||
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
nowMs += 30_000; // half the TTL
|
||||
const second = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.equal(combosFetcher.callCount(), 1, "combos fetched only once within TTL");
|
||||
assert.equal(modelsFetcher.callCount(), 1, "models fetched only once within TTL");
|
||||
assert.ok(second["opencode-omniroute/claude-tier"]);
|
||||
});
|
||||
|
||||
test("models(): combos refetched after TTL expiry (same key as models)", async () => {
|
||||
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
let nowMs = 1_000_000;
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1", modelCacheTtl: 60_000 },
|
||||
{ fetcher: modelsFetcher, combosFetcher, now: () => nowMs }
|
||||
);
|
||||
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
nowMs += 60_001;
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.equal(combosFetcher.callCount(), 2, "combos must refetch past TTL");
|
||||
assert.equal(modelsFetcher.callCount(), 2, "models must refetch past TTL");
|
||||
});
|
||||
|
||||
test("models(): combos fetcher receives the resolved baseURL + apiKey", async () => {
|
||||
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-spy") as never });
|
||||
assert.deepEqual(combosFetcher.callsBy()[0], ["https://or.example.com/v1", "sk-spy"]);
|
||||
});
|
||||
|
||||
test("models(): nested combo-ref context is the min of nested + raw members", async () => {
|
||||
// Top-level combo MASTER-LIGHT has 1 raw model (claude-primary, 200k)
|
||||
// and 2 combo-refs: OldLLM (8k member) and KIRO (32k member). The OLD
|
||||
// plugin would advertise 200k (only the raw model); the fix should
|
||||
// make it advertise 8k (the bottleneck across the member graph).
|
||||
const modelsFetcher = stubModelsFetcher([
|
||||
MODEL_PRIMARY,
|
||||
{
|
||||
id: "oldllm-member-1",
|
||||
context_length: 8_000,
|
||||
max_output_tokens: 4_000,
|
||||
capabilities: {
|
||||
tool_calling: false,
|
||||
reasoning: false,
|
||||
vision: false,
|
||||
thinking: false,
|
||||
temperature: true,
|
||||
},
|
||||
input_modalities: ["text"],
|
||||
output_modalities: ["text"],
|
||||
},
|
||||
{
|
||||
id: "kiro-member-1",
|
||||
context_length: 32_000,
|
||||
max_output_tokens: 8_000,
|
||||
capabilities: {
|
||||
tool_calling: true,
|
||||
reasoning: false,
|
||||
vision: false,
|
||||
thinking: false,
|
||||
temperature: true,
|
||||
},
|
||||
input_modalities: ["text"],
|
||||
output_modalities: ["text"],
|
||||
},
|
||||
]);
|
||||
const combosFetcher = stubCombosFetcher([
|
||||
{
|
||||
id: "oldllm",
|
||||
name: "OldLLM",
|
||||
models: [{ id: "s1", kind: "model", model: "oldllm-member-1", weight: 100 }],
|
||||
},
|
||||
{
|
||||
id: "kiro",
|
||||
name: "KIRO",
|
||||
models: [{ id: "s1", kind: "model", model: "kiro-member-1", weight: 100 }],
|
||||
},
|
||||
{
|
||||
id: "master-light",
|
||||
name: "MASTER-LIGHT",
|
||||
models: [
|
||||
{ id: "r1", kind: "model", model: "claude-primary", weight: 50 },
|
||||
{ id: "r2", kind: "combo-ref", comboName: "OldLLM", weight: 25 },
|
||||
{ id: "r3", kind: "combo-ref", comboName: "KIRO", weight: 25 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
const masterLight = out["opencode-omniroute/master-light"];
|
||||
assert.ok(masterLight, "MASTER-LIGHT entry must exist");
|
||||
assert.equal(
|
||||
masterLight.limit.context,
|
||||
8_000,
|
||||
`expected 8_000 (OldLLM bottleneck), got ${masterLight.limit.context}`
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Regression test for the disk-snapshot file permissions (release/v3.8.2
|
||||
* review finding C2). The snapshot embeds provider topology + connection
|
||||
* records and lives alongside auth.json (0o600), so it must NOT be readable by
|
||||
* group/other. Before the fix it was written with the default (typically
|
||||
* world-readable 0o644) mode.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
defaultDiskSnapshotWriter,
|
||||
diskSnapshotPath,
|
||||
type OmniRouteFetchCacheEntry,
|
||||
} from "../src/index.js";
|
||||
|
||||
function makeEntry(): Omit<OmniRouteFetchCacheEntry, "expiresAt"> {
|
||||
return {
|
||||
rawModels: [],
|
||||
rawCombos: [],
|
||||
rawEnrichment: new Map(),
|
||||
rawCompressionCombos: [],
|
||||
rawConnections: [],
|
||||
};
|
||||
}
|
||||
|
||||
test("defaultDiskSnapshotWriter writes an owner-only (no group/other) snapshot", async (t) => {
|
||||
// POSIX-only assertion; Windows does not honor numeric file modes.
|
||||
if (process.platform === "win32") {
|
||||
t.skip("file mode semantics are POSIX-only");
|
||||
return;
|
||||
}
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-disk-perms-"));
|
||||
const prevDataDir = process.env.OPENCODE_DATA_DIR;
|
||||
process.env.OPENCODE_DATA_DIR = tmp;
|
||||
|
||||
try {
|
||||
await defaultDiskSnapshotWriter("perm-test", makeEntry());
|
||||
|
||||
const file = diskSnapshotPath("perm-test");
|
||||
assert.ok(fs.existsSync(file), "snapshot file should be written");
|
||||
|
||||
const fileMode = fs.statSync(file).mode & 0o777;
|
||||
assert.equal(
|
||||
fileMode & 0o077,
|
||||
0,
|
||||
`snapshot must not be group/other accessible (got ${fileMode.toString(8)})`
|
||||
);
|
||||
|
||||
const dirMode = fs.statSync(path.dirname(file)).mode & 0o777;
|
||||
assert.equal(
|
||||
dirMode & 0o077,
|
||||
0,
|
||||
`plugins dir must not be group/other accessible (got ${dirMode.toString(8)})`
|
||||
);
|
||||
} finally {
|
||||
if (prevDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = prevDataDir;
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* T-04 fetch-interceptor contract tests.
|
||||
*
|
||||
* Covers `createOmniRouteFetchInterceptor` (URL-prefix gating, header merge,
|
||||
* Content-Type defaulting, input-shape polymorphism) plus the loader
|
||||
* integration that wires it into the AuthHook return shape.
|
||||
*
|
||||
* Strategy: replace `globalThis.fetch` with a closure-based recorder for the
|
||||
* duration of each test (saved-and-restored in try/finally — node:test has
|
||||
* no built-in spy/restore lifecycle). The recorder captures `(input, init)`
|
||||
* as observed by the wrapped global call so we can assert on what was
|
||||
* forwarded after header injection.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createOmniRouteAuthHook, createOmniRouteFetchInterceptor } from "../src/index.js";
|
||||
|
||||
type FetchCall = { input: Parameters<typeof fetch>[0]; init?: RequestInit };
|
||||
|
||||
function installFetchRecorder(response: Response = new Response("ok")) {
|
||||
const calls: FetchCall[] = [];
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: any, init?: any) => {
|
||||
calls.push({ input, init });
|
||||
return response;
|
||||
}) as typeof fetch;
|
||||
const restore = () => {
|
||||
globalThis.fetch = original;
|
||||
};
|
||||
return { calls, restore };
|
||||
}
|
||||
|
||||
const BASE = "https://or.example.com/v1";
|
||||
const KEY = "sk-test-fetch";
|
||||
|
||||
test("createOmniRouteFetchInterceptor: targets baseURL → Authorization header injected", async () => {
|
||||
const { calls, restore } = installFetchRecorder();
|
||||
try {
|
||||
const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE });
|
||||
await f(`${BASE}/chat/completions`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ x: 1 }),
|
||||
});
|
||||
assert.equal(calls.length, 1);
|
||||
const sent = calls[0]!;
|
||||
const sentHeaders = new Headers((sent.init as RequestInit).headers);
|
||||
assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("createOmniRouteFetchInterceptor: targets baseURL → Authorization OVERRIDES caller-supplied Bearer", async () => {
|
||||
const { calls, restore } = installFetchRecorder();
|
||||
try {
|
||||
const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE });
|
||||
await f(`${BASE}/chat/completions`, {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
headers: { Authorization: "Bearer attacker-key" },
|
||||
});
|
||||
const sent = calls[0]!;
|
||||
const sentHeaders = new Headers((sent.init as RequestInit).headers);
|
||||
// We own the apiKey for this provider — caller-supplied Bearer must lose.
|
||||
assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("createOmniRouteFetchInterceptor: targets baseURL + body → Content-Type defaults to application/json", async () => {
|
||||
const { calls, restore } = installFetchRecorder();
|
||||
try {
|
||||
const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE });
|
||||
await f(`${BASE}/chat/completions`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ m: "x" }),
|
||||
});
|
||||
const sent = calls[0]!;
|
||||
const sentHeaders = new Headers((sent.init as RequestInit).headers);
|
||||
assert.equal(sentHeaders.get("Content-Type"), "application/json");
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("createOmniRouteFetchInterceptor: caller-set Content-Type is NOT overwritten", async () => {
|
||||
const { calls, restore } = installFetchRecorder();
|
||||
try {
|
||||
const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE });
|
||||
await f(`${BASE}/v2/whatever`, {
|
||||
method: "POST",
|
||||
body: "raw",
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
});
|
||||
const sent = calls[0]!;
|
||||
const sentHeaders = new Headers((sent.init as RequestInit).headers);
|
||||
assert.equal(sentHeaders.get("Content-Type"), "text/plain; charset=utf-8");
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("createOmniRouteFetchInterceptor: non-baseURL host → passthrough, no Authorization injected", async () => {
|
||||
const { calls, restore } = installFetchRecorder();
|
||||
try {
|
||||
const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE });
|
||||
await f("https://third-party.example.org/v1/chat", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
headers: { "X-Caller": "yes" },
|
||||
});
|
||||
const sent = calls[0]!;
|
||||
// Init forwarded verbatim — no header injection.
|
||||
const sentHeaders = new Headers((sent.init as RequestInit | undefined)?.headers);
|
||||
assert.equal(sentHeaders.get("Authorization"), null, "MUST NOT leak apiKey");
|
||||
assert.equal(sentHeaders.get("X-Caller"), "yes");
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("createOmniRouteFetchInterceptor: refuses suffix-spoof — `${base}-attacker.evil` does NOT match baseURL", async () => {
|
||||
const { calls, restore } = installFetchRecorder();
|
||||
try {
|
||||
const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE });
|
||||
// baseURL is `https://or.example.com/v1`. A spoofed
|
||||
// `https://or.example.com/v1-attacker.evil/chat` shares the literal prefix
|
||||
// but is NOT under our origin path — must be treated as passthrough.
|
||||
await f("https://or.example.com/v1-attacker.evil/chat", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
});
|
||||
const sent = calls[0]!;
|
||||
const sentHeaders = new Headers((sent.init as RequestInit | undefined)?.headers);
|
||||
assert.equal(sentHeaders.get("Authorization"), null);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("createOmniRouteFetchInterceptor: URL object input is handled", async () => {
|
||||
const { calls, restore } = installFetchRecorder();
|
||||
try {
|
||||
const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE });
|
||||
await f(new URL(`${BASE}/models`), {});
|
||||
const sent = calls[0]!;
|
||||
const sentHeaders = new Headers((sent.init as RequestInit).headers);
|
||||
assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("createOmniRouteFetchInterceptor: Request input is handled (reads .url)", async () => {
|
||||
const { calls, restore } = installFetchRecorder();
|
||||
try {
|
||||
const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE });
|
||||
const req = new Request(`${BASE}/chat/completions`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ a: 1 }),
|
||||
headers: { "X-Caller": "preserved" },
|
||||
});
|
||||
await f(req);
|
||||
const sent = calls[0]!;
|
||||
// The interceptor forwards the original Request as `input` but layers our
|
||||
// headers into the `init`. We assert against the init view since fetch()
|
||||
// resolves headers from init first when both are present.
|
||||
const sentHeaders = new Headers((sent.init as RequestInit).headers);
|
||||
assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`);
|
||||
assert.equal(
|
||||
sentHeaders.get("X-Caller"),
|
||||
"preserved",
|
||||
"Request-attached headers must survive the merge"
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("createOmniRouteFetchInterceptor: trailing slash in baseURL is normalized", async () => {
|
||||
const { calls, restore } = installFetchRecorder();
|
||||
try {
|
||||
const f = createOmniRouteFetchInterceptor({
|
||||
apiKey: KEY,
|
||||
baseURL: `${BASE}////`,
|
||||
});
|
||||
await f(`${BASE}/models`, {});
|
||||
const sent = calls[0]!;
|
||||
const sentHeaders = new Headers((sent.init as RequestInit).headers);
|
||||
assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("createOmniRouteFetchInterceptor: GET without body does NOT set Content-Type", async () => {
|
||||
const { calls, restore } = installFetchRecorder();
|
||||
try {
|
||||
const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE });
|
||||
await f(`${BASE}/models`); // no init at all
|
||||
const sent = calls[0]!;
|
||||
const sentHeaders = new Headers((sent.init as RequestInit).headers);
|
||||
assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`);
|
||||
assert.equal(
|
||||
sentHeaders.get("Content-Type"),
|
||||
null,
|
||||
"Content-Type should only default when a body exists"
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// loader integration
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
test("loader: returns fetch fn when apiKey + baseURL both present (via opts)", async () => {
|
||||
const hook = createOmniRouteAuthHook({ baseURL: BASE });
|
||||
const result = await hook.loader!(async () => ({ type: "api", key: KEY }) as never, {} as never);
|
||||
assert.equal((result as { apiKey: string }).apiKey, KEY);
|
||||
assert.equal((result as { baseURL: string }).baseURL, BASE);
|
||||
assert.equal(
|
||||
typeof (result as { fetch?: unknown }).fetch,
|
||||
"function",
|
||||
"loader must wire fetch interceptor when baseURL resolves"
|
||||
);
|
||||
});
|
||||
|
||||
test("loader: returns fetch fn when baseURL is stashed on the auth credential", async () => {
|
||||
// Some auth backends attach baseURL alongside the key (post-/connect flow).
|
||||
// The loader should pick it up even when plugin opts.baseURL is unset.
|
||||
const hook = createOmniRouteAuthHook();
|
||||
const result = await hook.loader!(
|
||||
async () => ({ type: "api", key: KEY, baseURL: BASE }) as never,
|
||||
{} as never
|
||||
);
|
||||
assert.equal((result as { baseURL?: string }).baseURL, BASE);
|
||||
assert.equal(typeof (result as { fetch?: unknown }).fetch, "function");
|
||||
});
|
||||
|
||||
test("loader: omits fetch fn when baseURL missing (apiKey-only return)", async () => {
|
||||
const hook = createOmniRouteAuthHook(); // no baseURL opt
|
||||
const result = await hook.loader!(async () => ({ type: "api", key: KEY }) as never, {} as never);
|
||||
// Interceptor needs a baseURL to gate-keep; without one, fall back to
|
||||
// apiKey-only and let the SDK use its default fetch.
|
||||
assert.deepEqual(result, { apiKey: KEY });
|
||||
});
|
||||
|
||||
test("loader integration: wired interceptor actually injects Bearer when invoked", async () => {
|
||||
// End-to-end: pull the fetch fn out of the loader return and exercise it,
|
||||
// proving the wiring matches the standalone interceptor's contract.
|
||||
const { calls, restore } = installFetchRecorder();
|
||||
try {
|
||||
const hook = createOmniRouteAuthHook({ baseURL: BASE });
|
||||
const result = await hook.loader!(
|
||||
async () => ({ type: "api", key: KEY }) as never,
|
||||
{} as never
|
||||
);
|
||||
const wiredFetch = (result as { fetch: typeof fetch }).fetch;
|
||||
await wiredFetch(`${BASE}/v1/models`, {});
|
||||
assert.equal(calls.length, 1);
|
||||
const sentHeaders = new Headers((calls[0]!.init as RequestInit).headers);
|
||||
assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Tests for the 3 mrmm-fork features backported to @omniroute/opencode-plugin:
|
||||
*
|
||||
* 1. `normaliseFreeLabel` — free-tier model display names get a consistent
|
||||
* `[Free] ` prefix instead of trailing "(Free)" or ad-hoc "free" words.
|
||||
*
|
||||
* 2. `resolveApiBlock` — per-provider-prefix API format routing. Anthropic
|
||||
* prefixes (`cc/`, `claude/`, `anthropic/`, `kiro/`, `kr/`) get the
|
||||
* Anthropic SDK block; everything else gets OpenAI-compat.
|
||||
*
|
||||
* 3. `debugLog` — JSONL request/response capture, gated by
|
||||
* `features.debugLog` and togglable at runtime via
|
||||
* `debugLogEnabled/SetEnabled`.
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
normaliseFreeLabel,
|
||||
resolveApiBlock,
|
||||
DEFAULT_ANTHROPIC_PREFIXES,
|
||||
ensureV1Suffix,
|
||||
debugLogEnabled,
|
||||
debugLogSetEnabled,
|
||||
debugLogClear,
|
||||
debugLogRead,
|
||||
debugLogAppend,
|
||||
createDebugLoggingFetch,
|
||||
DebugLogEntry,
|
||||
} from "../src/index.js";
|
||||
|
||||
// ── 1. normaliseFreeLabel ────────────────────────────────────────────────────
|
||||
|
||||
test("normaliseFreeLabel: '(Free)' suffix becomes [Free] prefix", () => {
|
||||
assert.equal(normaliseFreeLabel("GPT-4.1 (Free)"), "[Free] GPT-4.1");
|
||||
});
|
||||
|
||||
test("normaliseFreeLabel: trailing ' Free' word becomes [Free] prefix", () => {
|
||||
assert.equal(
|
||||
normaliseFreeLabel("DeepSeek V4 Flash Free"),
|
||||
"[Free] DeepSeek V4 Flash"
|
||||
);
|
||||
});
|
||||
|
||||
test("normaliseFreeLabel: trailing '-free' (hyphen) becomes [Free] prefix", () => {
|
||||
assert.equal(normaliseFreeLabel("Llama 4 Scout-free"), "[Free] Llama 4 Scout");
|
||||
});
|
||||
|
||||
test("normaliseFreeLabel: case-insensitive (FREE, Free, free all match)", () => {
|
||||
assert.equal(normaliseFreeLabel("Model A FREE"), "[Free] Model A");
|
||||
assert.equal(normaliseFreeLabel("Model A free"), "[Free] Model A");
|
||||
assert.equal(normaliseFreeLabel("Model A Free"), "[Free] Model A");
|
||||
});
|
||||
|
||||
test("normaliseFreeLabel: names without 'free' pass through unchanged", () => {
|
||||
assert.equal(normaliseFreeLabel("Claude 4.7 Opus"), "Claude 4.7 Opus");
|
||||
assert.equal(normaliseFreeLabel("GPT-5"), "GPT-5");
|
||||
});
|
||||
|
||||
test("normaliseFreeLabel: 'free' in the middle of a name is NOT rewritten", () => {
|
||||
// Only trailing/standalone "free" markers count; embedded "freedom" stays
|
||||
assert.equal(
|
||||
normaliseFreeLabel("Freedom Model"),
|
||||
"Freedom Model"
|
||||
);
|
||||
});
|
||||
|
||||
test("normaliseFreeLabel: empty / whitespace-only inputs are handled", () => {
|
||||
// Empty input returns empty; pure whitespace input passes through (no Free marker)
|
||||
assert.equal(normaliseFreeLabel(""), "");
|
||||
assert.equal(normaliseFreeLabel(" "), " ");
|
||||
});
|
||||
|
||||
// ── 2. resolveApiBlock ───────────────────────────────────────────────────────
|
||||
|
||||
test("resolveApiBlock: cc/* models get the Anthropic SDK block (no /v1)", () => {
|
||||
const block = resolveApiBlock("cc/claude-opus-4-7", "https://api.example.com");
|
||||
assert.equal(block.id, "anthropic");
|
||||
assert.equal(block.npm, "@ai-sdk/anthropic");
|
||||
assert.equal(block.url, "https://api.example.com"); // NO /v1 suffix
|
||||
});
|
||||
|
||||
test("resolveApiBlock: claude/*, anthropic/*, kiro/*, kr/* all route to Anthropic", () => {
|
||||
for (const id of [
|
||||
"claude/claude-opus-4-7",
|
||||
"anthropic/claude-sonnet-4",
|
||||
"kiro/claude-sonnet-4-5",
|
||||
"kr/claude-opus-4-6",
|
||||
]) {
|
||||
const block = resolveApiBlock(id, "https://api.example.com");
|
||||
assert.equal(block.id, "anthropic", `${id} should route to Anthropic`);
|
||||
assert.equal(block.npm, "@ai-sdk/anthropic");
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveApiBlock: non-Anthropic models get OpenAI-compat with /v1", () => {
|
||||
const block = resolveApiBlock("gpt-4o", "https://api.example.com");
|
||||
assert.equal(block.id, "openai-compatible");
|
||||
assert.equal(block.npm, "@ai-sdk/openai-compatible");
|
||||
assert.equal(block.url, "https://api.example.com/v1");
|
||||
});
|
||||
|
||||
test("resolveApiBlock: user can override anthropicPrefixes to add custom prefixes", () => {
|
||||
const block = resolveApiBlock("myproxy/claude-opus", "https://api.example.com", {
|
||||
anthropicPrefixes: ["myproxy"],
|
||||
});
|
||||
assert.equal(block.id, "anthropic");
|
||||
assert.equal(block.npm, "@ai-sdk/anthropic");
|
||||
});
|
||||
|
||||
test("resolveApiBlock: empty anthropicPrefixes forces OpenAI-compat for everything", () => {
|
||||
const block = resolveApiBlock("cc/claude-opus", "https://api.example.com", {
|
||||
anthropicPrefixes: [],
|
||||
});
|
||||
assert.equal(block.id, "openai-compatible");
|
||||
});
|
||||
|
||||
test("resolveApiBlock: baseURL that already ends in /v1 is not double-suffixed (OpenAI path)", () => {
|
||||
const block = resolveApiBlock("gpt-4o", "https://api.example.com/v1");
|
||||
assert.equal(block.url, "https://api.example.com/v1"); // idempotent
|
||||
});
|
||||
|
||||
test("resolveApiBlock: model id without '/' uses the id as prefix", () => {
|
||||
const block = resolveApiBlock("claude-opus-4-7", "https://api.example.com");
|
||||
// The whole id is the prefix, which doesn't match "cc"/"claude" etc.
|
||||
// So it falls through to OpenAI-compat.
|
||||
assert.equal(block.id, "openai-compatible");
|
||||
});
|
||||
|
||||
test("DEFAULT_ANTHROPIC_PREFIXES: contains the canonical Anthropic aliases", () => {
|
||||
assert.deepEqual(DEFAULT_ANTHROPIC_PREFIXES, [
|
||||
"cc",
|
||||
"claude",
|
||||
"anthropic",
|
||||
"kiro",
|
||||
"kr",
|
||||
]);
|
||||
});
|
||||
|
||||
test("ensureV1Suffix: idempotent for URLs that already end in /v1", () => {
|
||||
assert.equal(ensureV1Suffix("https://api.example.com/v1"), "https://api.example.com/v1");
|
||||
assert.equal(
|
||||
ensureV1Suffix("https://api.example.com/v1/"),
|
||||
"https://api.example.com/v1" // trailing slash is stripped
|
||||
);
|
||||
});
|
||||
|
||||
test("ensureV1Suffix: appends /v1 when missing", () => {
|
||||
assert.equal(ensureV1Suffix("https://api.example.com"), "https://api.example.com/v1");
|
||||
assert.equal(ensureV1Suffix("https://api.example.com/"), "https://api.example.com/v1");
|
||||
});
|
||||
|
||||
// ── 3. debugLog ──────────────────────────────────────────────────────────────
|
||||
|
||||
test("debugLog: default state is disabled", () => {
|
||||
debugLogClear("test-provider-disabled-default");
|
||||
assert.equal(debugLogEnabled("test-provider-disabled-default"), false);
|
||||
});
|
||||
|
||||
test("debugLogSetEnabled + debugLogEnabled: roundtrip", () => {
|
||||
debugLogSetEnabled("test-provider-toggle", true);
|
||||
assert.equal(debugLogEnabled("test-provider-toggle"), true);
|
||||
debugLogSetEnabled("test-provider-toggle", false);
|
||||
assert.equal(debugLogEnabled("test-provider-toggle"), false);
|
||||
});
|
||||
|
||||
test("debugLogAppend + debugLogRead: roundtrip preserves entry shape", () => {
|
||||
const providerId = "test-provider-readroundtrip";
|
||||
debugLogClear(providerId);
|
||||
const entry: DebugLogEntry = {
|
||||
reqId: "req-1",
|
||||
providerId,
|
||||
ts: 1700000000000,
|
||||
url: "https://api.example.com/v1/chat",
|
||||
method: "POST",
|
||||
reqHeaders: { "content-type": "application/json" },
|
||||
reqBody: { model: "gpt-4o", messages: [] },
|
||||
resStatus: 200,
|
||||
resHeaders: { "content-type": "application/json" },
|
||||
resBody: { choices: [] },
|
||||
durationMs: 42,
|
||||
};
|
||||
debugLogAppend(entry);
|
||||
const read = debugLogRead(providerId, 10);
|
||||
assert.equal(read.length, 1);
|
||||
assert.deepEqual(read[0], entry);
|
||||
});
|
||||
|
||||
test("createDebugLoggingFetch: passes through when disabled", async () => {
|
||||
const providerId = "test-provider-passthrough";
|
||||
debugLogClear(providerId);
|
||||
debugLogSetEnabled(providerId, false);
|
||||
const calls: unknown[] = [];
|
||||
const inner: typeof fetch = async (input) => {
|
||||
calls.push(input);
|
||||
return new Response("ok", { status: 200 });
|
||||
};
|
||||
const wrapped = createDebugLoggingFetch(inner, providerId, false);
|
||||
const res = await wrapped("https://api.example.com/v1/chat");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(calls.length, 1);
|
||||
// No log entry should be written when disabled
|
||||
assert.equal(debugLogRead(providerId).length, 0);
|
||||
});
|
||||
|
||||
test("createDebugLoggingFetch: captures request/response when enabled", async () => {
|
||||
const providerId = "test-provider-captures";
|
||||
debugLogClear(providerId);
|
||||
const inner: typeof fetch = async () =>
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const wrapped = createDebugLoggingFetch(inner, providerId, true);
|
||||
const res = await wrapped("https://api.example.com/v1/chat", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ model: "gpt-4o" }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const entries = debugLogRead(providerId);
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0].method, "POST");
|
||||
assert.equal(entries[0].resStatus, 200);
|
||||
assert.equal(entries[0].url, "https://api.example.com/v1/chat");
|
||||
assert.deepEqual(entries[0].reqBody, { model: "gpt-4o" });
|
||||
});
|
||||
|
||||
test("createDebugLoggingFetch: records error without crashing the wrapped fetch", async () => {
|
||||
const providerId = "test-provider-error";
|
||||
debugLogClear(providerId);
|
||||
const inner: typeof fetch = async () => {
|
||||
throw new Error("network down");
|
||||
};
|
||||
const wrapped = createDebugLoggingFetch(inner, providerId, true);
|
||||
await assert.rejects(wrapped("https://api.example.com/v1/chat"), /network down/);
|
||||
const entries = debugLogRead(providerId);
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0].resStatus, null);
|
||||
assert.equal(entries[0].error, "network down");
|
||||
});
|
||||
|
||||
// ── Regression tests for the 3 HIGH-priority bot review fixes ───────────────
|
||||
|
||||
test("createDebugLoggingFetch: URL instance input is captured (not 'undefined')", async () => {
|
||||
const providerId = "test-provider-url-input";
|
||||
debugLogClear(providerId);
|
||||
const inner: typeof fetch = async () =>
|
||||
new Response("ok", { status: 200 });
|
||||
const wrapped = createDebugLoggingFetch(inner, providerId, true);
|
||||
await wrapped(new URL("https://api.example.com/v1/chat"));
|
||||
const entries = debugLogRead(providerId);
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0].url, "https://api.example.com/v1/chat");
|
||||
assert.notEqual(entries[0].url, undefined);
|
||||
});
|
||||
|
||||
test("createDebugLoggingFetch: Request object input captures URL and headers", async () => {
|
||||
const providerId = "test-provider-request-input";
|
||||
debugLogClear(providerId);
|
||||
const inner: typeof fetch = async () =>
|
||||
new Response("ok", { status: 200 });
|
||||
const wrapped = createDebugLoggingFetch(inner, providerId, true);
|
||||
const req = new Request("https://api.example.com/v1/chat", {
|
||||
method: "POST",
|
||||
headers: { "x-test": "yes" },
|
||||
});
|
||||
await wrapped(req);
|
||||
const entries = debugLogRead(providerId);
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0].url, "https://api.example.com/v1/chat");
|
||||
assert.equal(entries[0].reqHeaders["x-test"], "yes");
|
||||
});
|
||||
|
||||
test("createDebugLoggingFetch: SSE response is NOT buffered (resBody is the stream marker)", async () => {
|
||||
const providerId = "test-provider-sse";
|
||||
debugLogClear(providerId);
|
||||
const inner: typeof fetch = async () =>
|
||||
new Response("data: hello\n\n", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
const wrapped = createDebugLoggingFetch(inner, providerId, true);
|
||||
const res = await wrapped("https://api.example.com/v1/stream");
|
||||
// The response body must remain readable downstream
|
||||
const txt = await res.text();
|
||||
assert.equal(txt, "data: hello\n\n");
|
||||
const entries = debugLogRead(providerId);
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0].resBody, "[stream]", "SSE responses must not be buffered");
|
||||
});
|
||||
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* T-06 Gemini tool-schema sanitisation contract tests.
|
||||
*
|
||||
* Three layers under test:
|
||||
* 1. `sanitizeGeminiToolSchemas` — pure function; key stripping + clone
|
||||
* semantics on chat-completion + Responses-API shapes.
|
||||
* 2. `shouldSanitizeForGemini` — model-string detection (liberal).
|
||||
* 3. `createGeminiSanitizingFetch` — wrapper composition; URL gating,
|
||||
* body-shape polymorphism, streaming-body bypass, fail-open behaviour,
|
||||
* composition with the T-04 Bearer interceptor.
|
||||
*
|
||||
* Strategy: same posture as fetch-interceptor.test.ts — install a
|
||||
* closure-based fetch recorder; assert on the `(input, init)` observed by
|
||||
* the inner fetch after the sanitising wrapper has had its say.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
__resetGeminiStreamingWarning,
|
||||
createGeminiSanitizingFetch,
|
||||
createOmniRouteFetchInterceptor,
|
||||
sanitizeGeminiToolSchemas,
|
||||
shouldSanitizeForGemini,
|
||||
} from "../src/index.js";
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type FetchCall = { input: Parameters<typeof fetch>[0]; init?: RequestInit };
|
||||
|
||||
function recorder(response: Response = new Response("ok")): {
|
||||
fn: typeof fetch;
|
||||
calls: FetchCall[];
|
||||
} {
|
||||
const calls: FetchCall[] = [];
|
||||
const fn = (async (input: any, init?: any) => {
|
||||
calls.push({ input, init });
|
||||
return response;
|
||||
}) as typeof fetch;
|
||||
return { fn, calls };
|
||||
}
|
||||
|
||||
function bodyAsRecord(init: RequestInit | undefined): Record<string, unknown> {
|
||||
const b = init?.body;
|
||||
if (typeof b !== "string") {
|
||||
throw new Error(`expected string body, got ${typeof b}`);
|
||||
}
|
||||
return JSON.parse(b) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Sample tool payloads — small enough to inline, big enough to cover
|
||||
// chat-completion + Responses-API + nested properties.
|
||||
|
||||
function chatCompletionsWithDollarSchema(): Record<string, unknown> {
|
||||
return {
|
||||
model: "gemini-2.5-pro",
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "search",
|
||||
parameters: {
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
q: { type: "string" },
|
||||
},
|
||||
required: ["q"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function responsesApiWithRef(): Record<string, unknown> {
|
||||
return {
|
||||
model: "gemini-2.5-flash",
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
name: "lookup",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
$ref: "#/definitions/Lookup",
|
||||
properties: {
|
||||
id: { type: "string", ref: "Id" },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function nestedPropertiesPayload(): Record<string, unknown> {
|
||||
return {
|
||||
model: "gemini-pro",
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "deep",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
outer: {
|
||||
type: "object",
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
properties: {
|
||||
inner: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
$ref: "#/inner",
|
||||
properties: {
|
||||
leaf: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// sanitizeGeminiToolSchemas — pure function
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("sanitizeGeminiToolSchemas: strips $schema from top-level", () => {
|
||||
const input = {
|
||||
model: "gemini-2.5-pro",
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
tools: [],
|
||||
};
|
||||
const out = sanitizeGeminiToolSchemas(input) as Record<string, unknown>;
|
||||
assert.equal(out.$schema, undefined);
|
||||
assert.equal(out.model, "gemini-2.5-pro");
|
||||
});
|
||||
|
||||
test("sanitizeGeminiToolSchemas: strips $ref + additionalProperties from tools[].function.parameters", () => {
|
||||
const input = chatCompletionsWithDollarSchema();
|
||||
const out = sanitizeGeminiToolSchemas(input) as Record<string, unknown>;
|
||||
const params = (out.tools as Array<{ function: { parameters: Record<string, unknown> } }>)[0]!
|
||||
.function.parameters;
|
||||
assert.equal(params.$schema, undefined);
|
||||
assert.equal(params.additionalProperties, undefined);
|
||||
// Untouched keys survive.
|
||||
assert.equal(params.type, "object");
|
||||
assert.deepEqual(params.required, ["q"]);
|
||||
});
|
||||
|
||||
test("sanitizeGeminiToolSchemas: strips nested $schema from properties.x.properties.y", () => {
|
||||
const input = nestedPropertiesPayload();
|
||||
const out = sanitizeGeminiToolSchemas(input) as Record<string, unknown>;
|
||||
const params = (out.tools as Array<{ function: { parameters: Record<string, unknown> } }>)[0]!
|
||||
.function.parameters;
|
||||
const outer = (params.properties as Record<string, Record<string, unknown>>).outer!;
|
||||
const inner = (outer.properties as Record<string, Record<string, unknown>>).inner!;
|
||||
assert.equal(outer.$schema, undefined);
|
||||
assert.equal(inner.$ref, undefined);
|
||||
assert.equal(inner.additionalProperties, undefined);
|
||||
// Leaf still intact.
|
||||
assert.deepEqual(inner.properties, { leaf: { type: "string" } });
|
||||
});
|
||||
|
||||
test("sanitizeGeminiToolSchemas: handles Responses-API tools[].input_schema shape", () => {
|
||||
const input = responsesApiWithRef();
|
||||
const out = sanitizeGeminiToolSchemas(input) as Record<string, unknown>;
|
||||
const inputSchema = (out.tools as Array<{ input_schema: Record<string, unknown> }>)[0]!
|
||||
.input_schema;
|
||||
assert.equal(inputSchema.$ref, undefined);
|
||||
// Nested `ref` (lowercase) also stripped.
|
||||
const props = inputSchema.properties as Record<string, Record<string, unknown>>;
|
||||
assert.equal(props.id!.ref, undefined);
|
||||
assert.equal(props.id!.type, "string");
|
||||
});
|
||||
|
||||
test("sanitizeGeminiToolSchemas: leaves payload without tools untouched", () => {
|
||||
const input = { model: "gemini-2.5-pro", messages: [{ role: "user", content: "hi" }] };
|
||||
const out = sanitizeGeminiToolSchemas(input) as Record<string, unknown>;
|
||||
assert.deepEqual(out, input);
|
||||
});
|
||||
|
||||
test("sanitizeGeminiToolSchemas: does not mutate input (returned object is distinct)", () => {
|
||||
const input = chatCompletionsWithDollarSchema();
|
||||
const beforeJson = JSON.stringify(input);
|
||||
const out = sanitizeGeminiToolSchemas(input);
|
||||
// Input bit-identical to its pre-sanitise serialisation.
|
||||
assert.equal(JSON.stringify(input), beforeJson);
|
||||
// Output is a different reference.
|
||||
assert.notEqual(out, input);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// shouldSanitizeForGemini — detection
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("shouldSanitizeForGemini: gemini-2.5-pro → true", () => {
|
||||
assert.equal(shouldSanitizeForGemini({ model: "gemini-2.5-pro" }), true);
|
||||
});
|
||||
|
||||
test("shouldSanitizeForGemini: models/gemini-pro → true", () => {
|
||||
assert.equal(shouldSanitizeForGemini({ model: "models/gemini-pro" }), true);
|
||||
});
|
||||
|
||||
test("shouldSanitizeForGemini: google-vertex/gemini-1.5-flash → true", () => {
|
||||
assert.equal(shouldSanitizeForGemini({ model: "google-vertex/gemini-1.5-flash" }), true);
|
||||
});
|
||||
|
||||
test("shouldSanitizeForGemini: gemini/gemini-2.5-pro → true", () => {
|
||||
assert.equal(shouldSanitizeForGemini({ model: "gemini/gemini-2.5-pro" }), true);
|
||||
});
|
||||
|
||||
test("shouldSanitizeForGemini: claude-sonnet-4 → false", () => {
|
||||
assert.equal(shouldSanitizeForGemini({ model: "claude-sonnet-4" }), false);
|
||||
});
|
||||
|
||||
test("shouldSanitizeForGemini: payload.model missing → false", () => {
|
||||
assert.equal(shouldSanitizeForGemini({ messages: [] }), false);
|
||||
});
|
||||
|
||||
test("shouldSanitizeForGemini: payload is null → false", () => {
|
||||
assert.equal(shouldSanitizeForGemini(null), false);
|
||||
});
|
||||
|
||||
test("shouldSanitizeForGemini: payload.model is non-string → false", () => {
|
||||
assert.equal(shouldSanitizeForGemini({ model: 42 }), false);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// createGeminiSanitizingFetch — wrapper
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const URL_CHAT = "https://or.example.com/v1/chat/completions";
|
||||
const URL_RESPONSES = "https://or.example.com/v1/responses";
|
||||
const URL_MODELS = "https://or.example.com/v1/models";
|
||||
|
||||
test("createGeminiSanitizingFetch: gemini model + chat/completions → tool schemas stripped before forward", async () => {
|
||||
const rec = recorder();
|
||||
const wrapped = createGeminiSanitizingFetch(rec.fn);
|
||||
await wrapped(URL_CHAT, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(chatCompletionsWithDollarSchema()),
|
||||
});
|
||||
assert.equal(rec.calls.length, 1);
|
||||
const forwarded = bodyAsRecord(rec.calls[0]!.init);
|
||||
const params = (
|
||||
forwarded.tools as Array<{ function: { parameters: Record<string, unknown> } }>
|
||||
)[0]!.function.parameters;
|
||||
assert.equal(params.$schema, undefined);
|
||||
assert.equal(params.additionalProperties, undefined);
|
||||
});
|
||||
|
||||
test("createGeminiSanitizingFetch: non-gemini model + chat/completions → body passed through unchanged", async () => {
|
||||
const rec = recorder();
|
||||
const wrapped = createGeminiSanitizingFetch(rec.fn);
|
||||
const originalBody = JSON.stringify({
|
||||
model: "claude-sonnet-4",
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "x",
|
||||
parameters: { $schema: "keep-me", type: "object" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await wrapped(URL_CHAT, { method: "POST", body: originalBody });
|
||||
// Identity check on body — wrapper must NOT mutate non-Gemini payloads.
|
||||
assert.equal(rec.calls[0]!.init!.body, originalBody);
|
||||
});
|
||||
|
||||
test("createGeminiSanitizingFetch: gemini model + /v1/models (non-completion endpoint) → body passed through unchanged", async () => {
|
||||
const rec = recorder();
|
||||
const wrapped = createGeminiSanitizingFetch(rec.fn);
|
||||
// GET /v1/models has no body in production; assert that even if a caller
|
||||
// attached a Gemini-shaped body to a non-completion URL, the wrapper
|
||||
// doesn't touch it.
|
||||
const body = JSON.stringify(chatCompletionsWithDollarSchema());
|
||||
await wrapped(URL_MODELS, { method: "POST", body });
|
||||
assert.equal(rec.calls[0]!.init!.body, body);
|
||||
});
|
||||
|
||||
test("createGeminiSanitizingFetch: gemini model + /responses endpoint → input_schema stripped", async () => {
|
||||
const rec = recorder();
|
||||
const wrapped = createGeminiSanitizingFetch(rec.fn);
|
||||
await wrapped(URL_RESPONSES, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(responsesApiWithRef()),
|
||||
});
|
||||
const forwarded = bodyAsRecord(rec.calls[0]!.init);
|
||||
const schema = (forwarded.tools as Array<{ input_schema: Record<string, unknown> }>)[0]!
|
||||
.input_schema;
|
||||
assert.equal(schema.$ref, undefined);
|
||||
});
|
||||
|
||||
test("createGeminiSanitizingFetch: gemini model + Request input with body → tool schemas stripped", async () => {
|
||||
const rec = recorder();
|
||||
const wrapped = createGeminiSanitizingFetch(rec.fn);
|
||||
const req = new Request(URL_CHAT, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(chatCompletionsWithDollarSchema()),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
await wrapped(req);
|
||||
const forwarded = bodyAsRecord(rec.calls[0]!.init);
|
||||
const params = (
|
||||
forwarded.tools as Array<{ function: { parameters: Record<string, unknown> } }>
|
||||
)[0]!.function.parameters;
|
||||
assert.equal(params.$schema, undefined);
|
||||
});
|
||||
|
||||
test("createGeminiSanitizingFetch: gemini model + ReadableStream body → skipped + warn emitted once", async () => {
|
||||
__resetGeminiStreamingWarning();
|
||||
const rec = recorder();
|
||||
const wrapped = createGeminiSanitizingFetch(rec.fn);
|
||||
|
||||
// Capture console.warn for the duration of this test.
|
||||
const warnings: string[] = [];
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warnings.push(args.map(String).join(" "));
|
||||
};
|
||||
|
||||
try {
|
||||
const stream1 = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("{}"));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const stream2 = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("{}"));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
// Two streaming calls — only one warn expected.
|
||||
await wrapped(URL_CHAT, { method: "POST", body: stream1 });
|
||||
await wrapped(URL_CHAT, { method: "POST", body: stream2 });
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
|
||||
// Both calls forwarded to inner fetch with their streams intact.
|
||||
assert.equal(rec.calls.length, 2);
|
||||
// ONE warning total — one-shot latch held.
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0]!, /streaming Request body, skipping schema strip/);
|
||||
});
|
||||
|
||||
test("createGeminiSanitizingFetch: invalid JSON body → pass through, no throw", async () => {
|
||||
const rec = recorder();
|
||||
const wrapped = createGeminiSanitizingFetch(rec.fn);
|
||||
// Garbage body must not crash the wrapper.
|
||||
await wrapped(URL_CHAT, { method: "POST", body: "this is not json{{" });
|
||||
assert.equal(rec.calls.length, 1);
|
||||
assert.equal(rec.calls[0]!.init!.body, "this is not json{{");
|
||||
});
|
||||
|
||||
test("createGeminiSanitizingFetch: empty body → pass through unchanged", async () => {
|
||||
const rec = recorder();
|
||||
const wrapped = createGeminiSanitizingFetch(rec.fn);
|
||||
await wrapped(URL_CHAT, { method: "POST" });
|
||||
assert.equal(rec.calls.length, 1);
|
||||
});
|
||||
|
||||
test("createGeminiSanitizingFetch: composes correctly with createOmniRouteFetchInterceptor (Bearer + sanitization)", async () => {
|
||||
// Save and replace globalThis.fetch — the Bearer interceptor calls global
|
||||
// fetch when the URL targets its baseURL.
|
||||
const originalFetch = globalThis.fetch;
|
||||
const observed: FetchCall[] = [];
|
||||
globalThis.fetch = (async (input: any, init?: any) => {
|
||||
observed.push({ input, init });
|
||||
return new Response("ok");
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const composed = createGeminiSanitizingFetch(
|
||||
createOmniRouteFetchInterceptor({
|
||||
apiKey: "sk-test",
|
||||
baseURL: "https://or.example.com/v1",
|
||||
})
|
||||
);
|
||||
await composed(URL_CHAT, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(chatCompletionsWithDollarSchema()),
|
||||
});
|
||||
|
||||
assert.equal(observed.length, 1);
|
||||
// Bearer injected (header concern).
|
||||
const sentHeaders = new Headers((observed[0]!.init as RequestInit).headers);
|
||||
assert.equal(sentHeaders.get("Authorization"), "Bearer sk-test");
|
||||
// Schema sanitised (body concern).
|
||||
const forwarded = bodyAsRecord(observed[0]!.init);
|
||||
const params = (
|
||||
forwarded.tools as Array<{ function: { parameters: Record<string, unknown> } }>
|
||||
)[0]!.function.parameters;
|
||||
assert.equal(params.$schema, undefined);
|
||||
assert.equal(params.additionalProperties, undefined);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* T-08 multi-instance smoke.
|
||||
*
|
||||
* Validates that two `OmniRoutePlugin(input, opts)` invocations with
|
||||
* different `providerId` values coexist without sharing mutable state.
|
||||
* This is the contract that lets opencode.json declare prod + preprod
|
||||
* side by side:
|
||||
*
|
||||
* "plugin": [
|
||||
* ["@omniroute/opencode-plugin", {"providerId": "omniroute-prod", "baseURL": "https://or.example/v1"}],
|
||||
* ["@omniroute/opencode-plugin", {"providerId": "omniroute-preprod", "baseURL": "https://or-preprod.example/v1"}]
|
||||
* ]
|
||||
*
|
||||
* Assertions:
|
||||
* - Each invocation returns its own hooks object (no identity reuse).
|
||||
* - Each `auth` hook carries its own `provider` matching opts.providerId.
|
||||
* - Each `auth.methods` array is its own array (not the same reference).
|
||||
* - Calling the factory twice with IDENTICAL opts still yields two
|
||||
* independent objects (no instance reuse / no shared closure cache).
|
||||
* - Mutating one instance's auth hook does NOT bleed into the other.
|
||||
* - Each instance's loader closure captures its OWN baseURL — no
|
||||
* last-write-wins module-scope state.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { OmniRoutePlugin } from "../src/index.js";
|
||||
|
||||
const fakeInput = {} as Parameters<typeof OmniRoutePlugin>[0];
|
||||
|
||||
test("multi-instance: two plugin invocations bind to their own providerId", async () => {
|
||||
const a = await OmniRoutePlugin(fakeInput, {
|
||||
providerId: "omniroute-prod",
|
||||
baseURL: "https://a.example/v1",
|
||||
});
|
||||
const b = await OmniRoutePlugin(fakeInput, {
|
||||
providerId: "omniroute-preprod",
|
||||
baseURL: "https://b.example/v1",
|
||||
});
|
||||
|
||||
assert.equal(a.auth?.provider, "opencode-omniroute-prod");
|
||||
assert.equal(b.auth?.provider, "opencode-omniroute-preprod");
|
||||
});
|
||||
|
||||
test("multi-instance: hook objects + nested arrays are independent references", async () => {
|
||||
const a = await OmniRoutePlugin(fakeInput, {
|
||||
providerId: "alpha",
|
||||
baseURL: "https://a.example/v1",
|
||||
});
|
||||
const b = await OmniRoutePlugin(fakeInput, {
|
||||
providerId: "bravo",
|
||||
baseURL: "https://b.example/v1",
|
||||
});
|
||||
|
||||
assert.notEqual(a, b, "top-level hooks objects must not be the same reference");
|
||||
assert.notEqual(a.auth, b.auth, "auth hooks must not be the same reference");
|
||||
assert.notEqual(
|
||||
a.auth?.methods,
|
||||
b.auth?.methods,
|
||||
"methods arrays must not be the same reference"
|
||||
);
|
||||
});
|
||||
|
||||
test("multi-instance: identical opts twice still yield independent objects", async () => {
|
||||
const opts = { providerId: "twin", baseURL: "https://twin.example/v1" };
|
||||
const first = await OmniRoutePlugin(fakeInput, { ...opts });
|
||||
const second = await OmniRoutePlugin(fakeInput, { ...opts });
|
||||
|
||||
assert.notEqual(first, second);
|
||||
assert.notEqual(first.auth, second.auth);
|
||||
assert.notEqual(first.auth?.methods, second.auth?.methods);
|
||||
// Same provider id is fine — what matters is no shared mutable state.
|
||||
assert.equal(first.auth?.provider, "opencode-twin");
|
||||
assert.equal(second.auth?.provider, "opencode-twin");
|
||||
});
|
||||
|
||||
test("multi-instance: mutating instance A's auth.methods does not affect instance B", async () => {
|
||||
const a = await OmniRoutePlugin(fakeInput, {
|
||||
providerId: "iso-a",
|
||||
baseURL: "https://a.example/v1",
|
||||
});
|
||||
const b = await OmniRoutePlugin(fakeInput, {
|
||||
providerId: "iso-b",
|
||||
baseURL: "https://b.example/v1",
|
||||
});
|
||||
|
||||
const beforeLen = b.auth?.methods?.length ?? 0;
|
||||
// Mutate a's methods array — extend it; b's must be untouched.
|
||||
// We don't know the concrete method shape so push a sentinel cast.
|
||||
a.auth?.methods?.push({ type: "api", label: "sentinel" } as never);
|
||||
assert.equal(b.auth?.methods?.length, beforeLen, "instance B leaked from instance A mutation");
|
||||
});
|
||||
|
||||
test("multi-instance: loader closures see their own opts (not last-write-wins)", async () => {
|
||||
// Each plugin's loader builds its loader payload from the providerId/baseURL
|
||||
// captured at invocation time. If the factory accidentally shared a closure
|
||||
// (e.g. a module-scope let that the last invocation overwrites), both
|
||||
// loaders would emit the same baseURL. Verify they don't.
|
||||
const a = await OmniRoutePlugin(fakeInput, {
|
||||
providerId: "omniroute-prod",
|
||||
baseURL: "https://prod.example/v1",
|
||||
});
|
||||
const b = await OmniRoutePlugin(fakeInput, {
|
||||
providerId: "omniroute-preprod",
|
||||
baseURL: "https://preprod.example/v1",
|
||||
});
|
||||
|
||||
assert.ok(a.auth?.loader, "instance A must have a loader");
|
||||
assert.ok(b.auth?.loader, "instance B must have a loader");
|
||||
|
||||
const getAuthA = async () => ({ type: "api", key: "sk-prod" }) as never;
|
||||
const getAuthB = async () => ({ type: "api", key: "sk-preprod" }) as never;
|
||||
|
||||
const rA = (await a.auth!.loader!(getAuthA, {} as never)) as Record<string, unknown>;
|
||||
const rB = (await b.auth!.loader!(getAuthB, {} as never)) as Record<string, unknown>;
|
||||
|
||||
assert.equal(rA.apiKey, "sk-prod");
|
||||
assert.equal(rA.baseURL, "https://prod.example/v1");
|
||||
assert.equal(rB.apiKey, "sk-preprod");
|
||||
assert.equal(rB.baseURL, "https://preprod.example/v1");
|
||||
});
|
||||
|
||||
test("multi-instance: invalid opts on one instance does not poison the other", async () => {
|
||||
// Sequencing: bad opts → good opts. The bad call must throw cleanly; the
|
||||
// good call must still produce a working hooks object. Confirms no
|
||||
// half-built module-level state survives a failed parse.
|
||||
await assert.rejects(
|
||||
() => OmniRoutePlugin(fakeInput, { providerId: "bad id!" } as never),
|
||||
/providerId/
|
||||
);
|
||||
const ok = await OmniRoutePlugin(fakeInput, {
|
||||
providerId: "recovered",
|
||||
baseURL: "https://ok.example/v1",
|
||||
});
|
||||
assert.equal(ok.auth?.provider, "opencode-recovered");
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* T-08 options-schema tests.
|
||||
*
|
||||
* Covers `parseOmniRoutePluginOptions(opts)` — the strict Zod gate that
|
||||
* validates the second-arg `PluginOptions` bag from opencode.json before
|
||||
* any hook is wired. Anti-pattern checklist mirrored here:
|
||||
*
|
||||
* - `null` / `undefined` must collapse to `{}` (defaults apply downstream).
|
||||
* - Unknown keys must THROW (`.strict()` catches opencode.json typos).
|
||||
* - Validation runs at parse time, not import time (module loads cleanly).
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseOmniRoutePluginOptions } from "../src/index.js";
|
||||
|
||||
test("parseOmniRoutePluginOptions: undefined → {}", () => {
|
||||
assert.deepEqual(parseOmniRoutePluginOptions(undefined), {});
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: null → {}", () => {
|
||||
assert.deepEqual(parseOmniRoutePluginOptions(null), {});
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: empty object → {}", () => {
|
||||
assert.deepEqual(parseOmniRoutePluginOptions({}), {});
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: valid providerId → returns it", () => {
|
||||
const r = parseOmniRoutePluginOptions({ providerId: "omniroute-preprod" });
|
||||
assert.equal(r.providerId, "omniroute-preprod");
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: invalid providerId (special chars) → throws", () => {
|
||||
assert.throws(
|
||||
() => parseOmniRoutePluginOptions({ providerId: "omniroute prod!" }),
|
||||
/providerId.*slug/i
|
||||
);
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: empty providerId → throws", () => {
|
||||
assert.throws(() => parseOmniRoutePluginOptions({ providerId: "" }), /providerId/i);
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: valid modelCacheTtl → returns it", () => {
|
||||
const r = parseOmniRoutePluginOptions({ modelCacheTtl: 60_000 });
|
||||
assert.equal(r.modelCacheTtl, 60_000);
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: negative modelCacheTtl → throws", () => {
|
||||
assert.throws(() => parseOmniRoutePluginOptions({ modelCacheTtl: -1 }), /modelCacheTtl/i);
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: zero modelCacheTtl → throws (positive required)", () => {
|
||||
assert.throws(() => parseOmniRoutePluginOptions({ modelCacheTtl: 0 }), /modelCacheTtl/i);
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: invalid baseURL (not a URL) → throws", () => {
|
||||
assert.throws(() => parseOmniRoutePluginOptions({ baseURL: "not-a-url" }), /baseURL/i);
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: unknown key → throws (strict mode catches typos)", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
parseOmniRoutePluginOptions({
|
||||
providerId: "omniroute",
|
||||
provider_id: "typo-here",
|
||||
}),
|
||||
/provider_id|unrecognized/i
|
||||
);
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: all four fields populated correctly → returns them", () => {
|
||||
const opts = {
|
||||
providerId: "omniroute-prod",
|
||||
displayName: "OmniRoute Production",
|
||||
modelCacheTtl: 120_000,
|
||||
baseURL: "https://or.example.com/v1",
|
||||
};
|
||||
const r = parseOmniRoutePluginOptions(opts);
|
||||
assert.deepEqual(r, opts);
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: error message lists every issue path", () => {
|
||||
// Two bad fields at once → error string should mention BOTH.
|
||||
try {
|
||||
parseOmniRoutePluginOptions({
|
||||
providerId: "",
|
||||
baseURL: "garbage",
|
||||
});
|
||||
assert.fail("expected throw");
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
assert.match(msg, /providerId/);
|
||||
assert.match(msg, /baseURL/);
|
||||
}
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: module import alone does NOT throw", async () => {
|
||||
// Re-importing the entry must not trigger validation; validation only fires
|
||||
// on explicit parseOmniRoutePluginOptions / OmniRoutePlugin invocation.
|
||||
const mod = await import("../src/index.js");
|
||||
assert.equal(typeof mod.parseOmniRoutePluginOptions, "function");
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* T-03 provider-hook contract tests.
|
||||
*
|
||||
* Covers `createOmniRouteProviderHook(opts, deps)`:
|
||||
* - hook.id binds to resolved providerId (single + multi-instance)
|
||||
* - models() narrows ctx.auth, fetches via injected fetcher, caches per
|
||||
* (baseURL, apiKey) tuple, refetches after TTL
|
||||
* - mapRawModelToModelV2 emits a v2 Model shape matching the
|
||||
* @opencode-ai/sdk/v2 type
|
||||
*
|
||||
* Mocking strategy: the fetcher is dependency-injected at hook construction
|
||||
* (`deps.fetcher`). No global fetch monkey-patch needed. `deps.now` lets us
|
||||
* fast-forward time deterministically for TTL assertions.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
createOmniRouteProviderHook,
|
||||
mapRawModelToModelV2,
|
||||
type OmniRouteRawModelEntry,
|
||||
type OmniRouteModelsFetcher,
|
||||
} from "../src/index.js";
|
||||
|
||||
const FIXTURE: OmniRouteRawModelEntry[] = [
|
||||
{
|
||||
id: "claude-primary",
|
||||
object: "model",
|
||||
owned_by: "combo",
|
||||
capabilities: { tool_calling: true, reasoning: true, vision: true, thinking: true },
|
||||
context_length: 200000,
|
||||
max_output_tokens: 64000,
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
},
|
||||
{
|
||||
id: "claude-low",
|
||||
object: "model",
|
||||
owned_by: "combo",
|
||||
capabilities: { tool_calling: true, reasoning: true, vision: true, thinking: false },
|
||||
context_length: 200000,
|
||||
max_output_tokens: 64000,
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
},
|
||||
{
|
||||
id: "gemini-3-flash",
|
||||
object: "model",
|
||||
owned_by: "google",
|
||||
capabilities: { tool_calling: true, reasoning: false, vision: true, thinking: false },
|
||||
context_length: 1000000,
|
||||
max_output_tokens: 8192,
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
},
|
||||
];
|
||||
|
||||
function stubFetcher(payload: OmniRouteRawModelEntry[]): OmniRouteModelsFetcher & {
|
||||
callCount: () => number;
|
||||
callsBy: () => Array<[string, string]>;
|
||||
} {
|
||||
let calls: Array<[string, string]> = [];
|
||||
const f: OmniRouteModelsFetcher = async (baseURL, apiKey) => {
|
||||
calls.push([baseURL, apiKey]);
|
||||
return payload;
|
||||
};
|
||||
return Object.assign(f, {
|
||||
callCount: () => calls.length,
|
||||
callsBy: () => calls,
|
||||
});
|
||||
}
|
||||
|
||||
const apiAuth = (key: string, baseURL?: string): unknown =>
|
||||
baseURL ? { type: "api", key, baseURL } : { type: "api", key };
|
||||
|
||||
test("createOmniRouteProviderHook: default providerId is 'omniroute'", () => {
|
||||
const hook = createOmniRouteProviderHook(undefined, { combosFetcher: async () => [] });
|
||||
assert.equal(hook.id, "opencode-omniroute");
|
||||
});
|
||||
|
||||
test("createOmniRouteProviderHook: custom providerId binds to hook.id (multi-instance)", () => {
|
||||
const a = createOmniRouteProviderHook(
|
||||
{ providerId: "omniroute-preprod" },
|
||||
{ combosFetcher: async () => [] }
|
||||
);
|
||||
const b = createOmniRouteProviderHook(
|
||||
{ providerId: "omniroute-local" },
|
||||
{ combosFetcher: async () => [] }
|
||||
);
|
||||
assert.equal(a.id, "opencode-omniroute-preprod");
|
||||
assert.equal(b.id, "opencode-omniroute-local");
|
||||
});
|
||||
|
||||
test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it", async () => {
|
||||
const fetcher = stubFetcher(FIXTURE);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher, combosFetcher: async () => [] }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-abc") as never });
|
||||
assert.equal(fetcher.callCount(), 1);
|
||||
assert.deepEqual(fetcher.callsBy()[0], ["https://or.example.com/v1", "sk-abc"]);
|
||||
assert.equal(Object.keys(out).length, 3);
|
||||
assert.ok(out["opencode-omniroute/claude-primary"]);
|
||||
});
|
||||
|
||||
test("models: returns {} when ctx.auth is null/undefined/wrong-type/empty-key", async () => {
|
||||
const fetcher = stubFetcher(FIXTURE);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher, combosFetcher: async () => [] }
|
||||
);
|
||||
|
||||
assert.deepEqual(await hook.models!({} as never, {} as never), {});
|
||||
assert.deepEqual(await hook.models!({} as never, { auth: undefined } as never), {});
|
||||
assert.deepEqual(
|
||||
await hook.models!({} as never, {
|
||||
auth: { type: "oauth", refresh: "r", access: "a", expires: 0 } as never,
|
||||
}),
|
||||
{}
|
||||
);
|
||||
assert.deepEqual(
|
||||
await hook.models!({} as never, { auth: { type: "api", key: "" } as never }),
|
||||
{}
|
||||
);
|
||||
assert.equal(fetcher.callCount(), 0, "fetcher must not be called on auth rejection");
|
||||
});
|
||||
|
||||
test("models: returns {} when no baseURL resolvable (no opts.baseURL and no auth.baseURL)", async () => {
|
||||
const fetcher = stubFetcher(FIXTURE);
|
||||
const hook = createOmniRouteProviderHook({}, { fetcher, combosFetcher: async () => [] });
|
||||
// valid api auth but neither opts nor auth carries a baseURL
|
||||
assert.deepEqual(await hook.models!({} as never, { auth: apiAuth("sk-x") as never }), {});
|
||||
assert.equal(fetcher.callCount(), 0);
|
||||
});
|
||||
|
||||
test("models: baseURL falls back to auth.baseURL when opts.baseURL absent", async () => {
|
||||
const fetcher = stubFetcher(FIXTURE);
|
||||
const hook = createOmniRouteProviderHook({}, { fetcher, combosFetcher: async () => [] });
|
||||
const out = await hook.models!({} as never, {
|
||||
auth: apiAuth("sk-y", "https://or.creds-attached.example/v1") as never,
|
||||
});
|
||||
assert.equal(fetcher.callCount(), 1);
|
||||
assert.equal(fetcher.callsBy()[0][0], "https://or.creds-attached.example/v1");
|
||||
assert.equal(Object.keys(out).length, 3);
|
||||
});
|
||||
|
||||
test("models: maps a sample /v1/models entry to ModelV2 (sanity)", async () => {
|
||||
const fetcher = stubFetcher(FIXTURE);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher, combosFetcher: async () => [] }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-abc") as never });
|
||||
const claude = out["opencode-omniroute/claude-primary"];
|
||||
assert.ok(claude, "claude-primary present");
|
||||
// `mapRawModelToModelV2` stamps the provider prefix on the id so OC's
|
||||
// static-catalog reader resolves `(providerID, modelID)` from the key.
|
||||
assert.equal(claude.id, "opencode-omniroute/claude-primary");
|
||||
assert.equal(claude.name, "claude-primary");
|
||||
assert.equal(claude.providerID, "opencode-omniroute");
|
||||
assert.equal(claude.api.id, "openai-compatible");
|
||||
assert.equal(claude.api.url, "https://or.example.com/v1");
|
||||
assert.equal(claude.api.npm, "@ai-sdk/openai-compatible");
|
||||
// capabilities: toolcall (one word), reasoning OR thinking, attachment = vision
|
||||
assert.equal(claude.capabilities.toolcall, true);
|
||||
assert.equal(claude.capabilities.reasoning, true);
|
||||
assert.equal(claude.capabilities.attachment, true);
|
||||
assert.equal(claude.capabilities.temperature, true);
|
||||
// modalities mapped from arrays
|
||||
assert.equal(claude.capabilities.input.text, true);
|
||||
assert.equal(claude.capabilities.input.image, true);
|
||||
assert.equal(claude.capabilities.input.audio, false);
|
||||
assert.equal(claude.capabilities.output.text, true);
|
||||
assert.equal(claude.capabilities.output.image, false);
|
||||
// cost is zeroed (OmniRoute /v1/models has no pricing)
|
||||
assert.deepEqual(claude.cost, { input: 0, output: 0, cache: { read: 0, write: 0 } });
|
||||
// limits
|
||||
assert.equal(claude.limit.context, 200000);
|
||||
assert.equal(claude.limit.output, 64000);
|
||||
assert.equal(claude.status, "active");
|
||||
});
|
||||
|
||||
test("mapRawModelToModelV2: thinking-only model still surfaces reasoning=true", () => {
|
||||
const m = mapRawModelToModelV2(
|
||||
{
|
||||
id: "thinking-only",
|
||||
capabilities: { thinking: true, reasoning: false },
|
||||
context_length: 100000,
|
||||
max_output_tokens: 8192,
|
||||
},
|
||||
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" }
|
||||
);
|
||||
assert.equal(m.capabilities.reasoning, true);
|
||||
});
|
||||
|
||||
test("mapRawModelToModelV2: missing capabilities defaults to all-false (except temperature)", () => {
|
||||
const m = mapRawModelToModelV2(
|
||||
{ id: "minimal" },
|
||||
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" }
|
||||
);
|
||||
assert.equal(m.capabilities.temperature, true);
|
||||
assert.equal(m.capabilities.reasoning, false);
|
||||
assert.equal(m.capabilities.attachment, false);
|
||||
assert.equal(m.capabilities.toolcall, false);
|
||||
// default modalities = text only
|
||||
assert.equal(m.capabilities.input.text, true);
|
||||
assert.equal(m.capabilities.output.text, true);
|
||||
// missing context / output tokens → 0 fallback (ModelV2.limit.{context,output} required)
|
||||
assert.equal(m.limit.context, 0);
|
||||
assert.equal(m.limit.output, 0);
|
||||
});
|
||||
|
||||
test("models: caches result for second call within TTL (fetcher called once)", async () => {
|
||||
const fetcher = stubFetcher(FIXTURE);
|
||||
let nowMs = 1_000_000;
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1", modelCacheTtl: 60_000 },
|
||||
{ fetcher, now: () => nowMs, combosFetcher: async () => [] }
|
||||
);
|
||||
|
||||
const a = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
nowMs += 30_000; // half the TTL
|
||||
const b = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.equal(fetcher.callCount(), 1, "second call within TTL must hit the cache");
|
||||
assert.equal(Object.keys(a).length, 3);
|
||||
assert.equal(Object.keys(b).length, 3);
|
||||
});
|
||||
|
||||
test("models: refetches after TTL expires", async () => {
|
||||
const fetcher = stubFetcher(FIXTURE);
|
||||
let nowMs = 1_000_000;
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1", modelCacheTtl: 60_000 },
|
||||
{ fetcher, now: () => nowMs, combosFetcher: async () => [] }
|
||||
);
|
||||
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
nowMs += 60_001; // just past the TTL
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.equal(fetcher.callCount(), 2, "call past TTL must refetch");
|
||||
});
|
||||
|
||||
test("models: caches per (baseURL, apiKey) tuple (different keys → independent fetches)", async () => {
|
||||
const fetcher = stubFetcher(FIXTURE);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1", modelCacheTtl: 300_000 },
|
||||
{ fetcher, combosFetcher: async () => [] }
|
||||
);
|
||||
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-A") as never });
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-B") as never });
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-A") as never }); // cached
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-B") as never }); // cached
|
||||
assert.equal(fetcher.callCount(), 2, "one fetch per distinct apiKey, then cache hits");
|
||||
});
|
||||
|
||||
test("models: caches per (baseURL, apiKey) tuple (different baseURL → independent fetches)", async () => {
|
||||
const fetcher = stubFetcher(FIXTURE);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ modelCacheTtl: 300_000 }, // no opts.baseURL → falls back to auth.baseURL
|
||||
{ fetcher, combosFetcher: async () => [] }
|
||||
);
|
||||
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-same", "https://prod.example/v1") as never });
|
||||
await hook.models!({} as never, {
|
||||
auth: apiAuth("sk-same", "https://preprod.example/v1") as never,
|
||||
});
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-same", "https://prod.example/v1") as never }); // cached
|
||||
assert.equal(fetcher.callCount(), 2, "distinct baseURLs share apiKey but not cache");
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
OmniRoutePlugin,
|
||||
OMNIROUTE_PROVIDER_KEY,
|
||||
DEFAULT_MODEL_CACHE_TTL_MS,
|
||||
resolveOmniRoutePluginOptions,
|
||||
} from "../src/index.js";
|
||||
|
||||
test("scaffold: exports public surface", () => {
|
||||
assert.equal(
|
||||
typeof OmniRoutePlugin,
|
||||
"function",
|
||||
"OmniRoutePlugin must be a function (Plugin factory)"
|
||||
);
|
||||
assert.equal(OMNIROUTE_PROVIDER_KEY, "omniroute");
|
||||
assert.equal(DEFAULT_MODEL_CACHE_TTL_MS, 300_000);
|
||||
});
|
||||
|
||||
test("scaffold: default export is v1 plugin shape { id, server: OmniRoutePlugin }", async () => {
|
||||
const mod = await import("../src/index.js");
|
||||
assert.equal(typeof mod.default, "object");
|
||||
assert.equal(mod.default.id, "@omniroute/opencode-plugin");
|
||||
assert.equal(mod.default.server, mod.OmniRoutePlugin);
|
||||
});
|
||||
|
||||
test("resolveOmniRoutePluginOptions: defaults", () => {
|
||||
const r = resolveOmniRoutePluginOptions();
|
||||
assert.equal(r.providerId, "opencode-omniroute");
|
||||
assert.equal(r.displayName, "OmniRoute");
|
||||
assert.equal(r.modelCacheTtl, 300_000);
|
||||
assert.equal(r.baseURL, undefined);
|
||||
});
|
||||
|
||||
test("resolveOmniRoutePluginOptions: custom providerId derives displayName", () => {
|
||||
const r = resolveOmniRoutePluginOptions({ providerId: "omniroute-preprod" });
|
||||
assert.equal(r.providerId, "opencode-omniroute-preprod");
|
||||
assert.equal(r.displayName, "OmniRoute (opencode-omniroute-preprod)");
|
||||
});
|
||||
|
||||
test("resolveOmniRoutePluginOptions: explicit displayName wins", () => {
|
||||
const r = resolveOmniRoutePluginOptions({
|
||||
providerId: "omniroute-x",
|
||||
displayName: "Custom Label",
|
||||
});
|
||||
assert.equal(r.displayName, "Custom Label");
|
||||
});
|
||||
|
||||
test("resolveOmniRoutePluginOptions: invalid TTL falls back to default", () => {
|
||||
assert.equal(resolveOmniRoutePluginOptions({ modelCacheTtl: 0 }).modelCacheTtl, 300_000);
|
||||
assert.equal(resolveOmniRoutePluginOptions({ modelCacheTtl: -1 }).modelCacheTtl, 300_000);
|
||||
});
|
||||
|
||||
test("resolveOmniRoutePluginOptions: positive TTL respected", () => {
|
||||
assert.equal(resolveOmniRoutePluginOptions({ modelCacheTtl: 60_000 }).modelCacheTtl, 60_000);
|
||||
});
|
||||
|
||||
test("OmniRoutePlugin: returns an empty hooks object (scaffold)", async () => {
|
||||
const fakeCtx = {} as Parameters<typeof OmniRoutePlugin>[0];
|
||||
const hooks = await OmniRoutePlugin(fakeCtx);
|
||||
assert.equal(typeof hooks, "object");
|
||||
assert.notEqual(hooks, null);
|
||||
});
|
||||
|
||||
test("scaffold: built ESM default export resolves with the v1 plugin shape", async () => {
|
||||
// The plugin is ESM-only now — the CJS bundle was dropped to fix the OpenCode
|
||||
// loader (#3883), so there is no more ../dist/index.cjs. Validate that the built
|
||||
// distributable's default export still carries the OpenCode v1 { id, server } shape.
|
||||
const mod = await import("../dist/index.js");
|
||||
assert.strictEqual(typeof mod.default, "object");
|
||||
assert.strictEqual(mod.default.id, "@omniroute/opencode-plugin");
|
||||
assert.strictEqual(typeof mod.default.server, "function");
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Regression tests for `isUsableCombo` (release/v3.8.2 code review, finding C1).
|
||||
*
|
||||
* The combo member refs returned by `/api/combos` do NOT carry a separate
|
||||
* `providerId` field — OmniRoute's `normalizeComboRecord` folds the provider
|
||||
* id INTO the full model string (e.g. "cc/claude-opus-4-7"). The previous
|
||||
* implementation read `step.providerId` (always `undefined`), so the
|
||||
* `usableOnly` combo filter silently never dropped anything. These tests pin
|
||||
* the corrected behavior: the verdict is derived from the `step.model` prefix,
|
||||
* mirroring `isUsableRawModelId`'s subtract-filter semantics.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { isUsableCombo, type OmniRouteRawCombo } from "../src/index.js";
|
||||
|
||||
/** Build a `usable` set bundle for the tests. */
|
||||
function buildUsable(opts: { aliases?: string[]; canonicals?: string[]; known?: string[] }): {
|
||||
aliases: Set<string>;
|
||||
canonicals: Set<string>;
|
||||
knownAliases: Set<string>;
|
||||
} {
|
||||
return {
|
||||
aliases: new Set(opts.aliases ?? []),
|
||||
canonicals: new Set(opts.canonicals ?? []),
|
||||
// knownAliases is the union of every prefix the universe is aware of —
|
||||
// usable or not. Default to including the usable aliases too.
|
||||
knownAliases: new Set([...(opts.known ?? []), ...(opts.aliases ?? [])]),
|
||||
};
|
||||
}
|
||||
|
||||
function combo(models: OmniRouteRawCombo["models"]): OmniRouteRawCombo {
|
||||
return { id: "c1", name: "Test Combo", models };
|
||||
}
|
||||
|
||||
test("isUsableCombo: member with a usable alias prefix → keep", () => {
|
||||
const usable = buildUsable({ aliases: ["cc"], known: ["cc", "dead"] });
|
||||
const c = combo([{ kind: "model", model: "cc/claude-opus-4-7" }]);
|
||||
assert.equal(isUsableCombo(c, usable), true);
|
||||
});
|
||||
|
||||
test("isUsableCombo: all members known-but-NOT-usable → drop (the C1 regression)", () => {
|
||||
// Before the fix this returned true unconditionally because step.providerId
|
||||
// was always undefined. Now the known-but-unusable "dead" prefix is dropped.
|
||||
const usable = buildUsable({ aliases: ["cc"], known: ["cc", "dead"] });
|
||||
const c = combo([
|
||||
{ kind: "model", model: "dead/legacy-model" },
|
||||
{ kind: "model", model: "dead/another" },
|
||||
]);
|
||||
assert.equal(isUsableCombo(c, usable), false);
|
||||
});
|
||||
|
||||
test("isUsableCombo: unknown prefix → keep (cannot prove unroutable)", () => {
|
||||
const usable = buildUsable({ aliases: ["cc"], known: ["cc", "dead"] });
|
||||
const c = combo([{ kind: "model", model: "agentrouter/mystery" }]);
|
||||
assert.equal(isUsableCombo(c, usable), true);
|
||||
});
|
||||
|
||||
test("isUsableCombo: mixed non-usable + usable member → keep", () => {
|
||||
const usable = buildUsable({ aliases: ["cc"], known: ["cc", "dead"] });
|
||||
const c = combo([
|
||||
{ kind: "model", model: "dead/legacy" },
|
||||
{ kind: "model", model: "cc/claude-opus-4-7" },
|
||||
]);
|
||||
assert.equal(isUsableCombo(c, usable), true);
|
||||
});
|
||||
|
||||
test("isUsableCombo: zero members → keep", () => {
|
||||
const usable = buildUsable({ aliases: ["cc"], known: ["cc"] });
|
||||
assert.equal(isUsableCombo(combo([]), usable), true);
|
||||
assert.equal(isUsableCombo(combo(undefined), usable), true);
|
||||
});
|
||||
|
||||
test("isUsableCombo: only combo-ref steps (no resolvable model) → keep", () => {
|
||||
const usable = buildUsable({ aliases: ["cc"], known: ["cc", "dead"] });
|
||||
const c = combo([{ kind: "combo-ref", comboName: "nested" }]);
|
||||
assert.equal(isUsableCombo(c, usable), true);
|
||||
});
|
||||
|
||||
test("isUsableCombo: usable canonical prefix → keep", () => {
|
||||
const usable = buildUsable({ canonicals: ["anthropic"], known: ["anthropic", "dead"] });
|
||||
const c = combo([{ kind: "model", model: "anthropic/claude-opus-4-7" }]);
|
||||
assert.equal(isUsableCombo(c, usable), true);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"isolatedModules": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules", "tests"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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,
|
||||
// Bundle runtime deps so the .tgz / npm install is self-contained.
|
||||
// `zod` is required at runtime by the options schema and would otherwise
|
||||
// need a peer install when the plugin is loaded directly from a file path
|
||||
// in opencode.jsonc.
|
||||
noExternal: ["zod"],
|
||||
});
|
||||
Reference in New Issue
Block a user