mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 06:12:10 +03:00
feat(@omniroute/opencode-plugin): introducing the OmniRoute OpenCode plugin (live models, combos, Gemini sanitize, multi-instance) (#2529)
feat(@omniroute/opencode-plugin): introducing the OmniRoute OpenCode plugin — integrated into release/v3.8.2
This commit is contained in:
62
.github/workflows/opencode-plugin-ci.yml
vendored
Normal file
62
.github/workflows/opencode-plugin-ci.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
name: opencode-plugin CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, release/v3.8.1, release/v3.8.2]
|
||||
paths:
|
||||
- "@omniroute/opencode-plugin/**"
|
||||
pull_request:
|
||||
branches: [main, release/v3.8.1, release/v3.8.2]
|
||||
paths:
|
||||
- "@omniroute/opencode-plugin/**"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: "@omniroute/opencode-plugin"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test (Node ${{ matrix.node }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ["22", "24"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
cache-dependency-path: "@omniroute/opencode-plugin/package-lock.json"
|
||||
- run: npm install --no-audit --no-fund
|
||||
- run: npm run build
|
||||
- run: npm test
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: "@omniroute/opencode-plugin/package-lock.json"
|
||||
- run: npm install --no-audit --no-fund
|
||||
- run: npm run build
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: opencode-plugin-dist
|
||||
path: "@omniroute/opencode-plugin/dist"
|
||||
retention-days: 7
|
||||
4
@omniroute/opencode-plugin/.gitignore
vendored
Normal file
4
@omniroute/opencode-plugin/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.DS_Store
|
||||
21
@omniroute/opencode-plugin/LICENSE
Normal file
21
@omniroute/opencode-plugin/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 OmniRoute contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
218
@omniroute/opencode-plugin/README.md
Normal file
218
@omniroute/opencode-plugin/README.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# @omniroute/opencode-plugin
|
||||
|
||||
First-class OpenCode plugin for the [OmniRoute AI Gateway](https://github.com/diegosouzapw/OmniRoute). Pulls a live model catalog from `/v1/models` (including `-low`/`-medium`/`-high`/`-thinking` variants as first-class IDs), aggregates combos via `/api/combos` using a least-common-denominator capability/limit join, sanitizes Gemini tool schemas in flight, and supports multiple side-by-side OmniRoute instances out of the box.
|
||||
|
||||
## Install
|
||||
|
||||
Once published to npm:
|
||||
|
||||
```sh
|
||||
npm install @omniroute/opencode-plugin
|
||||
```
|
||||
|
||||
Until then (or for local development), reference the built artifact directly. Either extract the package into your OpenCode plugins dir and point at the extracted `dist/index.js`:
|
||||
|
||||
```sh
|
||||
# from inside the OmniRoute repo
|
||||
cd @omniroute/opencode-plugin && npm run build && npm pack
|
||||
# then extract into ~/.config/opencode/plugins/omniroute-opencode-plugin/
|
||||
```
|
||||
|
||||
Peer dep: `@opencode-ai/plugin` (managed by your OpenCode install).
|
||||
|
||||
## Quick start (single instance)
|
||||
|
||||
```jsonc
|
||||
// opencode.json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"plugin": [
|
||||
[
|
||||
"@omniroute/opencode-plugin",
|
||||
{
|
||||
"providerId": "omniroute",
|
||||
"baseURL": "https://or.example.com",
|
||||
},
|
||||
],
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
```sh
|
||||
opencode auth login --provider omniroute
|
||||
# prompts for the OmniRoute API key, writes to ~/.local/share/opencode/auth.json
|
||||
```
|
||||
|
||||
> ⚠ Use the `--provider` flag explicitly. `opencode auth login omniroute` is parsed as a positional `url` argument by current OC releases (≤1.15.5) and fails with `fetch() URL is invalid`. Tracked upstream.
|
||||
|
||||
Restart OpenCode. `/models` lists the full live catalog. Variants (`-low`, `-medium`, `-high`, `-thinking`) and combos appear as first-class IDs — OmniRoute is the source of truth, no client-side synthesis.
|
||||
|
||||
## Multi-instance (prod + preprod side-by-side)
|
||||
|
||||
> ⚠ OC ≤1.15.5 dedupes plugin loads by absolute module path. Two `plugin:` entries pointing at the same `dist/index.js` collapse into one (last-listed options win). Workaround: install the plugin twice into separate directories so each entry resolves to a distinct module file. v0.2.x will introduce an `instances: [...]` shape that registers N providers from a single load.
|
||||
|
||||
### Dual-install workaround (works today on OC ≤1.15.5)
|
||||
|
||||
Pack the plugin once, extract it twice into named directories, then point each `plugin:` entry at its own copy:
|
||||
|
||||
```sh
|
||||
# 1. Build + pack the plugin (run from the plugin worktree)
|
||||
cd /path/to/OmniRoute/@omniroute/opencode-plugin
|
||||
npm run build
|
||||
npm pack
|
||||
# produces omniroute-opencode-plugin-0.1.0.tgz
|
||||
|
||||
# 2. Extract one copy per OmniRoute endpoint
|
||||
mkdir -p ~/.config/opencode/plugins/omniroute-opencode-plugin-prod
|
||||
mkdir -p ~/.config/opencode/plugins/omniroute-opencode-plugin-preprod
|
||||
tar -xzf omniroute-opencode-plugin-0.1.0.tgz -C ~/.config/opencode/plugins/omniroute-opencode-plugin-prod --strip-components=1
|
||||
tar -xzf omniroute-opencode-plugin-0.1.0.tgz -C ~/.config/opencode/plugins/omniroute-opencode-plugin-preprod --strip-components=1
|
||||
```
|
||||
|
||||
Then in `~/.config/opencode/opencode.json` reference each directory by absolute path:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"plugin": [
|
||||
[
|
||||
"./plugins/omniroute-opencode-plugin-prod/dist/index.js",
|
||||
{
|
||||
"providerId": "omniroute",
|
||||
"displayName": "OmniRoute",
|
||||
"baseURL": "https://or.example.com",
|
||||
},
|
||||
],
|
||||
[
|
||||
"./plugins/omniroute-opencode-plugin-preprod/dist/index.js",
|
||||
{
|
||||
"providerId": "omniroute-preprod",
|
||||
"displayName": "OmniRoute Preprod",
|
||||
"baseURL": "https://or-preprod.example.com",
|
||||
},
|
||||
],
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
Paths are relative to `~/.config/opencode/`. Each entry now resolves to a distinct module file, so OC loads them as two separate plugin instances. Authenticate each:
|
||||
|
||||
```sh
|
||||
opencode auth login --provider omniroute
|
||||
opencode auth login --provider omniroute-preprod
|
||||
```
|
||||
|
||||
Each entry gets its own provider id, its own model picker entry, its own slot in `auth.json`, and its own TTL cache. Closures are isolated per plugin instance — no cross-talk.
|
||||
|
||||
### After publish (`@omniroute/opencode-plugin` npm)
|
||||
|
||||
Once the package is published, the dual-install becomes two `npm install --prefix` commands instead of `tar -xzf`:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.config/opencode/plugins/omniroute-opencode-plugin-prod
|
||||
mkdir -p ~/.config/opencode/plugins/omniroute-opencode-plugin-preprod
|
||||
npm install --prefix ~/.config/opencode/plugins/omniroute-opencode-plugin-prod @omniroute/opencode-plugin
|
||||
npm install --prefix ~/.config/opencode/plugins/omniroute-opencode-plugin-preprod @omniroute/opencode-plugin
|
||||
```
|
||||
|
||||
`opencode.json` paths become `./plugins/omniroute-opencode-plugin-prod/node_modules/@omniroute/opencode-plugin/dist/index.js` (and the preprod equivalent).
|
||||
|
||||
## Features
|
||||
|
||||
| Feature | What it does | Hook |
|
||||
| ------------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------ |
|
||||
| Dynamic `/v1/models` | Pulls live catalog (455+ entries on prod) on each refresh, TTL-cached | `provider.models` |
|
||||
| Variants pass-through | `-low`/`-medium`/`-high`/`-thinking` ship as first-class IDs from OmniRoute (no client synthesis) | `provider.models` |
|
||||
| Combo LCD aggregation | Combos appear with intersected capabilities + min context/output across members | `provider.models` |
|
||||
| Nice names | `combo.name` / `model.id` surfaces as `ModelV2.name` | `provider.models` |
|
||||
| Bearer injection + suffix-spoof guard | Adds `Authorization` on baseURL-matched requests only | `auth.loader.fetch` |
|
||||
| Gemini schema sanitization | Strips `$schema`/`$ref`/`additionalProperties` for `gemini-*`/`google-vertex-gemini/*` | `auth.loader.fetch` wrap |
|
||||
| Multi-instance | Each plugin entry binds to its own `providerId`; closures isolated | factory |
|
||||
| Config-hook shim | OC ≤1.14.48 fallback: writes static catalog into `config.provider[id]` | `config` |
|
||||
|
||||
## Plugin options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| --------------- | -------- | ------------------------------------------ | ---------------------------------------------------------- |
|
||||
| `providerId` | `string` | `"omniroute"` | OpenCode provider id; must be unique across plugin entries |
|
||||
| `displayName` | `string` | `"OmniRoute"` or `OmniRoute (<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 |
|
||||
| `enrichment` | `boolean` | `true` | Pull display names from `/api/pricing/models` AND per-million-token pricing (`input`, `output`, `cached` → `cacheRead`, `cache_creation` → `cacheWrite`) from `/api/pricing`, then overlay both onto the live catalog (so the UI shows `Claude 4.7 Opus` with `cost.input: 5`, `cost.output: 25` instead of raw IDs and zeroed cost). |
|
||||
| `compressionMetadata` | `boolean` | `false` | Pull `/api/context/combos` so combo names get tagged with their compression pipeline, e.g. `claude-primary [rtk:standard → caveman:full]` |
|
||||
| `geminiSanitization` | `boolean` | `true` | Strip `$schema`/`$ref`/`additionalProperties` from tool params when the model id matches `gemini` |
|
||||
| `mcpAutoEmit` | `boolean` | `false` | Auto-write an `mcp.<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.
|
||||
|
||||
## 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).
|
||||
2414
@omniroute/opencode-plugin/package-lock.json
generated
Normal file
2414
@omniroute/opencode-plugin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
73
@omniroute/opencode-plugin/package.json
Normal file
73
@omniroute/opencode-plugin/package.json
Normal file
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"name": "@omniroute/opencode-plugin",
|
||||
"version": "0.1.0",
|
||||
"description": "OpenCode plugin for the OmniRoute AI Gateway. Drives dynamic model discovery, /connect auth flow, and multi-instance OmniRoute providers via the official @opencode-ai/plugin contract.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts",
|
||||
"prepublishOnly": "npm run clean && npm run build && npm test"
|
||||
},
|
||||
"keywords": [
|
||||
"omniroute",
|
||||
"opencode",
|
||||
"opencode-plugin",
|
||||
"ai-sdk",
|
||||
"openai-compatible",
|
||||
"provider",
|
||||
"gemini",
|
||||
"combos",
|
||||
"mcp"
|
||||
],
|
||||
"author": "OmniRoute contributors",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/diegosouzapw/OmniRoute.git",
|
||||
"directory": "@omniroute/opencode-plugin"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/diegosouzapw/OmniRoute/issues"
|
||||
},
|
||||
"homepage": "https://github.com/diegosouzapw/OmniRoute/tree/main/%40omniroute/opencode-plugin#readme",
|
||||
"engines": {
|
||||
"node": ">=22.22.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/plugin": "*"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/plugin": "^1.15.6",
|
||||
"@types/node": "^22.19.19",
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.22.3",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
2384
@omniroute/opencode-plugin/src/index.ts
Normal file
2384
@omniroute/opencode-plugin/src/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
112
@omniroute/opencode-plugin/tests/auth.test.ts
Normal file
112
@omniroute/opencode-plugin/tests/auth.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* T-02 auth-hook contract tests.
|
||||
*
|
||||
* Covers the `createOmniRouteAuthHook(opts)` factory and its loader behaviour
|
||||
* against every Auth flavor (`api`, `oauth`, null, empty key). Validates the
|
||||
* multi-instance fix: provider id flows from plugin options, not a module
|
||||
* constant.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createOmniRouteAuthHook } from "../src/index.js";
|
||||
|
||||
test("createOmniRouteAuthHook: default providerId is 'omniroute'", () => {
|
||||
const hook = createOmniRouteAuthHook();
|
||||
assert.equal(hook.provider, "omniroute");
|
||||
});
|
||||
|
||||
test("createOmniRouteAuthHook: custom providerId binds to hook.provider (multi-instance)", () => {
|
||||
const hook = createOmniRouteAuthHook({ providerId: "omniroute-preprod" });
|
||||
assert.equal(hook.provider, "omniroute-preprod");
|
||||
});
|
||||
|
||||
test("createOmniRouteAuthHook: methods[0] is type 'api' with label including displayName", () => {
|
||||
const hook = createOmniRouteAuthHook();
|
||||
assert.equal(Array.isArray(hook.methods), true);
|
||||
assert.equal(hook.methods.length, 1);
|
||||
const m = hook.methods[0];
|
||||
assert.equal(m.type, "api");
|
||||
assert.equal(m.label, "OmniRoute API Key");
|
||||
|
||||
const custom = createOmniRouteAuthHook({ providerId: "omniroute-preprod" });
|
||||
assert.equal(custom.methods[0].label, "OmniRoute (omniroute-preprod) API Key");
|
||||
});
|
||||
|
||||
test("createOmniRouteAuthHook: prompts[0] uses key='apiKey' per @opencode-ai/plugin contract", () => {
|
||||
// NOTE: spec referenced `name: "apiKey"`; the official
|
||||
// @opencode-ai/plugin@1.15.6 prompt shape uses `key` + `message` (no
|
||||
// `name`/`label`/`mask` fields). Asserting against the real type contract.
|
||||
const hook = createOmniRouteAuthHook();
|
||||
const m = hook.methods[0];
|
||||
assert.equal(m.type, "api");
|
||||
// narrow: api method may carry prompts
|
||||
const prompts = "prompts" in m ? m.prompts : undefined;
|
||||
assert.ok(Array.isArray(prompts) && prompts.length === 1, "expected one prompt");
|
||||
const p = prompts![0];
|
||||
assert.equal(p.type, "text");
|
||||
assert.equal((p as { key: string }).key, "apiKey");
|
||||
assert.ok(
|
||||
typeof (p as { message: string }).message === "string" &&
|
||||
(p as { message: string }).message.includes("omniroute"),
|
||||
"prompt message should mention provider id"
|
||||
);
|
||||
});
|
||||
|
||||
test("loader: valid api auth → {apiKey} when no baseURL option (T-04: fetch omitted)", async () => {
|
||||
// T-04 changed the loader return shape: without a resolvable baseURL the
|
||||
// interceptor cannot gate-keep requests, so the loader falls back to
|
||||
// apiKey-only and the AI-SDK uses its default fetch. See fetch-interceptor
|
||||
// tests for the wired-fetch branches.
|
||||
const hook = createOmniRouteAuthHook();
|
||||
assert.ok(hook.loader, "loader must be defined");
|
||||
const result = await hook.loader!(
|
||||
async () => ({ type: "api", key: "sk-test" }) as never,
|
||||
{} as never
|
||||
);
|
||||
assert.deepEqual(result, { apiKey: "sk-test" });
|
||||
});
|
||||
|
||||
test("loader: valid api auth → {apiKey, baseURL, fetch} when baseURL option set (T-04)", async () => {
|
||||
const hook = createOmniRouteAuthHook({ baseURL: "https://or.example.com/v1" });
|
||||
const result = await hook.loader!(
|
||||
async () => ({ type: "api", key: "sk-x" }) as never,
|
||||
{} as never
|
||||
);
|
||||
assert.equal((result as { apiKey: string }).apiKey, "sk-x");
|
||||
assert.equal((result as { baseURL: string }).baseURL, "https://or.example.com/v1");
|
||||
assert.equal(
|
||||
typeof (result as { fetch?: unknown }).fetch,
|
||||
"function",
|
||||
"T-04: loader must wire fetch interceptor when baseURL resolves"
|
||||
);
|
||||
});
|
||||
|
||||
test("loader: null/undefined auth → {} (no creds yet, OC surfaces /connect)", async () => {
|
||||
const hook = createOmniRouteAuthHook();
|
||||
const r1 = await hook.loader!(async () => null as never, {} as never);
|
||||
assert.deepEqual(r1, {});
|
||||
const r2 = await hook.loader!(async () => undefined as never, {} as never);
|
||||
assert.deepEqual(r2, {});
|
||||
});
|
||||
|
||||
test("loader: oauth-flavored auth → {} (wrong method type, ignored)", async () => {
|
||||
const hook = createOmniRouteAuthHook();
|
||||
const result = await hook.loader!(
|
||||
async () =>
|
||||
({
|
||||
type: "oauth",
|
||||
refresh: "r",
|
||||
access: "a",
|
||||
expires: 0,
|
||||
}) as never,
|
||||
{} as never
|
||||
);
|
||||
assert.deepEqual(result, {});
|
||||
});
|
||||
|
||||
test("loader: api auth with empty key → {} (empty creds rejected)", async () => {
|
||||
const hook = createOmniRouteAuthHook();
|
||||
const result = await hook.loader!(async () => ({ type: "api", key: "" }) as never, {} as never);
|
||||
assert.deepEqual(result, {});
|
||||
});
|
||||
641
@omniroute/opencode-plugin/tests/combos.test.ts
Normal file
641
@omniroute/opencode-plugin/tests/combos.test.ts
Normal file
@@ -0,0 +1,641 @@
|
||||
/**
|
||||
* T-05 combo-discovery contract tests.
|
||||
*
|
||||
* Covers:
|
||||
* - `defaultOmniRouteCombosFetcher(baseURL, apiKey, timeoutMs?)`
|
||||
* — envelope tolerance (`{combos: [...]}` and bare array), non-2xx errors.
|
||||
* - `mapComboToModelV2(combo, members, providerId, baseURL)`
|
||||
* — LCD policy across capabilities, limits, modalities; defensive
|
||||
* posture on empty members; nice-name preference.
|
||||
* - `createOmniRouteProviderHook(opts, deps)` extension
|
||||
* — combos merged into the models map; collision resolution (combo
|
||||
* wins, warn-once); soft-fail when the combos fetcher throws;
|
||||
* combos cached + reused under the same TTL key as models.
|
||||
*
|
||||
* Mocking strategy mirrors `provider.test.ts`: both fetchers are
|
||||
* dependency-injected at hook construction, no `fetch` monkey-patch.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
createOmniRouteProviderHook,
|
||||
defaultOmniRouteCombosFetcher,
|
||||
mapComboToModelV2,
|
||||
type OmniRouteCombosFetcher,
|
||||
type OmniRouteModelsFetcher,
|
||||
type OmniRouteRawCombo,
|
||||
type OmniRouteRawModelEntry,
|
||||
} from "../src/index.js";
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Fixtures
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const MODEL_PRIMARY: OmniRouteRawModelEntry = {
|
||||
id: "claude-primary",
|
||||
capabilities: {
|
||||
tool_calling: true,
|
||||
reasoning: true,
|
||||
vision: true,
|
||||
thinking: true,
|
||||
temperature: true,
|
||||
},
|
||||
context_length: 200_000,
|
||||
max_output_tokens: 64_000,
|
||||
max_input_tokens: 180_000,
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
};
|
||||
|
||||
const MODEL_SECONDARY: OmniRouteRawModelEntry = {
|
||||
id: "claude-secondary",
|
||||
capabilities: {
|
||||
tool_calling: true,
|
||||
reasoning: false,
|
||||
vision: true,
|
||||
thinking: false,
|
||||
temperature: true,
|
||||
},
|
||||
context_length: 100_000,
|
||||
max_output_tokens: 32_000,
|
||||
max_input_tokens: 96_000,
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
};
|
||||
|
||||
const MODEL_NO_TOOLS: OmniRouteRawModelEntry = {
|
||||
id: "gemini-3-flash",
|
||||
capabilities: { tool_calling: false, reasoning: false, vision: false, thinking: false },
|
||||
context_length: 1_000_000,
|
||||
max_output_tokens: 8_192,
|
||||
input_modalities: ["text"],
|
||||
output_modalities: ["text"],
|
||||
};
|
||||
|
||||
const COMBO_CLAUDE_TIER: OmniRouteRawCombo = {
|
||||
id: "combo-claude-tier",
|
||||
name: "Claude Tier",
|
||||
strategy: "priority",
|
||||
models: [
|
||||
{ id: "s1", kind: "model", model: "claude-primary", weight: 100 },
|
||||
{ id: "s2", kind: "model", model: "claude-secondary", weight: 80 },
|
||||
],
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function stubModelsFetcher(
|
||||
payload: OmniRouteRawModelEntry[]
|
||||
): OmniRouteModelsFetcher & { callCount: () => number } {
|
||||
let n = 0;
|
||||
const f: OmniRouteModelsFetcher = async () => {
|
||||
n++;
|
||||
return payload;
|
||||
};
|
||||
return Object.assign(f, { callCount: () => n });
|
||||
}
|
||||
|
||||
function stubCombosFetcher(
|
||||
payload: OmniRouteRawCombo[]
|
||||
): OmniRouteCombosFetcher & { callCount: () => number; callsBy: () => Array<[string, string]> } {
|
||||
let n = 0;
|
||||
const calls: Array<[string, string]> = [];
|
||||
const f: OmniRouteCombosFetcher = async (baseURL, apiKey) => {
|
||||
n++;
|
||||
calls.push([baseURL, apiKey]);
|
||||
return payload;
|
||||
};
|
||||
return Object.assign(f, {
|
||||
callCount: () => n,
|
||||
callsBy: () => calls,
|
||||
});
|
||||
}
|
||||
|
||||
function failingCombosFetcher(
|
||||
err = new Error("boom")
|
||||
): OmniRouteCombosFetcher & { callCount: () => number } {
|
||||
let n = 0;
|
||||
const f: OmniRouteCombosFetcher = async () => {
|
||||
n++;
|
||||
throw err;
|
||||
};
|
||||
return Object.assign(f, { callCount: () => n });
|
||||
}
|
||||
|
||||
const apiAuth = (key: string): unknown => ({ type: "api", key });
|
||||
|
||||
// Capture console.warn invocations for the duration of a callback, then
|
||||
// restore the original. Needed because the collision + soft-fail paths
|
||||
// emit warnings we want to assert on.
|
||||
async function withWarnCapture<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://or4269-preprod.mrmm.xyz/v1"
|
||||
);
|
||||
assert.equal(m.providerID, "omniroute-preprod");
|
||||
assert.equal(m.api.id, "openai-compatible");
|
||||
assert.equal(m.api.url, "https://or4269-preprod.mrmm.xyz/v1");
|
||||
assert.equal(m.api.npm, "@ai-sdk/openai-compatible");
|
||||
assert.equal(m.status, "active");
|
||||
});
|
||||
|
||||
test("mapComboToModelV2: explicit member temperature=false drops combo temperature=false", () => {
|
||||
const tempFalse: OmniRouteRawModelEntry = {
|
||||
id: "no-temp",
|
||||
capabilities: { tool_calling: true, temperature: false },
|
||||
context_length: 100_000,
|
||||
max_output_tokens: 8_000,
|
||||
input_modalities: ["text"],
|
||||
output_modalities: ["text"],
|
||||
};
|
||||
const m = mapComboToModelV2(
|
||||
{ id: "c" },
|
||||
[MODEL_PRIMARY, tempFalse],
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
assert.equal(m.capabilities.temperature, false);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// createOmniRouteProviderHook — combos merge + collision + soft-fail + cache
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("models() returns combo entries merged into the map", async () => {
|
||||
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY, MODEL_SECONDARY, MODEL_NO_TOOLS]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
|
||||
// 3 raw models + 1 combo = 4 entries
|
||||
assert.equal(Object.keys(out).length, 4);
|
||||
assert.ok(out["claude-primary"]);
|
||||
assert.ok(out["claude-secondary"]);
|
||||
assert.ok(out["gemini-3-flash"]);
|
||||
assert.ok(out["combo-claude-tier"]);
|
||||
|
||||
const combo = out["combo-claude-tier"];
|
||||
assert.equal(combo.name, "Claude Tier");
|
||||
assert.equal(combo.providerID, "omniroute");
|
||||
// LCD over claude-primary (200k, reasoning) + claude-secondary (100k, no reasoning)
|
||||
assert.equal(combo.limit.context, 100_000);
|
||||
assert.equal(combo.capabilities.reasoning, false);
|
||||
assert.equal(combo.capabilities.toolcall, true);
|
||||
});
|
||||
|
||||
test("models(): combo with unknown member ids degrades to all-false LCD posture", async () => {
|
||||
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY]); // catalog only has claude-primary
|
||||
const combosFetcher = stubCombosFetcher([
|
||||
{
|
||||
id: "phantom",
|
||||
name: "Phantom Combo",
|
||||
models: [
|
||||
{ id: "s1", kind: "model", model: "does-not-exist-1", weight: 50 },
|
||||
{ id: "s2", kind: "model", model: "does-not-exist-2", weight: 50 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.ok(out["phantom"]);
|
||||
// With zero resolvable members, LCD = all-false (defensive posture).
|
||||
assert.equal(out["phantom"].capabilities.toolcall, false);
|
||||
assert.equal(out["phantom"].capabilities.reasoning, false);
|
||||
assert.equal(out["phantom"].limit.context, 0);
|
||||
});
|
||||
|
||||
test("models(): hidden combos are excluded from the map", async () => {
|
||||
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY]);
|
||||
const combosFetcher = stubCombosFetcher([
|
||||
{
|
||||
id: "visible",
|
||||
name: "Visible",
|
||||
models: [{ id: "s1", kind: "model", model: "claude-primary", weight: 100 }],
|
||||
},
|
||||
{
|
||||
id: "hidden",
|
||||
name: "Hidden",
|
||||
isHidden: true,
|
||||
models: [{ id: "s1", kind: "model", model: "claude-primary", weight: 100 }],
|
||||
},
|
||||
]);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.ok(out["visible"]);
|
||||
assert.ok(!out["hidden"], "hidden combo must be omitted");
|
||||
});
|
||||
|
||||
test("models(): combo ID collides with a model ID → combo wins, warn emitted once", async () => {
|
||||
// The combo shares id with a model in the catalog.
|
||||
const colliderCombo: OmniRouteRawCombo = {
|
||||
id: "claude-primary", // SAME id as MODEL_PRIMARY
|
||||
name: "Claude Primary Combo Override",
|
||||
models: [{ id: "s1", kind: "model", model: "claude-secondary", weight: 100 }],
|
||||
};
|
||||
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY, MODEL_SECONDARY]);
|
||||
const combosFetcher = stubCombosFetcher([colliderCombo]);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
|
||||
const { result: out, warnings } = await withWarnCapture(async (_w) => {
|
||||
return hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
});
|
||||
|
||||
// Combo wins → the entry at "claude-primary" has the combo's display name,
|
||||
// not the raw model's id-as-name.
|
||||
assert.equal(out["claude-primary"].name, "Claude Primary Combo Override");
|
||||
// Exactly one collision warning was emitted.
|
||||
const collisionWarns = warnings.filter((w) => {
|
||||
const msg = w.args[0];
|
||||
return typeof msg === "string" && msg.includes("collides with a model id");
|
||||
});
|
||||
assert.equal(collisionWarns.length, 1, "collision warning emitted exactly once");
|
||||
|
||||
// Second call within TTL hits the cache; no additional warnings.
|
||||
const { warnings: warnings2 } = await withWarnCapture(async (_w) => {
|
||||
return hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
});
|
||||
const collisionWarns2 = warnings2.filter((w) => {
|
||||
const msg = w.args[0];
|
||||
return typeof msg === "string" && msg.includes("collides with a model id");
|
||||
});
|
||||
assert.equal(collisionWarns2.length, 0, "no re-warn on cached call");
|
||||
});
|
||||
|
||||
test("models(): collision warn is per-comboId — distinct collisions both warn", async () => {
|
||||
const m1: OmniRouteRawModelEntry = { ...MODEL_PRIMARY, id: "id-a" };
|
||||
const m2: OmniRouteRawModelEntry = { ...MODEL_SECONDARY, id: "id-b" };
|
||||
const combos: OmniRouteRawCombo[] = [
|
||||
{ id: "id-a", name: "A", models: [{ id: "s", kind: "model", model: "id-a", weight: 1 }] },
|
||||
{ id: "id-b", name: "B", models: [{ id: "s", kind: "model", model: "id-b", weight: 1 }] },
|
||||
];
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher: stubModelsFetcher([m1, m2]), combosFetcher: stubCombosFetcher(combos) }
|
||||
);
|
||||
|
||||
const { warnings } = await withWarnCapture(async () => {
|
||||
return hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
});
|
||||
const collisionWarns = warnings.filter((w) => {
|
||||
const msg = w.args[0];
|
||||
return typeof msg === "string" && msg.includes("collides with a model id");
|
||||
});
|
||||
assert.equal(collisionWarns.length, 2);
|
||||
});
|
||||
|
||||
test("models(): combos fetch fails → falls back to models-only, warn emitted, no throw", async () => {
|
||||
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY, MODEL_SECONDARY]);
|
||||
const combosFetcher = failingCombosFetcher(new Error("ECONNRESET"));
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
|
||||
const { result: out, warnings } = await withWarnCapture(async () => {
|
||||
return hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
});
|
||||
|
||||
// Catalog includes the models but NOT any combo entries.
|
||||
assert.equal(Object.keys(out).length, 2);
|
||||
assert.ok(out["claude-primary"]);
|
||||
assert.ok(out["claude-secondary"]);
|
||||
|
||||
// Soft-fail warning surfaced.
|
||||
const softFail = warnings.find((w) => {
|
||||
const msg = w.args[0];
|
||||
return typeof msg === "string" && msg.includes("combos fetch failed");
|
||||
});
|
||||
assert.ok(softFail, "soft-fail warning must be emitted on combos fetch error");
|
||||
assert.equal(combosFetcher.callCount(), 1);
|
||||
});
|
||||
|
||||
test("models(): combos cached + reused within TTL (one combo fetch per TTL window)", async () => {
|
||||
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY, MODEL_SECONDARY]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
let nowMs = 1_000_000;
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1", modelCacheTtl: 60_000 },
|
||||
{ fetcher: modelsFetcher, combosFetcher, now: () => nowMs }
|
||||
);
|
||||
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
nowMs += 30_000; // half the TTL
|
||||
const second = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.equal(combosFetcher.callCount(), 1, "combos fetched only once within TTL");
|
||||
assert.equal(modelsFetcher.callCount(), 1, "models fetched only once within TTL");
|
||||
assert.ok(second["combo-claude-tier"]);
|
||||
});
|
||||
|
||||
test("models(): combos refetched after TTL expiry (same key as models)", async () => {
|
||||
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
let nowMs = 1_000_000;
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1", modelCacheTtl: 60_000 },
|
||||
{ fetcher: modelsFetcher, combosFetcher, now: () => nowMs }
|
||||
);
|
||||
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
nowMs += 60_001;
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.equal(combosFetcher.callCount(), 2, "combos must refetch past TTL");
|
||||
assert.equal(modelsFetcher.callCount(), 2, "models must refetch past TTL");
|
||||
});
|
||||
|
||||
test("models(): combos fetcher receives the resolved baseURL + apiKey", async () => {
|
||||
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-spy") as never });
|
||||
assert.deepEqual(combosFetcher.callsBy()[0], ["https://or.example.com/v1", "sk-spy"]);
|
||||
});
|
||||
715
@omniroute/opencode-plugin/tests/config-shim.test.ts
Normal file
715
@omniroute/opencode-plugin/tests/config-shim.test.ts
Normal file
@@ -0,0 +1,715 @@
|
||||
/**
|
||||
* T-07 config-hook backward-compat shim tests.
|
||||
*
|
||||
* Covers `createOmniRouteConfigHook(opts, deps)`:
|
||||
* - happy path: valid auth.json → mutates input.provider[id] with the
|
||||
* stripped per-model shape (mirroring `@omniroute/opencode-provider`).
|
||||
* - no-op paths: missing auth.json, malformed JSON, missing apiKey,
|
||||
* missing baseURL, existing input.provider[id] (manual override).
|
||||
* - fail-open: /v1/models error → stub `models: {}`; /api/combos error →
|
||||
* models-only static catalog.
|
||||
* - baseURL resolution: opts.baseURL → auth.json.baseURL fallback.
|
||||
* - multi-instance: two plugins with different providerIds publish to
|
||||
* their own keys without collision.
|
||||
* - cache sharing: provider hook + config hook on the same Map dedupe
|
||||
* fetcher invocations.
|
||||
* - sibling-shape parity: emitted entries carry only
|
||||
* `{name, attachment?, reasoning?, temperature?, tool_call?, limit?}`
|
||||
* — never the rich ModelV2 nested capabilities tree.
|
||||
*
|
||||
* Mocking strategy mirrors `provider.test.ts` and `combos.test.ts`: every
|
||||
* dependency (`readAuthJson`, `fetcher`, `combosFetcher`, `now`, `cache`,
|
||||
* `logger`) is dependency-injected at hook construction. No global
|
||||
* `fs/promises` or `fetch` monkey-patch needed.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { Config } from "@opencode-ai/plugin";
|
||||
import {
|
||||
buildStaticProviderEntry,
|
||||
createOmniRouteConfigHook,
|
||||
createOmniRouteProviderHook,
|
||||
OmniRoutePlugin,
|
||||
resolveOmniRoutePluginOptions,
|
||||
type OmniRouteCombosFetcher,
|
||||
type OmniRouteFetchCache,
|
||||
type OmniRouteModelsFetcher,
|
||||
type OmniRouteRawCombo,
|
||||
type OmniRouteRawModelEntry,
|
||||
type OmniRouteReadAuthJson,
|
||||
type OmniRouteStaticProviderEntry,
|
||||
} from "../src/index.js";
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Fixtures
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const MODEL_CLAUDE: OmniRouteRawModelEntry = {
|
||||
id: "claude-sonnet-4-6",
|
||||
capabilities: {
|
||||
tool_calling: true,
|
||||
reasoning: true,
|
||||
vision: true,
|
||||
thinking: false,
|
||||
temperature: true,
|
||||
},
|
||||
context_length: 200_000,
|
||||
max_output_tokens: 64_000,
|
||||
max_input_tokens: 180_000,
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
};
|
||||
|
||||
const MODEL_GEMINI: OmniRouteRawModelEntry = {
|
||||
id: "gemini-3-flash",
|
||||
capabilities: { tool_calling: true, reasoning: false, vision: true, thinking: false },
|
||||
context_length: 1_000_000,
|
||||
max_output_tokens: 8_192,
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
};
|
||||
|
||||
const COMBO_CLAUDE_TIER: OmniRouteRawCombo = {
|
||||
id: "combo-claude-tier",
|
||||
name: "Claude Tier",
|
||||
models: [
|
||||
{ id: "s1", kind: "model", model: "claude-sonnet-4-6", weight: 100 },
|
||||
{ id: "s2", kind: "model", model: "gemini-3-flash", weight: 50 },
|
||||
],
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers (DI stubs — mirrors patterns in provider.test.ts / combos.test.ts)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function stubReadAuthJson(
|
||||
value: Record<string, unknown> | undefined | null
|
||||
): OmniRouteReadAuthJson & { callCount: () => number } {
|
||||
let n = 0;
|
||||
const f: OmniRouteReadAuthJson = async () => {
|
||||
n++;
|
||||
return value as never;
|
||||
};
|
||||
return Object.assign(f, { callCount: () => n });
|
||||
}
|
||||
|
||||
function throwingReadAuthJson(): OmniRouteReadAuthJson & { callCount: () => number } {
|
||||
let n = 0;
|
||||
const f: OmniRouteReadAuthJson = async () => {
|
||||
n++;
|
||||
throw new Error("EACCES");
|
||||
};
|
||||
return Object.assign(f, { callCount: () => n });
|
||||
}
|
||||
|
||||
function stubModelsFetcher(
|
||||
payload: OmniRouteRawModelEntry[]
|
||||
): OmniRouteModelsFetcher & { callCount: () => number; callsBy: () => Array<[string, string]> } {
|
||||
let n = 0;
|
||||
const calls: Array<[string, string]> = [];
|
||||
const f: OmniRouteModelsFetcher = async (baseURL, apiKey) => {
|
||||
n++;
|
||||
calls.push([baseURL, apiKey]);
|
||||
return payload;
|
||||
};
|
||||
return Object.assign(f, { callCount: () => n, callsBy: () => calls });
|
||||
}
|
||||
|
||||
function stubCombosFetcher(
|
||||
payload: OmniRouteRawCombo[]
|
||||
): OmniRouteCombosFetcher & { callCount: () => number; callsBy: () => Array<[string, string]> } {
|
||||
let n = 0;
|
||||
const calls: Array<[string, string]> = [];
|
||||
const f: OmniRouteCombosFetcher = async (baseURL, apiKey) => {
|
||||
n++;
|
||||
calls.push([baseURL, apiKey]);
|
||||
return payload;
|
||||
};
|
||||
return Object.assign(f, { callCount: () => n, callsBy: () => calls });
|
||||
}
|
||||
|
||||
function throwingModelsFetcher(): OmniRouteModelsFetcher & { callCount: () => number } {
|
||||
let n = 0;
|
||||
const f: OmniRouteModelsFetcher = async () => {
|
||||
n++;
|
||||
throw new Error("ECONNREFUSED");
|
||||
};
|
||||
return Object.assign(f, { callCount: () => n });
|
||||
}
|
||||
|
||||
function throwingCombosFetcher(): OmniRouteCombosFetcher & { callCount: () => number } {
|
||||
let n = 0;
|
||||
const f: OmniRouteCombosFetcher = async () => {
|
||||
n++;
|
||||
throw new Error("403 Forbidden");
|
||||
};
|
||||
return Object.assign(f, { callCount: () => n });
|
||||
}
|
||||
|
||||
interface WarnCapture {
|
||||
warn: (...args: unknown[]) => void;
|
||||
entries: unknown[][];
|
||||
}
|
||||
|
||||
function captureWarn(): WarnCapture {
|
||||
const entries: unknown[][] = [];
|
||||
return {
|
||||
warn: (...args: unknown[]) => {
|
||||
entries.push(args);
|
||||
},
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
function makeInput(initialProvider: Record<string, unknown> = {}): Config {
|
||||
// Config = Omit<SDKConfig, "plugin"> & {plugin?: ...}. We only touch the
|
||||
// `provider` slot, so a partial cast is acceptable for these tests.
|
||||
return { provider: initialProvider } as unknown as Config;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 1. Happy path — valid auth.json + apiKey + baseURL → mutates input.provider
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("config: with valid auth.json + apiKey + baseURL → mutates input.provider[id] with stripped models block", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test-1", baseURL: "https://or.example.com/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE, MODEL_GEMINI]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" },
|
||||
{ readAuthJson, fetcher, combosFetcher, logger }
|
||||
);
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const provider = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider;
|
||||
const entry = provider.omniroute;
|
||||
assert.ok(entry, "input.provider.omniroute set");
|
||||
assert.equal(entry.npm, "@ai-sdk/openai-compatible");
|
||||
assert.equal(entry.name, "OmniRoute");
|
||||
assert.equal(entry.options.baseURL, "https://or.example.com/v1");
|
||||
assert.equal(entry.options.apiKey, "sk-test-1");
|
||||
|
||||
// Stripped per-model shape: name + cap flags only, NO nested
|
||||
// capabilities.input.* tree, NO cost block.
|
||||
const claude = entry.models["claude-sonnet-4-6"];
|
||||
assert.ok(claude, "claude model surfaced");
|
||||
assert.equal(claude.name, "claude-sonnet-4-6");
|
||||
assert.equal(claude.attachment, true);
|
||||
assert.equal(claude.reasoning, true);
|
||||
assert.equal(claude.temperature, true);
|
||||
assert.equal(claude.tool_call, true);
|
||||
assert.equal(claude.limit?.context, 200_000);
|
||||
assert.equal(claude.limit?.input, 180_000);
|
||||
assert.equal(claude.limit?.output, 64_000);
|
||||
|
||||
// Combo present + LCD'd (gemini's reasoning=false → combo reasoning=false).
|
||||
const combo = entry.models["combo-claude-tier"];
|
||||
assert.ok(combo, "combo surfaced");
|
||||
assert.equal(combo.name, "Claude Tier");
|
||||
assert.equal(combo.reasoning, false, "LCD: any member reasoning=false → combo reasoning=false");
|
||||
assert.equal(combo.tool_call, true);
|
||||
assert.equal(combo.limit?.context, 200_000, "LCD: min(200_000, 1_000_000)");
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 2. Missing auth.json → no-op, no throw, no mutation
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("config: missing auth.json file → no-op, no throw, no input mutation", async () => {
|
||||
const readAuthJson = stubReadAuthJson(undefined);
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" },
|
||||
{ readAuthJson, fetcher, combosFetcher, logger }
|
||||
);
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
assert.deepEqual((input as { provider: Record<string, unknown> }).provider, {});
|
||||
assert.equal(fetcher.callCount(), 0, "no fetch on missing auth.json");
|
||||
assert.equal(combosFetcher.callCount(), 0, "no combos fetch on missing auth.json");
|
||||
// One breadcrumb — the missing-apiKey path.
|
||||
assert.ok(
|
||||
logger.entries.some((e) => String(e[0]).includes("no apiKey")),
|
||||
"breadcrumb emitted"
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 3. Malformed auth.json → no-op + warn once
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("config: malformed auth.json → no-op + warn once", async () => {
|
||||
// stubReadAuthJson returns `null` to signal malformed JSON (matches
|
||||
// defaultReadAuthJson's contract).
|
||||
const readAuthJson = stubReadAuthJson(null);
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" },
|
||||
{ readAuthJson, fetcher, combosFetcher, logger }
|
||||
);
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
assert.deepEqual((input as { provider: Record<string, unknown> }).provider, {});
|
||||
assert.equal(fetcher.callCount(), 0);
|
||||
// First warn = "failed to parse"; second warn = "no apiKey".
|
||||
assert.ok(
|
||||
logger.entries.some((e) => String(e[0]).includes("failed to parse")),
|
||||
"parse-failure breadcrumb emitted"
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 4. Existing input.provider[id] → no overwrite (respect manual override)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("config: existing input.provider[id] → no overwrite (respect manual override)", async () => {
|
||||
const manual = {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Manual OmniRoute",
|
||||
options: { baseURL: "http://manual/v1", apiKey: "manual-key" },
|
||||
models: { "manual-model": { name: "manual-model" } },
|
||||
};
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" },
|
||||
{ readAuthJson, fetcher, combosFetcher, logger }
|
||||
);
|
||||
const input = makeInput({ omniroute: manual });
|
||||
await hook(input);
|
||||
|
||||
const provider = (input as { provider: Record<string, unknown> }).provider;
|
||||
assert.equal(provider.omniroute, manual, "manual override preserved by reference");
|
||||
assert.equal(fetcher.callCount(), 0, "no fetch — short-circuited before I/O");
|
||||
assert.equal(readAuthJson.callCount(), 0, "no auth.json read either");
|
||||
assert.ok(
|
||||
logger.entries.some((e) => String(e[0]).includes("already set")),
|
||||
"override breadcrumb emitted"
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 5. fetchers throw → warn + emit stub entry with `models: {}`
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("config: fetchers throw → warn + emit stub entry with models: {}", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = throwingModelsFetcher();
|
||||
const combosFetcher = throwingCombosFetcher();
|
||||
const logger = captureWarn();
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" },
|
||||
{ readAuthJson, fetcher, combosFetcher, logger }
|
||||
);
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.ok(entry, "stub provider entry published even when fetchers fail");
|
||||
assert.equal(entry.npm, "@ai-sdk/openai-compatible");
|
||||
assert.deepEqual(entry.models, {}, "models stub is empty object");
|
||||
assert.equal(entry.options.baseURL, "https://or.example/v1");
|
||||
assert.equal(entry.options.apiKey, "sk-test");
|
||||
// Both warns fired.
|
||||
assert.ok(
|
||||
logger.entries.some((e) => String(e[0]).includes("/v1/models fetch failed")),
|
||||
"models-fetch breadcrumb emitted"
|
||||
);
|
||||
assert.ok(
|
||||
logger.entries.some((e) => String(e[0]).includes("/api/combos fetch failed")),
|
||||
"combos-fetch breadcrumb emitted"
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 6. Combos fetcher throws → models-only catalog (no combos in models block)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("config: combos fetcher throws → emit models-only catalog (no combos in models block)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE, MODEL_GEMINI]);
|
||||
const combosFetcher = throwingCombosFetcher();
|
||||
const logger = captureWarn();
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" },
|
||||
{ readAuthJson, fetcher, combosFetcher, logger }
|
||||
);
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.ok(entry);
|
||||
const ids = Object.keys(entry.models).sort();
|
||||
assert.deepEqual(ids, ["claude-sonnet-4-6", "gemini-3-flash"]);
|
||||
assert.equal(entry.models["combo-claude-tier"], undefined, "no combo entry");
|
||||
assert.ok(
|
||||
logger.entries.some((e) => String(e[0]).includes("/api/combos fetch failed")),
|
||||
"combos-fetch breadcrumb emitted"
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 7. baseURL from auth.json takes precedence when opts.baseURL absent
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("config: baseURL from auth.json takes precedence when opts.baseURL absent", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://creds.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" }, // NO opts.baseURL
|
||||
{ readAuthJson, fetcher, combosFetcher, logger }
|
||||
);
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
assert.equal(fetcher.callsBy()[0][0], "https://creds.example/v1");
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(entry.options.baseURL, "https://creds.example/v1");
|
||||
});
|
||||
|
||||
test("config: opts.baseURL wins over auth.json's stored baseURL", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://creds.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute", baseURL: "https://opts.example/v1" },
|
||||
{ readAuthJson, fetcher, combosFetcher, logger }
|
||||
);
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
assert.equal(fetcher.callsBy()[0][0], "https://opts.example/v1");
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(entry.options.baseURL, "https://opts.example/v1");
|
||||
});
|
||||
|
||||
test("config: no baseURL resolvable (no opts, no auth.json baseURL) → no-op", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test" }, // NO baseURL on the credential
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" }, // NO opts.baseURL
|
||||
{ readAuthJson, fetcher, combosFetcher, logger }
|
||||
);
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
assert.deepEqual((input as { provider: Record<string, unknown> }).provider, {});
|
||||
assert.equal(fetcher.callCount(), 0);
|
||||
assert.ok(
|
||||
logger.entries.some((e) => String(e[0]).includes("no baseURL")),
|
||||
"no-baseURL breadcrumb emitted"
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 8. Multi-instance: two plugins with different providerIds publish to
|
||||
// their own keys without collision.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("config: multi-instance — two plugins with different providerIds publish to their own keys without collision", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"omniroute-prod": {
|
||||
type: "api",
|
||||
key: "sk-prod",
|
||||
baseURL: "https://prod.example/v1",
|
||||
},
|
||||
"omniroute-preprod": {
|
||||
type: "api",
|
||||
key: "sk-preprod",
|
||||
baseURL: "https://preprod.example/v1",
|
||||
},
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const hookA = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute-prod" },
|
||||
{ readAuthJson, fetcher, combosFetcher, logger }
|
||||
);
|
||||
const hookB = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute-preprod" },
|
||||
{ readAuthJson, fetcher, combosFetcher, logger }
|
||||
);
|
||||
|
||||
const input = makeInput();
|
||||
await hookA(input);
|
||||
await hookB(input);
|
||||
|
||||
const provider = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider;
|
||||
assert.ok(provider["omniroute-prod"], "prod block present");
|
||||
assert.ok(provider["omniroute-preprod"], "preprod block present");
|
||||
assert.equal(provider["omniroute-prod"].options.apiKey, "sk-prod");
|
||||
assert.equal(provider["omniroute-preprod"].options.apiKey, "sk-preprod");
|
||||
assert.equal(provider["omniroute-prod"].options.baseURL, "https://prod.example/v1");
|
||||
assert.equal(provider["omniroute-preprod"].options.baseURL, "https://preprod.example/v1");
|
||||
assert.notEqual(
|
||||
provider["omniroute-prod"],
|
||||
provider["omniroute-preprod"],
|
||||
"blocks are distinct references"
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 9. Cache sharing: provider hook + config hook on the same Map dedupe
|
||||
// fetcher invocations.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("config + provider share cache: second call uses cached fetch result (single fetch per TTL)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
const sharedCache: OmniRouteFetchCache = new Map();
|
||||
const logger = captureWarn();
|
||||
|
||||
const configHook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute", baseURL: "https://or.example/v1", modelCacheTtl: 60_000 },
|
||||
{ readAuthJson, fetcher, combosFetcher, cache: sharedCache, logger }
|
||||
);
|
||||
const providerHook = createOmniRouteProviderHook(
|
||||
{ providerId: "omniroute", baseURL: "https://or.example/v1", modelCacheTtl: 60_000 },
|
||||
{ fetcher, combosFetcher, cache: sharedCache }
|
||||
);
|
||||
|
||||
// Simulate OC ≥1.14.49 cold start: config fires first, populates cache,
|
||||
// then provider.models() reuses the cached raw results.
|
||||
const input = makeInput();
|
||||
await configHook(input);
|
||||
assert.equal(fetcher.callCount(), 1, "config fired the only models fetch");
|
||||
assert.equal(combosFetcher.callCount(), 1, "config fired the only combos fetch");
|
||||
|
||||
// provider hook then runs — should hit the shared cache, NOT refetch.
|
||||
const apiAuth = { type: "api", key: "sk-shared" };
|
||||
await providerHook.models!({} as never, { auth: apiAuth as never });
|
||||
assert.equal(fetcher.callCount(), 1, "provider reused cached models");
|
||||
assert.equal(combosFetcher.callCount(), 1, "provider reused cached combos");
|
||||
});
|
||||
|
||||
test("provider → config order also dedupes (cache populated by provider, consumed by config)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-reverse", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
const sharedCache: OmniRouteFetchCache = new Map();
|
||||
const logger = captureWarn();
|
||||
|
||||
const configHook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute", baseURL: "https://or.example/v1", modelCacheTtl: 60_000 },
|
||||
{ readAuthJson, fetcher, combosFetcher, cache: sharedCache, logger }
|
||||
);
|
||||
const providerHook = createOmniRouteProviderHook(
|
||||
{ providerId: "omniroute", baseURL: "https://or.example/v1", modelCacheTtl: 60_000 },
|
||||
{ fetcher, combosFetcher, cache: sharedCache }
|
||||
);
|
||||
|
||||
await providerHook.models!({} as never, {
|
||||
auth: { type: "api", key: "sk-reverse" } as never,
|
||||
});
|
||||
assert.equal(fetcher.callCount(), 1);
|
||||
|
||||
const input = makeInput();
|
||||
await configHook(input);
|
||||
assert.equal(fetcher.callCount(), 1, "config reused cached models");
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 10. Stripped models shape matches sibling provider spec
|
||||
// (`{name, attachment, reasoning, tool_call, temperature, limit?}`).
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("buildStaticProviderEntry: stripped per-model shape matches sibling @omniroute/opencode-provider", () => {
|
||||
const resolved = resolveOmniRoutePluginOptions({
|
||||
providerId: "omniroute",
|
||||
displayName: "OmniRoute",
|
||||
});
|
||||
const block = buildStaticProviderEntry(
|
||||
[MODEL_CLAUDE, MODEL_GEMINI],
|
||||
[],
|
||||
resolved,
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
|
||||
// Top-level provider entry shape — ONLY these four keys.
|
||||
assert.deepEqual(Object.keys(block).sort(), ["models", "name", "npm", "options"]);
|
||||
assert.equal(block.npm, "@ai-sdk/openai-compatible");
|
||||
assert.equal(block.name, "OmniRoute");
|
||||
assert.deepEqual(Object.keys(block.options).sort(), ["apiKey", "baseURL"]);
|
||||
|
||||
// Per-model entry shape — STRIPPED (no nested capabilities tree, no
|
||||
// cost block, no providerID/api/status/headers/release_date that
|
||||
// ModelV2 carries). Allowed keys: name, attachment, reasoning,
|
||||
// temperature, tool_call, limit.
|
||||
const allowedKeys = new Set([
|
||||
"name",
|
||||
"attachment",
|
||||
"reasoning",
|
||||
"temperature",
|
||||
"tool_call",
|
||||
"limit",
|
||||
]);
|
||||
for (const [id, entry] of Object.entries(block.models)) {
|
||||
for (const key of Object.keys(entry)) {
|
||||
assert.ok(allowedKeys.has(key), `${id}.${key} is not in the stripped sibling shape`);
|
||||
}
|
||||
// capabilities (ModelV2-only) must NOT leak.
|
||||
assert.equal(
|
||||
(entry as Record<string, unknown>).capabilities,
|
||||
undefined,
|
||||
`${id} must not carry nested capabilities tree`
|
||||
);
|
||||
// cost (ModelV2-only) must NOT leak.
|
||||
assert.equal(
|
||||
(entry as Record<string, unknown>).cost,
|
||||
undefined,
|
||||
`${id} must not carry cost block`
|
||||
);
|
||||
}
|
||||
|
||||
// Sanity: claude entry has all expected stripped fields.
|
||||
const claude = block.models["claude-sonnet-4-6"];
|
||||
assert.equal(typeof claude.name, "string");
|
||||
assert.equal(typeof claude.attachment, "boolean");
|
||||
assert.equal(typeof claude.reasoning, "boolean");
|
||||
assert.equal(typeof claude.temperature, "boolean");
|
||||
assert.equal(typeof claude.tool_call, "boolean");
|
||||
assert.equal(typeof claude.limit?.context, "number");
|
||||
});
|
||||
|
||||
test("buildStaticProviderEntry: empty fetch results → stub block with models: {}", () => {
|
||||
const resolved = resolveOmniRoutePluginOptions({ providerId: "omniroute" });
|
||||
const block = buildStaticProviderEntry([], [], resolved, "https://or.example/v1", "sk-test");
|
||||
assert.deepEqual(block.models, {});
|
||||
assert.equal(block.options.apiKey, "sk-test");
|
||||
});
|
||||
|
||||
test("buildStaticProviderEntry: hidden combos are excluded", () => {
|
||||
const resolved = resolveOmniRoutePluginOptions({ providerId: "omniroute" });
|
||||
const block = buildStaticProviderEntry(
|
||||
[MODEL_CLAUDE],
|
||||
[{ ...COMBO_CLAUDE_TIER, isHidden: true }],
|
||||
resolved,
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
assert.equal(block.models["combo-claude-tier"], undefined);
|
||||
assert.ok(block.models["claude-sonnet-4-6"]);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Integration: OmniRoutePlugin factory now exposes config hook
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("OmniRoutePlugin factory exposes config hook alongside auth + provider", async () => {
|
||||
const hooks = await OmniRoutePlugin({} as never, { providerId: "omniroute" });
|
||||
assert.equal(typeof hooks.config, "function", "config hook present");
|
||||
assert.ok(hooks.auth, "auth hook present");
|
||||
assert.ok(hooks.provider, "provider hook present");
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Edge cases / robustness
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("config: auth.json entry of wrong type (oauth) → no-op", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "oauth", refresh: "r", access: "a", expires: 0 },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute", baseURL: "https://or.example/v1" },
|
||||
{ readAuthJson, fetcher, combosFetcher, logger }
|
||||
);
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
assert.deepEqual((input as { provider: Record<string, unknown> }).provider, {});
|
||||
assert.equal(fetcher.callCount(), 0);
|
||||
});
|
||||
|
||||
test("config: readAuthJson throws → treat as missing file (silent fallback)", async () => {
|
||||
const readAuthJson = throwingReadAuthJson();
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute", baseURL: "https://or.example/v1" },
|
||||
{ readAuthJson, fetcher, combosFetcher, logger }
|
||||
);
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
assert.deepEqual((input as { provider: Record<string, unknown> }).provider, {});
|
||||
assert.equal(readAuthJson.callCount(), 1);
|
||||
assert.equal(fetcher.callCount(), 0);
|
||||
});
|
||||
|
||||
test("config: initialises input.provider when undefined", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" },
|
||||
{ readAuthJson, fetcher, combosFetcher, logger }
|
||||
);
|
||||
// input with NO provider field at all
|
||||
const input = {} as Config;
|
||||
await hook(input);
|
||||
const provider = (input as { provider?: Record<string, unknown> }).provider;
|
||||
assert.ok(provider, "provider bag initialised");
|
||||
assert.ok(provider!.omniroute);
|
||||
});
|
||||
611
@omniroute/opencode-plugin/tests/features.test.ts
Normal file
611
@omniroute/opencode-plugin/tests/features.test.ts
Normal file
@@ -0,0 +1,611 @@
|
||||
/**
|
||||
* Features-block tests.
|
||||
*
|
||||
* Covers the v0.1.0 `features` toggle block + the enrichment / compression
|
||||
* metadata fetchers + the MCP auto-emit branch on the config hook.
|
||||
*
|
||||
* Surfaces tested:
|
||||
* - `parseOmniRoutePluginOptions({ features: ... })` → schema accept/reject
|
||||
* - `applyEnrichment(model, entry)` → mutation semantics
|
||||
* - `formatCompressionPipeline(steps)` → display formatting
|
||||
* - `createOmniRouteProviderHook` with mocked
|
||||
* `enrichmentFetcher` / `compressionMetaFetcher` → overlay applied,
|
||||
* off-by-default
|
||||
* gating works.
|
||||
* - `createOmniRouteConfigHook` with `features.mcpAutoEmit:true`
|
||||
* → emits mcp entry
|
||||
* → falls back to
|
||||
* provider apiKey
|
||||
* when mcpToken
|
||||
* is unset
|
||||
* → respects operator
|
||||
* override
|
||||
* → no emit when
|
||||
* mcpAutoEmit is
|
||||
* false / unset
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
applyEnrichment,
|
||||
createOmniRouteConfigHook,
|
||||
createOmniRouteProviderHook,
|
||||
defaultOmniRouteEnrichmentFetcher,
|
||||
defaultOmniRouteCompressionMetaFetcher,
|
||||
formatCompressionPipeline,
|
||||
parseOmniRoutePluginOptions,
|
||||
type OmniRouteEnrichmentMap,
|
||||
type OmniRouteCompressionCombo,
|
||||
type OmniRouteRawModelEntry,
|
||||
} from "../src/index.js";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Zod schema — features block
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("parseOmniRoutePluginOptions: empty features object → preserved", () => {
|
||||
const r = parseOmniRoutePluginOptions({ features: {} });
|
||||
assert.deepEqual(r, { features: {} });
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: all boolean features set → preserved", () => {
|
||||
const r = parseOmniRoutePluginOptions({
|
||||
features: {
|
||||
combos: true,
|
||||
enrichment: true,
|
||||
compressionMetadata: true,
|
||||
geminiSanitization: true,
|
||||
mcpAutoEmit: true,
|
||||
fetchInterceptor: true,
|
||||
},
|
||||
});
|
||||
assert.equal(r.features?.combos, true);
|
||||
assert.equal(r.features?.enrichment, true);
|
||||
assert.equal(r.features?.compressionMetadata, true);
|
||||
assert.equal(r.features?.mcpAutoEmit, true);
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: mcpToken string → preserved", () => {
|
||||
const r = parseOmniRoutePluginOptions({
|
||||
features: { mcpAutoEmit: true, mcpToken: "sk-mcp-only-token-12345" },
|
||||
});
|
||||
assert.equal(r.features?.mcpToken, "sk-mcp-only-token-12345");
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: unknown features key → throws (strict)", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
parseOmniRoutePluginOptions({
|
||||
features: { combos: true, unknown_field: "oops" },
|
||||
}),
|
||||
/Invalid @omniroute\/opencode-plugin options/
|
||||
);
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: non-boolean for boolean feature → throws", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
parseOmniRoutePluginOptions({
|
||||
features: { combos: "yes" as unknown as boolean },
|
||||
}),
|
||||
/Invalid @omniroute\/opencode-plugin options/
|
||||
);
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: empty mcpToken → throws (min 1)", () => {
|
||||
assert.throws(
|
||||
() => parseOmniRoutePluginOptions({ features: { mcpToken: "" } }),
|
||||
/Invalid @omniroute\/opencode-plugin options/
|
||||
);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// applyEnrichment
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const baseModel = () => ({
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "claude-sonnet-4-6",
|
||||
capabilities: {
|
||||
temperature: true,
|
||||
reasoning: false,
|
||||
attachment: false,
|
||||
toolcall: false,
|
||||
input: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
interleaved: false,
|
||||
},
|
||||
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
limit: { context: 200000, output: 64000 },
|
||||
status: "active" as const,
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: "",
|
||||
providerID: "omniroute",
|
||||
api: {
|
||||
id: "openai-compatible" as const,
|
||||
url: "https://or.example.com/v1",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
},
|
||||
});
|
||||
|
||||
test("applyEnrichment: undefined entry → no-op", () => {
|
||||
const m = baseModel();
|
||||
const orig = JSON.parse(JSON.stringify(m));
|
||||
applyEnrichment(m as never, undefined);
|
||||
assert.deepEqual(m, orig);
|
||||
});
|
||||
|
||||
test("applyEnrichment: name overlay applied", () => {
|
||||
const m = baseModel();
|
||||
applyEnrichment(m as never, { name: "Claude Sonnet 4.6" });
|
||||
assert.equal(m.name, "Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
test("applyEnrichment: empty name string ignored", () => {
|
||||
const m = baseModel();
|
||||
applyEnrichment(m as never, { name: " " });
|
||||
assert.equal(m.name, "claude-sonnet-4-6"); // raw id untouched
|
||||
});
|
||||
|
||||
test("applyEnrichment: pricing fields applied to cost", () => {
|
||||
const m = baseModel();
|
||||
applyEnrichment(m as never, {
|
||||
pricing: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
||||
});
|
||||
assert.equal(m.cost.input, 3);
|
||||
assert.equal(m.cost.output, 15);
|
||||
assert.equal(m.cost.cache.read, 0.3);
|
||||
assert.equal(m.cost.cache.write, 3.75);
|
||||
});
|
||||
|
||||
test("applyEnrichment: partial pricing preserves untouched fields", () => {
|
||||
const m = baseModel();
|
||||
m.cost = { input: 1, output: 2, cache: { read: 0.1, write: 0.2 } };
|
||||
applyEnrichment(m as never, { pricing: { input: 99 } });
|
||||
assert.equal(m.cost.input, 99);
|
||||
assert.equal(m.cost.output, 2);
|
||||
assert.equal(m.cost.cache.read, 0.1);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// formatCompressionPipeline
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("formatCompressionPipeline: empty pipeline → empty string", () => {
|
||||
assert.equal(formatCompressionPipeline([]), "");
|
||||
});
|
||||
|
||||
test("formatCompressionPipeline: single step with intensity", () => {
|
||||
assert.equal(
|
||||
formatCompressionPipeline([{ engine: "caveman", intensity: "full" }]),
|
||||
"[caveman:full]"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatCompressionPipeline: multi-step pipeline", () => {
|
||||
assert.equal(
|
||||
formatCompressionPipeline([
|
||||
{ engine: "rtk", intensity: "standard" },
|
||||
{ engine: "caveman", intensity: "full" },
|
||||
]),
|
||||
"[rtk:standard → caveman:full]"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatCompressionPipeline: step without intensity", () => {
|
||||
assert.equal(formatCompressionPipeline([{ engine: "rtk" }]), "[rtk]");
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Provider hook — enrichment applied via injected fetcher
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const SAMPLE_RAW: OmniRouteRawModelEntry[] = [
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
object: "model",
|
||||
created: 0,
|
||||
owned_by: "anthropic",
|
||||
permission: [],
|
||||
root: "claude-sonnet-4-6",
|
||||
parent: null,
|
||||
context_length: 200000,
|
||||
max_output_tokens: 64000,
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
capabilities: { tool_calling: true, reasoning: true, vision: true, thinking: true },
|
||||
},
|
||||
];
|
||||
|
||||
const apiAuth = (key: string) => ({ type: "api" as const, key });
|
||||
|
||||
test("provider hook: enrichment fetcher called when features.enrichment !== false", async () => {
|
||||
let called = 0;
|
||||
const enrichment: OmniRouteEnrichmentMap = new Map([
|
||||
["claude-sonnet-4-6", { name: "Claude Sonnet 4.6", pricing: { input: 3, output: 15 } }],
|
||||
]);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" },
|
||||
{
|
||||
fetcher: async () => SAMPLE_RAW,
|
||||
combosFetcher: async () => [],
|
||||
enrichmentFetcher: async () => {
|
||||
called++;
|
||||
return enrichment;
|
||||
},
|
||||
}
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk") as never });
|
||||
assert.equal(called, 1, "enrichment fetcher called once");
|
||||
const m = out["claude-sonnet-4-6"];
|
||||
assert.equal(m.name, "Claude Sonnet 4.6", "enrichment name overlay applied");
|
||||
assert.equal(m.cost.input, 3, "enrichment pricing applied");
|
||||
assert.equal(m.cost.output, 15);
|
||||
});
|
||||
|
||||
test("provider hook: enrichment fetcher NOT called when features.enrichment:false", async () => {
|
||||
let called = 0;
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://or.example.com/v1",
|
||||
features: { enrichment: false },
|
||||
},
|
||||
{
|
||||
fetcher: async () => SAMPLE_RAW,
|
||||
combosFetcher: async () => [],
|
||||
enrichmentFetcher: async () => {
|
||||
called++;
|
||||
return new Map();
|
||||
},
|
||||
}
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk") as never });
|
||||
assert.equal(called, 0, "enrichment fetcher NOT called when gated off");
|
||||
assert.equal(out["claude-sonnet-4-6"].name, "claude-sonnet-4-6", "raw id preserved");
|
||||
});
|
||||
|
||||
test("provider hook: compression metadata fetcher NOT called by default (opt-in)", async () => {
|
||||
let called = 0;
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" },
|
||||
{
|
||||
fetcher: async () => SAMPLE_RAW,
|
||||
combosFetcher: async () => [],
|
||||
enrichmentFetcher: async () => new Map(),
|
||||
compressionMetaFetcher: async () => {
|
||||
called++;
|
||||
return [];
|
||||
},
|
||||
}
|
||||
);
|
||||
await hook.models!({} as never, { auth: apiAuth("sk") as never });
|
||||
assert.equal(called, 0, "compression metadata is opt-in (features.compressionMetadata:true)");
|
||||
});
|
||||
|
||||
test("provider hook: compression metadata fetcher called when opted in", async () => {
|
||||
let called = 0;
|
||||
const compressionCombos: OmniRouteCompressionCombo[] = [
|
||||
{
|
||||
id: "default-caveman",
|
||||
name: "Standard Savings",
|
||||
pipeline: [
|
||||
{ engine: "rtk", intensity: "standard" },
|
||||
{ engine: "caveman", intensity: "full" },
|
||||
],
|
||||
isDefault: true,
|
||||
},
|
||||
];
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://or.example.com/v1",
|
||||
features: { compressionMetadata: true },
|
||||
},
|
||||
{
|
||||
fetcher: async () => SAMPLE_RAW,
|
||||
combosFetcher: async () => [
|
||||
{
|
||||
id: "claude-primary",
|
||||
name: "Claude Primary",
|
||||
models: [{ id: "step-1", model: "claude-sonnet-4-6" }],
|
||||
},
|
||||
],
|
||||
enrichmentFetcher: async () => new Map(),
|
||||
compressionMetaFetcher: async () => {
|
||||
called++;
|
||||
return compressionCombos;
|
||||
},
|
||||
}
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk") as never });
|
||||
assert.equal(called, 1, "compression metadata fetcher called");
|
||||
const combo = out["claude-primary"];
|
||||
assert.ok(combo, "combo entry present");
|
||||
assert.match(combo.name, /\[rtk:standard → caveman:full\]/, "combo name decorated with pipeline");
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Config hook — MCP auto-emit
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const stubAuthJson = (apiKey: string) => async () => ({
|
||||
omniroute: { type: "api" as const, key: apiKey },
|
||||
});
|
||||
|
||||
test("config hook: MCP auto-emit OFF by default (no mcp entry)", async () => {
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" },
|
||||
{
|
||||
readAuthJson: stubAuthJson("sk-prod"),
|
||||
fetcher: async () => SAMPLE_RAW,
|
||||
combosFetcher: async () => [],
|
||||
logger: { warn: () => {} },
|
||||
}
|
||||
);
|
||||
const input: { provider?: Record<string, unknown>; mcp?: Record<string, unknown> } = {};
|
||||
await hook(input as never);
|
||||
assert.ok(input.provider?.omniroute, "provider block written");
|
||||
assert.equal(input.mcp, undefined, "no mcp block written");
|
||||
});
|
||||
|
||||
test("config hook: features.mcpAutoEmit:true writes mcp entry with provider apiKey", async () => {
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://or.example.com/v1",
|
||||
features: { mcpAutoEmit: true },
|
||||
},
|
||||
{
|
||||
readAuthJson: stubAuthJson("sk-prod-key"),
|
||||
fetcher: async () => SAMPLE_RAW,
|
||||
combosFetcher: async () => [],
|
||||
logger: { warn: () => {} },
|
||||
}
|
||||
);
|
||||
const input: { provider?: Record<string, unknown>; mcp?: Record<string, unknown> } = {};
|
||||
await hook(input as never);
|
||||
const entry = input.mcp?.omniroute as
|
||||
| { type: string; url: string; enabled: boolean; headers: Record<string, string> }
|
||||
| undefined;
|
||||
assert.ok(entry, "mcp entry written");
|
||||
assert.equal(entry.type, "remote");
|
||||
assert.equal(
|
||||
entry.url,
|
||||
"https://or.example.com/api/mcp/stream",
|
||||
"baseURL /v1 stripped to /api/mcp/stream"
|
||||
);
|
||||
assert.equal(entry.enabled, true);
|
||||
assert.equal(entry.headers.Authorization, "Bearer sk-prod-key");
|
||||
});
|
||||
|
||||
test("config hook: features.mcpToken overrides provider apiKey in mcp Bearer", async () => {
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://or.example.com/v1",
|
||||
features: { mcpAutoEmit: true, mcpToken: "sk-mcp-narrower" },
|
||||
},
|
||||
{
|
||||
readAuthJson: stubAuthJson("sk-chat"),
|
||||
fetcher: async () => SAMPLE_RAW,
|
||||
combosFetcher: async () => [],
|
||||
logger: { warn: () => {} },
|
||||
}
|
||||
);
|
||||
const input: { provider?: Record<string, unknown>; mcp?: Record<string, unknown> } = {};
|
||||
await hook(input as never);
|
||||
const entry = input.mcp?.omniroute as { headers: Record<string, string> };
|
||||
assert.equal(
|
||||
entry.headers.Authorization,
|
||||
"Bearer sk-mcp-narrower",
|
||||
"mcpToken takes precedence over apiKey"
|
||||
);
|
||||
});
|
||||
|
||||
test("config hook: existing operator mcp.<providerId> wins (no overwrite)", async () => {
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://or.example.com/v1",
|
||||
features: { mcpAutoEmit: true },
|
||||
},
|
||||
{
|
||||
readAuthJson: stubAuthJson("sk-prod"),
|
||||
fetcher: async () => SAMPLE_RAW,
|
||||
combosFetcher: async () => [],
|
||||
logger: { warn: () => {} },
|
||||
}
|
||||
);
|
||||
const input: { provider?: Record<string, unknown>; mcp?: Record<string, unknown> } = {
|
||||
mcp: { omniroute: { type: "custom-user-entry", url: "https://manual.example/mcp" } },
|
||||
};
|
||||
await hook(input as never);
|
||||
assert.deepEqual(
|
||||
input.mcp?.omniroute,
|
||||
{ type: "custom-user-entry", url: "https://manual.example/mcp" },
|
||||
"operator override preserved"
|
||||
);
|
||||
});
|
||||
|
||||
test("config hook: features.mcpAutoEmit:true with /v1 in baseURL → strips correctly", async () => {
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{
|
||||
providerId: "omniroute-preprod",
|
||||
baseURL: "https://or-preprod.example.com/v1",
|
||||
features: { mcpAutoEmit: true },
|
||||
},
|
||||
{
|
||||
readAuthJson: async () => ({
|
||||
"omniroute-preprod": { type: "api" as const, key: "sk-preprod" },
|
||||
}),
|
||||
fetcher: async () => SAMPLE_RAW,
|
||||
combosFetcher: async () => [],
|
||||
logger: { warn: () => {} },
|
||||
}
|
||||
);
|
||||
const input: { provider?: Record<string, unknown>; mcp?: Record<string, unknown> } = {};
|
||||
await hook(input as never);
|
||||
const entry = input.mcp?.["omniroute-preprod"] as { url: string };
|
||||
assert.equal(
|
||||
entry.url,
|
||||
"https://or-preprod.example.com/api/mcp/stream",
|
||||
"/v1 stripped, /api/mcp/stream appended"
|
||||
);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Default fetchers — soft-fail behavior (no real network)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("defaultOmniRouteEnrichmentFetcher: empty baseURL → empty map", async () => {
|
||||
const m = await defaultOmniRouteEnrichmentFetcher("", "sk", 100);
|
||||
assert.equal(m.size, 0);
|
||||
});
|
||||
|
||||
test("defaultOmniRouteEnrichmentFetcher: empty apiKey → empty map", async () => {
|
||||
const m = await defaultOmniRouteEnrichmentFetcher("https://or.example.com", "", 100);
|
||||
assert.equal(m.size, 0);
|
||||
});
|
||||
|
||||
test("defaultOmniRouteCompressionMetaFetcher: empty baseURL → empty array", async () => {
|
||||
const arr = await defaultOmniRouteCompressionMetaFetcher("", "sk", 100);
|
||||
assert.equal(arr.length, 0);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Default enrichment fetcher — joins /api/pricing/models (names) with
|
||||
// /api/pricing (per-model per-million-token pricing). The two endpoints are
|
||||
// fetched independently; either may soft-fail. Verified via a stub fetch
|
||||
// installed on globalThis.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("defaultOmniRouteEnrichmentFetcher: merges names from /api/pricing/models and prices from /api/pricing", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = (async (input: unknown) => {
|
||||
const url = typeof input === "string" ? input : (input as { url: string }).url;
|
||||
calls.push(url);
|
||||
if (url.endsWith("/api/pricing/models")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
cc: {
|
||||
id: "cc",
|
||||
alias: "cc",
|
||||
name: "Cc",
|
||||
models: [
|
||||
{ id: "claude-opus-4-7", name: "Claude Opus 4.7", custom: false },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude 4.6 Sonnet", custom: false },
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
if (url.endsWith("/api/pricing")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
cc: {
|
||||
"claude-opus-4-7": {
|
||||
input: 5,
|
||||
output: 25,
|
||||
cached: 0.5,
|
||||
cache_creation: 6.25,
|
||||
reasoning: 25,
|
||||
},
|
||||
"claude-sonnet-4-6": {
|
||||
input: 3,
|
||||
output: 15,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const map = await defaultOmniRouteEnrichmentFetcher(
|
||||
"https://or.example.com/v1",
|
||||
"sk-test",
|
||||
5_000
|
||||
);
|
||||
assert.ok(
|
||||
calls.some((u) => u.endsWith("/api/pricing/models")),
|
||||
"catalog endpoint hit"
|
||||
);
|
||||
assert.ok(
|
||||
calls.some((u) => u.endsWith("/api/pricing")),
|
||||
"pricing endpoint hit"
|
||||
);
|
||||
const opus = map.get("cc/claude-opus-4-7");
|
||||
assert.ok(opus, "namespaced entry present");
|
||||
assert.equal(opus?.name, "Claude Opus 4.7", "name from /api/pricing/models");
|
||||
assert.equal(opus?.pricing?.input, 5, "input price merged");
|
||||
assert.equal(opus?.pricing?.output, 25, "output price merged");
|
||||
assert.equal(opus?.pricing?.cacheRead, 0.5, "cached → cacheRead alias");
|
||||
assert.equal(opus?.pricing?.cacheWrite, 6.25, "cache_creation → cacheWrite alias");
|
||||
const opusBare = map.get("claude-opus-4-7");
|
||||
assert.ok(opusBare, "bare id entry present (collision-avoidance)");
|
||||
assert.equal(opusBare?.name, "Claude Opus 4.7");
|
||||
assert.equal(opusBare?.pricing?.input, 5);
|
||||
const sonnet = map.get("cc/claude-sonnet-4-6");
|
||||
assert.equal(sonnet?.name, "Claude 4.6 Sonnet");
|
||||
assert.equal(sonnet?.pricing?.input, 3);
|
||||
assert.equal(sonnet?.pricing?.output, 15);
|
||||
assert.equal(sonnet?.pricing?.cacheRead, undefined, "no cached key → no cacheRead");
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("defaultOmniRouteEnrichmentFetcher: name-only when pricing endpoint 5xxs", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: unknown) => {
|
||||
const url = typeof input === "string" ? input : (input as { url: string }).url;
|
||||
if (url.endsWith("/api/pricing/models")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
cc: { models: [{ id: "claude-opus-4-7", name: "Claude Opus 4.7", custom: false }] },
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
return new Response("boom", { status: 500 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const map = await defaultOmniRouteEnrichmentFetcher("https://or.example.com", "sk-test", 5_000);
|
||||
const opus = map.get("cc/claude-opus-4-7");
|
||||
assert.equal(opus?.name, "Claude Opus 4.7", "name still present");
|
||||
assert.equal(opus?.pricing, undefined, "no pricing when /api/pricing fails");
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("defaultOmniRouteEnrichmentFetcher: pricing-only when catalog endpoint 5xxs", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: unknown) => {
|
||||
const url = typeof input === "string" ? input : (input as { url: string }).url;
|
||||
if (url.endsWith("/api/pricing")) {
|
||||
return new Response(JSON.stringify({ cc: { "claude-opus-4-7": { input: 5, output: 25 } } }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response("boom", { status: 500 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const map = await defaultOmniRouteEnrichmentFetcher("https://or.example.com", "sk-test", 5_000);
|
||||
const opus = map.get("cc/claude-opus-4-7");
|
||||
assert.equal(opus?.pricing?.input, 5);
|
||||
assert.equal(opus?.pricing?.output, 25);
|
||||
assert.equal(opus?.name, undefined, "no name when catalog endpoint fails");
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
269
@omniroute/opencode-plugin/tests/fetch-interceptor.test.ts
Normal file
269
@omniroute/opencode-plugin/tests/fetch-interceptor.test.ts
Normal file
@@ -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();
|
||||
}
|
||||
});
|
||||
410
@omniroute/opencode-plugin/tests/gemini-sanitize.test.ts
Normal file
410
@omniroute/opencode-plugin/tests/gemini-sanitize.test.ts
Normal file
@@ -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-cli/gemini-2.5-pro → true (real OmniRoute alias)", () => {
|
||||
assert.equal(shouldSanitizeForGemini({ model: "gemini-cli/gemini-2.5-pro" }), true);
|
||||
});
|
||||
|
||||
test("shouldSanitizeForGemini: claude-sonnet-4 → false", () => {
|
||||
assert.equal(shouldSanitizeForGemini({ model: "claude-sonnet-4" }), false);
|
||||
});
|
||||
|
||||
test("shouldSanitizeForGemini: payload.model missing → false", () => {
|
||||
assert.equal(shouldSanitizeForGemini({ messages: [] }), false);
|
||||
});
|
||||
|
||||
test("shouldSanitizeForGemini: payload is null → false", () => {
|
||||
assert.equal(shouldSanitizeForGemini(null), false);
|
||||
});
|
||||
|
||||
test("shouldSanitizeForGemini: payload.model is non-string → false", () => {
|
||||
assert.equal(shouldSanitizeForGemini({ model: 42 }), false);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// createGeminiSanitizingFetch — wrapper
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const URL_CHAT = "https://or.example.com/v1/chat/completions";
|
||||
const URL_RESPONSES = "https://or.example.com/v1/responses";
|
||||
const URL_MODELS = "https://or.example.com/v1/models";
|
||||
|
||||
test("createGeminiSanitizingFetch: gemini model + chat/completions → tool schemas stripped before forward", async () => {
|
||||
const rec = recorder();
|
||||
const wrapped = createGeminiSanitizingFetch(rec.fn);
|
||||
await wrapped(URL_CHAT, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(chatCompletionsWithDollarSchema()),
|
||||
});
|
||||
assert.equal(rec.calls.length, 1);
|
||||
const forwarded = bodyAsRecord(rec.calls[0]!.init);
|
||||
const params = (
|
||||
forwarded.tools as Array<{ function: { parameters: Record<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;
|
||||
}
|
||||
});
|
||||
136
@omniroute/opencode-plugin/tests/multi-instance.test.ts
Normal file
136
@omniroute/opencode-plugin/tests/multi-instance.test.ts
Normal file
@@ -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, "omniroute-prod");
|
||||
assert.equal(b.auth?.provider, "omniroute-preprod");
|
||||
});
|
||||
|
||||
test("multi-instance: hook objects + nested arrays are independent references", async () => {
|
||||
const a = await OmniRoutePlugin(fakeInput, {
|
||||
providerId: "alpha",
|
||||
baseURL: "https://a.example/v1",
|
||||
});
|
||||
const b = await OmniRoutePlugin(fakeInput, {
|
||||
providerId: "bravo",
|
||||
baseURL: "https://b.example/v1",
|
||||
});
|
||||
|
||||
assert.notEqual(a, b, "top-level hooks objects must not be the same reference");
|
||||
assert.notEqual(a.auth, b.auth, "auth hooks must not be the same reference");
|
||||
assert.notEqual(
|
||||
a.auth?.methods,
|
||||
b.auth?.methods,
|
||||
"methods arrays must not be the same reference"
|
||||
);
|
||||
});
|
||||
|
||||
test("multi-instance: identical opts twice still yield independent objects", async () => {
|
||||
const opts = { providerId: "twin", baseURL: "https://twin.example/v1" };
|
||||
const first = await OmniRoutePlugin(fakeInput, { ...opts });
|
||||
const second = await OmniRoutePlugin(fakeInput, { ...opts });
|
||||
|
||||
assert.notEqual(first, second);
|
||||
assert.notEqual(first.auth, second.auth);
|
||||
assert.notEqual(first.auth?.methods, second.auth?.methods);
|
||||
// Same provider id is fine — what matters is no shared mutable state.
|
||||
assert.equal(first.auth?.provider, "twin");
|
||||
assert.equal(second.auth?.provider, "twin");
|
||||
});
|
||||
|
||||
test("multi-instance: mutating instance A's auth.methods does not affect instance B", async () => {
|
||||
const a = await OmniRoutePlugin(fakeInput, {
|
||||
providerId: "iso-a",
|
||||
baseURL: "https://a.example/v1",
|
||||
});
|
||||
const b = await OmniRoutePlugin(fakeInput, {
|
||||
providerId: "iso-b",
|
||||
baseURL: "https://b.example/v1",
|
||||
});
|
||||
|
||||
const beforeLen = b.auth?.methods?.length ?? 0;
|
||||
// Mutate a's methods array — extend it; b's must be untouched.
|
||||
// We don't know the concrete method shape so push a sentinel cast.
|
||||
a.auth?.methods?.push({ type: "api", label: "sentinel" } as never);
|
||||
assert.equal(b.auth?.methods?.length, beforeLen, "instance B leaked from instance A mutation");
|
||||
});
|
||||
|
||||
test("multi-instance: loader closures see their own opts (not last-write-wins)", async () => {
|
||||
// Each plugin's loader builds its loader payload from the providerId/baseURL
|
||||
// captured at invocation time. If the factory accidentally shared a closure
|
||||
// (e.g. a module-scope let that the last invocation overwrites), both
|
||||
// loaders would emit the same baseURL. Verify they don't.
|
||||
const a = await OmniRoutePlugin(fakeInput, {
|
||||
providerId: "omniroute-prod",
|
||||
baseURL: "https://prod.example/v1",
|
||||
});
|
||||
const b = await OmniRoutePlugin(fakeInput, {
|
||||
providerId: "omniroute-preprod",
|
||||
baseURL: "https://preprod.example/v1",
|
||||
});
|
||||
|
||||
assert.ok(a.auth?.loader, "instance A must have a loader");
|
||||
assert.ok(b.auth?.loader, "instance B must have a loader");
|
||||
|
||||
const getAuthA = async () => ({ type: "api", key: "sk-prod" }) as never;
|
||||
const getAuthB = async () => ({ type: "api", key: "sk-preprod" }) as never;
|
||||
|
||||
const rA = (await a.auth!.loader!(getAuthA, {} as never)) as Record<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, "recovered");
|
||||
});
|
||||
104
@omniroute/opencode-plugin/tests/options-schema.test.ts
Normal file
104
@omniroute/opencode-plugin/tests/options-schema.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
269
@omniroute/opencode-plugin/tests/provider.test.ts
Normal file
269
@omniroute/opencode-plugin/tests/provider.test.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* T-03 provider-hook contract tests.
|
||||
*
|
||||
* Covers `createOmniRouteProviderHook(opts, deps)`:
|
||||
* - hook.id binds to resolved providerId (single + multi-instance)
|
||||
* - models() narrows ctx.auth, fetches via injected fetcher, caches per
|
||||
* (baseURL, apiKey) tuple, refetches after TTL
|
||||
* - mapRawModelToModelV2 emits a v2 Model shape matching the
|
||||
* @opencode-ai/sdk/v2 type
|
||||
*
|
||||
* Mocking strategy: the fetcher is dependency-injected at hook construction
|
||||
* (`deps.fetcher`). No global fetch monkey-patch needed. `deps.now` lets us
|
||||
* fast-forward time deterministically for TTL assertions.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
createOmniRouteProviderHook,
|
||||
mapRawModelToModelV2,
|
||||
type OmniRouteRawModelEntry,
|
||||
type OmniRouteModelsFetcher,
|
||||
} from "../src/index.js";
|
||||
|
||||
const FIXTURE: OmniRouteRawModelEntry[] = [
|
||||
{
|
||||
id: "claude-primary",
|
||||
object: "model",
|
||||
owned_by: "combo",
|
||||
capabilities: { tool_calling: true, reasoning: true, vision: true, thinking: true },
|
||||
context_length: 200000,
|
||||
max_output_tokens: 64000,
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
},
|
||||
{
|
||||
id: "claude-low",
|
||||
object: "model",
|
||||
owned_by: "combo",
|
||||
capabilities: { tool_calling: true, reasoning: true, vision: true, thinking: false },
|
||||
context_length: 200000,
|
||||
max_output_tokens: 64000,
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
},
|
||||
{
|
||||
id: "gemini-3-flash",
|
||||
object: "model",
|
||||
owned_by: "google",
|
||||
capabilities: { tool_calling: true, reasoning: false, vision: true, thinking: false },
|
||||
context_length: 1000000,
|
||||
max_output_tokens: 8192,
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
},
|
||||
];
|
||||
|
||||
function stubFetcher(payload: OmniRouteRawModelEntry[]): OmniRouteModelsFetcher & {
|
||||
callCount: () => number;
|
||||
callsBy: () => Array<[string, string]>;
|
||||
} {
|
||||
let calls: Array<[string, string]> = [];
|
||||
const f: OmniRouteModelsFetcher = async (baseURL, apiKey) => {
|
||||
calls.push([baseURL, apiKey]);
|
||||
return payload;
|
||||
};
|
||||
return Object.assign(f, {
|
||||
callCount: () => calls.length,
|
||||
callsBy: () => calls,
|
||||
});
|
||||
}
|
||||
|
||||
const apiAuth = (key: string, baseURL?: string): unknown =>
|
||||
baseURL ? { type: "api", key, baseURL } : { type: "api", key };
|
||||
|
||||
test("createOmniRouteProviderHook: default providerId is 'omniroute'", () => {
|
||||
const hook = createOmniRouteProviderHook(undefined, { combosFetcher: async () => [] });
|
||||
assert.equal(hook.id, "omniroute");
|
||||
});
|
||||
|
||||
test("createOmniRouteProviderHook: custom providerId binds to hook.id (multi-instance)", () => {
|
||||
const a = createOmniRouteProviderHook(
|
||||
{ providerId: "omniroute-preprod" },
|
||||
{ combosFetcher: async () => [] }
|
||||
);
|
||||
const b = createOmniRouteProviderHook(
|
||||
{ providerId: "omniroute-local" },
|
||||
{ combosFetcher: async () => [] }
|
||||
);
|
||||
assert.equal(a.id, "omniroute-preprod");
|
||||
assert.equal(b.id, "omniroute-local");
|
||||
});
|
||||
|
||||
test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it", async () => {
|
||||
const fetcher = stubFetcher(FIXTURE);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher, combosFetcher: async () => [] }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-abc") as never });
|
||||
assert.equal(fetcher.callCount(), 1);
|
||||
assert.deepEqual(fetcher.callsBy()[0], ["https://or.example.com/v1", "sk-abc"]);
|
||||
assert.equal(Object.keys(out).length, 3);
|
||||
assert.ok(out["claude-primary"]);
|
||||
});
|
||||
|
||||
test("models: returns {} when ctx.auth is null/undefined/wrong-type/empty-key", async () => {
|
||||
const fetcher = stubFetcher(FIXTURE);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher, combosFetcher: async () => [] }
|
||||
);
|
||||
|
||||
assert.deepEqual(await hook.models!({} as never, {} as never), {});
|
||||
assert.deepEqual(await hook.models!({} as never, { auth: undefined } as never), {});
|
||||
assert.deepEqual(
|
||||
await hook.models!({} as never, {
|
||||
auth: { type: "oauth", refresh: "r", access: "a", expires: 0 } as never,
|
||||
}),
|
||||
{}
|
||||
);
|
||||
assert.deepEqual(
|
||||
await hook.models!({} as never, { auth: { type: "api", key: "" } as never }),
|
||||
{}
|
||||
);
|
||||
assert.equal(fetcher.callCount(), 0, "fetcher must not be called on auth rejection");
|
||||
});
|
||||
|
||||
test("models: returns {} when no baseURL resolvable (no opts.baseURL and no auth.baseURL)", async () => {
|
||||
const fetcher = stubFetcher(FIXTURE);
|
||||
const hook = createOmniRouteProviderHook({}, { fetcher, combosFetcher: async () => [] });
|
||||
// valid api auth but neither opts nor auth carries a baseURL
|
||||
assert.deepEqual(await hook.models!({} as never, { auth: apiAuth("sk-x") as never }), {});
|
||||
assert.equal(fetcher.callCount(), 0);
|
||||
});
|
||||
|
||||
test("models: baseURL falls back to auth.baseURL when opts.baseURL absent", async () => {
|
||||
const fetcher = stubFetcher(FIXTURE);
|
||||
const hook = createOmniRouteProviderHook({}, { fetcher, combosFetcher: async () => [] });
|
||||
const out = await hook.models!({} as never, {
|
||||
auth: apiAuth("sk-y", "https://or.creds-attached.example/v1") as never,
|
||||
});
|
||||
assert.equal(fetcher.callCount(), 1);
|
||||
assert.equal(fetcher.callsBy()[0][0], "https://or.creds-attached.example/v1");
|
||||
assert.equal(Object.keys(out).length, 3);
|
||||
});
|
||||
|
||||
test("models: maps a sample /v1/models entry to ModelV2 (sanity)", async () => {
|
||||
const fetcher = stubFetcher(FIXTURE);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher, combosFetcher: async () => [] }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-abc") as never });
|
||||
const claude = out["claude-primary"];
|
||||
assert.ok(claude, "claude-primary present");
|
||||
assert.equal(claude.id, "claude-primary");
|
||||
assert.equal(claude.name, "claude-primary");
|
||||
assert.equal(claude.providerID, "omniroute");
|
||||
assert.equal(claude.api.id, "openai-compatible");
|
||||
assert.equal(claude.api.url, "https://or.example.com/v1");
|
||||
assert.equal(claude.api.npm, "@ai-sdk/openai-compatible");
|
||||
// capabilities: toolcall (one word), reasoning OR thinking, attachment = vision
|
||||
assert.equal(claude.capabilities.toolcall, true);
|
||||
assert.equal(claude.capabilities.reasoning, true);
|
||||
assert.equal(claude.capabilities.attachment, true);
|
||||
assert.equal(claude.capabilities.temperature, true);
|
||||
// modalities mapped from arrays
|
||||
assert.equal(claude.capabilities.input.text, true);
|
||||
assert.equal(claude.capabilities.input.image, true);
|
||||
assert.equal(claude.capabilities.input.audio, false);
|
||||
assert.equal(claude.capabilities.output.text, true);
|
||||
assert.equal(claude.capabilities.output.image, false);
|
||||
// cost is zeroed (OmniRoute /v1/models has no pricing)
|
||||
assert.deepEqual(claude.cost, { input: 0, output: 0, cache: { read: 0, write: 0 } });
|
||||
// limits
|
||||
assert.equal(claude.limit.context, 200000);
|
||||
assert.equal(claude.limit.output, 64000);
|
||||
assert.equal(claude.status, "active");
|
||||
});
|
||||
|
||||
test("mapRawModelToModelV2: thinking-only model still surfaces reasoning=true", () => {
|
||||
const m = mapRawModelToModelV2(
|
||||
{
|
||||
id: "thinking-only",
|
||||
capabilities: { thinking: true, reasoning: false },
|
||||
context_length: 100000,
|
||||
max_output_tokens: 8192,
|
||||
},
|
||||
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" }
|
||||
);
|
||||
assert.equal(m.capabilities.reasoning, true);
|
||||
});
|
||||
|
||||
test("mapRawModelToModelV2: missing capabilities defaults to all-false (except temperature)", () => {
|
||||
const m = mapRawModelToModelV2(
|
||||
{ id: "minimal" },
|
||||
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" }
|
||||
);
|
||||
assert.equal(m.capabilities.temperature, true);
|
||||
assert.equal(m.capabilities.reasoning, false);
|
||||
assert.equal(m.capabilities.attachment, false);
|
||||
assert.equal(m.capabilities.toolcall, false);
|
||||
// default modalities = text only
|
||||
assert.equal(m.capabilities.input.text, true);
|
||||
assert.equal(m.capabilities.output.text, true);
|
||||
// missing context / output tokens → 0 fallback (ModelV2.limit.{context,output} required)
|
||||
assert.equal(m.limit.context, 0);
|
||||
assert.equal(m.limit.output, 0);
|
||||
});
|
||||
|
||||
test("models: caches result for second call within TTL (fetcher called once)", async () => {
|
||||
const fetcher = stubFetcher(FIXTURE);
|
||||
let nowMs = 1_000_000;
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1", modelCacheTtl: 60_000 },
|
||||
{ fetcher, now: () => nowMs, combosFetcher: async () => [] }
|
||||
);
|
||||
|
||||
const a = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
nowMs += 30_000; // half the TTL
|
||||
const b = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.equal(fetcher.callCount(), 1, "second call within TTL must hit the cache");
|
||||
assert.equal(Object.keys(a).length, 3);
|
||||
assert.equal(Object.keys(b).length, 3);
|
||||
});
|
||||
|
||||
test("models: refetches after TTL expires", async () => {
|
||||
const fetcher = stubFetcher(FIXTURE);
|
||||
let nowMs = 1_000_000;
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1", modelCacheTtl: 60_000 },
|
||||
{ fetcher, now: () => nowMs, combosFetcher: async () => [] }
|
||||
);
|
||||
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
nowMs += 60_001; // just past the TTL
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.equal(fetcher.callCount(), 2, "call past TTL must refetch");
|
||||
});
|
||||
|
||||
test("models: caches per (baseURL, apiKey) tuple (different keys → independent fetches)", async () => {
|
||||
const fetcher = stubFetcher(FIXTURE);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1", modelCacheTtl: 300_000 },
|
||||
{ fetcher, combosFetcher: async () => [] }
|
||||
);
|
||||
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-A") as never });
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-B") as never });
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-A") as never }); // cached
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-B") as never }); // cached
|
||||
assert.equal(fetcher.callCount(), 2, "one fetch per distinct apiKey, then cache hits");
|
||||
});
|
||||
|
||||
test("models: caches per (baseURL, apiKey) tuple (different baseURL → independent fetches)", async () => {
|
||||
const fetcher = stubFetcher(FIXTURE);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ modelCacheTtl: 300_000 }, // no opts.baseURL → falls back to auth.baseURL
|
||||
{ fetcher, combosFetcher: async () => [] }
|
||||
);
|
||||
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-same", "https://prod.example/v1") as never });
|
||||
await hook.models!({} as never, {
|
||||
auth: apiAuth("sk-same", "https://preprod.example/v1") as never,
|
||||
});
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-same", "https://prod.example/v1") as never }); // cached
|
||||
assert.equal(fetcher.callCount(), 2, "distinct baseURLs share apiKey but not cache");
|
||||
});
|
||||
73
@omniroute/opencode-plugin/tests/scaffold.test.ts
Normal file
73
@omniroute/opencode-plugin/tests/scaffold.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import {
|
||||
OmniRoutePlugin,
|
||||
OMNIROUTE_PROVIDER_KEY,
|
||||
DEFAULT_MODEL_CACHE_TTL_MS,
|
||||
resolveOmniRoutePluginOptions,
|
||||
} from "../src/index.js";
|
||||
|
||||
test("scaffold: exports public surface", () => {
|
||||
assert.equal(
|
||||
typeof OmniRoutePlugin,
|
||||
"function",
|
||||
"OmniRoutePlugin must be a function (Plugin factory)"
|
||||
);
|
||||
assert.equal(OMNIROUTE_PROVIDER_KEY, "omniroute");
|
||||
assert.equal(DEFAULT_MODEL_CACHE_TTL_MS, 300_000);
|
||||
});
|
||||
|
||||
test("scaffold: default export is v1 plugin shape { id, server: OmniRoutePlugin }", async () => {
|
||||
const mod = await import("../src/index.js");
|
||||
assert.equal(typeof mod.default, "object");
|
||||
assert.equal(mod.default.id, "@omniroute/opencode-plugin");
|
||||
assert.equal(mod.default.server, mod.OmniRoutePlugin);
|
||||
});
|
||||
|
||||
test("resolveOmniRoutePluginOptions: defaults", () => {
|
||||
const r = resolveOmniRoutePluginOptions();
|
||||
assert.equal(r.providerId, "omniroute");
|
||||
assert.equal(r.displayName, "OmniRoute");
|
||||
assert.equal(r.modelCacheTtl, 300_000);
|
||||
assert.equal(r.baseURL, undefined);
|
||||
});
|
||||
|
||||
test("resolveOmniRoutePluginOptions: custom providerId derives displayName", () => {
|
||||
const r = resolveOmniRoutePluginOptions({ providerId: "omniroute-preprod" });
|
||||
assert.equal(r.providerId, "omniroute-preprod");
|
||||
assert.equal(r.displayName, "OmniRoute (omniroute-preprod)");
|
||||
});
|
||||
|
||||
test("resolveOmniRoutePluginOptions: explicit displayName wins", () => {
|
||||
const r = resolveOmniRoutePluginOptions({
|
||||
providerId: "omniroute-x",
|
||||
displayName: "Custom Label",
|
||||
});
|
||||
assert.equal(r.displayName, "Custom Label");
|
||||
});
|
||||
|
||||
test("resolveOmniRoutePluginOptions: invalid TTL falls back to default", () => {
|
||||
assert.equal(resolveOmniRoutePluginOptions({ modelCacheTtl: 0 }).modelCacheTtl, 300_000);
|
||||
assert.equal(resolveOmniRoutePluginOptions({ modelCacheTtl: -1 }).modelCacheTtl, 300_000);
|
||||
});
|
||||
|
||||
test("resolveOmniRoutePluginOptions: positive TTL respected", () => {
|
||||
assert.equal(resolveOmniRoutePluginOptions({ modelCacheTtl: 60_000 }).modelCacheTtl, 60_000);
|
||||
});
|
||||
|
||||
test("OmniRoutePlugin: returns an empty hooks object (scaffold)", async () => {
|
||||
const fakeCtx = {} as Parameters<typeof OmniRoutePlugin>[0];
|
||||
const hooks = await OmniRoutePlugin(fakeCtx);
|
||||
assert.equal(typeof hooks, "object");
|
||||
assert.notEqual(hooks, null);
|
||||
});
|
||||
|
||||
test("scaffold: CJS default export resolves via require() with v1 shape", () => {
|
||||
const require_ = createRequire(import.meta.url);
|
||||
const cjs = require_("../dist/index.cjs");
|
||||
// after cjsInterop:true, default export is on cjs.default
|
||||
assert.strictEqual(typeof cjs.default, "object");
|
||||
assert.strictEqual(cjs.default.id, "@omniroute/opencode-plugin");
|
||||
assert.strictEqual(typeof cjs.default.server, "function");
|
||||
});
|
||||
20
@omniroute/opencode-plugin/tsconfig.json
Normal file
20
@omniroute/opencode-plugin/tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
20
@omniroute/opencode-plugin/tsup.config.ts
Normal file
20
@omniroute/opencode-plugin/tsup.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["esm", "cjs"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: false,
|
||||
splitting: false,
|
||||
treeshake: false,
|
||||
target: "node22",
|
||||
outDir: "dist",
|
||||
minify: false,
|
||||
cjsInterop: true,
|
||||
// Bundle runtime deps so the .tgz / npm install is self-contained.
|
||||
// `zod` is required at runtime by the options schema and would otherwise
|
||||
// need a peer install when the plugin is loaded directly from a file path
|
||||
// in opencode.jsonc.
|
||||
noExternal: ["zod"],
|
||||
});
|
||||
Reference in New Issue
Block a user