diff --git a/@omniroute/opencode-plugin/README.md b/@omniroute/opencode-plugin/README.md index 5f09259716..55aff38434 100644 --- a/@omniroute/opencode-plugin/README.md +++ b/@omniroute/opencode-plugin/README.md @@ -196,6 +196,7 @@ npm install --prefix ~/.config/opencode/plugins/omniroute-opencode-plugin-prepro | Compression pipeline tags | Combo names get tagged with their compression pipeline (e.g. `Combo: claude-primary [rtk🟑 β†’ caveman🟠]`) when `features.compressionMetadata: true`. Intensity tokens render as a traffic-light emoji: 🟒 lite/minimal Β· 🟑 standard Β· 🟠 aggressive/full Β· πŸ”΄ ultra | both hooks | | Provider-tag prefix | Prepend short upstream-provider label to enriched names (e.g. `Claude - Claude Opus 4.7` vs `Kiro - Claude Opus 4.7`, `GHM - GPT 5`) so same-id models routed via different upstream connections group visibly in the picker (default-on, opt-out via `features.providerTag: false`) | both hooks | | Usable-only filter | Filter to providers with at least one healthy connection in `/api/providers` (opt-in via `features.usableOnly`) | both hooks | +| Model allowlist/blocklist | Curate the model picker to a fixed set of IDs via `features.visibleModels` (allowlist) and/or `features.hiddenModels` (blocklist). Bare suffixes like `claude-opus-4-7` match any `{prefix}/claude-opus-4-7`. Both compose with `usableOnly` (all filters AND together). Blocklist wins over allowlist (deny takes precedence) | both hooks | | Disk-cache fallback | Last-known-good catalog persisted to disk; hydrates on a cold start when `/v1/models` is unreachable (default-on, opt-out via `features.diskCache: false`) | `config` | | Bearer injection + suffix-spoof guard | Adds `Authorization` on baseURL-matched requests only | `auth.loader.fetch` | | Gemini schema sanitization | Strips `$schema`/`$ref`/`additionalProperties` for `gemini-*`/`google-vertex-gemini/*` | `auth.loader.fetch` wrap | @@ -226,6 +227,8 @@ Every field is optional. Defaults mirror v0.1.0 behaviour so existing `opencode. | `compressionMetadata` | `boolean` | `false` | Pull `/api/context/combos` so combo names get tagged with their compression pipeline, e.g. `Combo: claude-primary [rtk🟑 β†’ caveman🟠]`. Intensity tokens render as traffic-light emoji (🟒 lite/minimal Β· 🟑 standard Β· 🟠 aggressive/full Β· πŸ”΄ ultra) so the picker advertises "how compressed" each combo is at a glance. | | `providerTag` | `boolean` | `true` | Prepend a short upstream-provider label to the enriched display name with `" - "` separator, so `cc/claude-opus-4-7 β†’ Claude - Claude Opus 4.7` differs visibly from `kr/claude-opus-4-7 β†’ Kiro - Claude Opus 4.7` in the OC TUI model picker. Label resolution: use `/api/pricing/models[].name` verbatim when ≀8 chars (e.g. `Claude`, `Kiro`, `Codex`, `Qwen`), otherwise fall back to `UPPER(alias)` (e.g. `GitHub Models` β†’ `GHM`, `Gemini` β†’ `GEMINI`). Idempotent. Combos intentionally skipped (the `Combo:` prefix already conveys multi-upstream). | | `usableOnly` | `boolean` | `false` | Read `/api/providers` and filter the catalog to providers that have at least one connection with `isActive: true` AND `testStatus: 'active'`. Subtract-filter semantics: providers unknown to BOTH the pricing-models catalog AND the connection table pass through (so synthetic prefixes like `agentrouter/*` survive). On fetch failure the filter is disabled for the refresh β€” never hides the whole catalog. | +| `visibleModels` | `string[]` | _unset_ | Allowlist β€” when set and non-empty, only models whose raw `/v1/models` ID matches are emitted. Bare IDs (no slash, e.g. `claude-opus-4-7`) match any `{prefix}/claude-opus-4-7`; full IDs (e.g. `cc/claude-opus-4-7`) match exactly. Composes with `usableOnly` and `hiddenModels` (all filters AND together). Unset or empty = no filter. | +| `hiddenModels` | `string[]` | _unset_ | Blocklist β€” models whose raw ID matches are dropped. Same matching rules as `visibleModels`. When a model is in both `visibleModels` and `hiddenModels`, the blocklist wins (deny takes precedence). Composes with `usableOnly` and `visibleModels` (all filters AND together). Unset or empty = no filter. | | `diskCache` | `boolean` | `true` | Persist the last successful `/v1/models` + `/api/combos` + enrichment + connections + compression snapshot to `${OPENCODE_DATA_DIR ?? ~/.local/share/opencode}/plugins/omniroute-.json`. On a subsequent cold start where `/v1/models` throws (network down / IP whitelist drop / 5xx) the static block hydrates from the snapshot so OC's model picker survives offline. Soft-fail on read/write β€” never blocks publishing. | | `geminiSanitization` | `boolean` | `true` | Strip `$schema`/`$ref`/`additionalProperties` from tool params when the model id matches `gemini` | | `mcpAutoEmit` | `boolean` | `false` | Auto-write an `mcp.` remote entry into the OC config pointing at `/api/mcp/stream` with the resolved Bearer token | @@ -298,7 +301,45 @@ If you want a narrower-scoped Bearer for MCP (different from the chat/inference - `compressionMetadata: true` annotates combo display names with their pipeline using traffic-light emoji for intensity (e.g. `Combo: claude-primary [rtk🟑 β†’ caveman🟠]`) so the picker advertises which compression each combo applies and how heavy it is at a glance. Palette: 🟒 lite/minimal Β· 🟑 standard Β· 🟠 aggressive/full Β· πŸ”΄ ultra. Unknown intensities fall through to raw text (`[rtk:custom-thing]`) so the plugin never hides a value OmniRoute knows but the plugin doesn't. - `providerTag: true` (default) prepends a short upstream-provider label so the picker shows `Claude - Claude Opus 4.7` for `cc/claude-opus-4-7`, `Kiro - Claude Opus 4.7` for `kr/claude-opus-4-7`, and `GHM - GPT 5` for `ghm/gpt-5` (slot.name `GitHub Models` > 8 chars β†’ abbreviated). Critical when the same model id is sold through multiple upstream connections with different cost/auth/rate-limit profiles. Set to `false` to keep the pre-v3.8.3 unsuffixed format. -## Comparison vs `@omniroute/opencode-provider` +#### Example β€” curating the model picker (allowlist + blocklist) + +A typical OmniRoute instance serves 600+ models. The OpenCode TUI/CLI picker becomes unusable when you need to scroll through hundreds of entries to find the ~30 models you actually use. `visibleModels` and `hiddenModels` let you curate the picker to a fixed set of model IDs that persists in `opencode.json` across config resets. + +```jsonc +{ + "plugin": [ + [ + "@omniroute/opencode-plugin", + { + "providerId": "omniroute", + "baseURL": "https://or.example.com", + "features": { + "combos": true, + "enrichment": true, + "usableOnly": true, + "visibleModels": [ + "claude-opus-4-7", // bare suffix: matches cc/claude-opus-4-7, kr/claude-opus-4-7, etc. + "cc/claude-sonnet-4-6", // exact: only the cc/ alias + "gemini-2.5-pro", + "gpt-5", + "o3", + "o3-pro", + "o4-mini", + ], + "hiddenModels": [ + "o3-mini", // hide the mini variant even if visibleModels is unset + ], + }, + }, + ], + ], +} +``` + +- `visibleModels` is an allowlist β€” only models whose raw ID matches are emitted. Bare IDs (no slash) match any provider prefix; full IDs (with slash) match exactly. +- `hiddenModels` is a blocklist β€” listed models are dropped. When a model is in both lists, the blocklist wins (deny takes precedence). +- Both compose with `usableOnly` (all filters AND together: a model must pass usableOnly AND visibleModels AND not be in hiddenModels). +- Unset or empty = no filter (current behavior). [`@omniroute/opencode-provider`](https://github.com/diegosouzapw/OmniRoute/tree/main/%40omniroute/opencode-provider) is the existing config-generator package β€” it writes a frozen `provider.` block into `opencode.json` at build time. This plugin is the runtime integration. diff --git a/@omniroute/opencode-plugin/package-lock.json b/@omniroute/opencode-plugin/package-lock.json index b75338eb09..82fac18dc1 100644 --- a/@omniroute/opencode-plugin/package-lock.json +++ b/@omniroute/opencode-plugin/package-lock.json @@ -1,12 +1,12 @@ { "name": "@omniroute/opencode-plugin", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@omniroute/opencode-plugin", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { "zod": "^4.4.3" diff --git a/@omniroute/opencode-plugin/package.json b/@omniroute/opencode-plugin/package.json index 97ab734d4a..848bca4b3a 100644 --- a/@omniroute/opencode-plugin/package.json +++ b/@omniroute/opencode-plugin/package.json @@ -23,7 +23,7 @@ "scripts": { "build": "tsup", "clean": "rm -rf dist", - "test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts", + "test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts", "prepublishOnly": "npm run clean && npm run build && npm test" }, "keywords": [ diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index 5823bc06bc..681b2fe3d0 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -177,6 +177,8 @@ const featuresSchema = z mcpToken: z.string().min(1).optional(), fetchInterceptor: z.boolean().optional(), usableOnly: z.boolean().optional(), + visibleModels: z.array(z.string().min(1)).optional(), + hiddenModels: z.array(z.string().min(1)).optional(), diskCache: z.boolean().optional(), providerTag: z.boolean().optional(), debugLog: z.boolean().optional(), @@ -241,6 +243,11 @@ export const OMNIROUTE_FEATURE_DEFAULTS = { // default-OFF (read sites use `features.X === true`) compressionMetadata: false, usableOnly: false, + // Array flags: unset/empty = no filter. These are not boolean toggles β€” + // they are operator-curated model-ID lists applied in the dynamic and static + // hooks alongside usableOnly (all filters AND together). + // visibleModels: undefined, // allowlist β€” only listed IDs pass + // hiddenModels: undefined, // blocklist β€” listed IDs are dropped mcpAutoEmit: false, debugLog: false, startupDebug: false, @@ -2826,6 +2833,118 @@ export function isUsableCombo( return false; } +// ───────────────────────────────────────────────────────────────────────── +// #9473 β€” Model allowlist / blocklist filter helpers +// ───────────────────────────────────────────────────────────────────────── + +/** + * Pre-compiled filter structure for the model allowlist/blocklist. + * + * "exact" holds full raw IDs (e.g. "cc/claude-opus-4-7") for O(1) match. + * "suffixes" holds bare model IDs (e.g. "claude-opus-4-7") that match any + * "{prefix}/claude-opus-4-7" β€” so operators can curate by model name without + * knowing the provider prefix. + */ +export interface ModelListFilter { + exact: Set; + suffixes: Set; +} + +/** + * Compile a string[] of model IDs into a pre-computed filter structure. + * Returns undefined when the list is empty or undefined β€” the "no filter" + * state that callers use as a passthrough. + * + * IDs containing a "/" are stored in "exact"; bare IDs (no slash) go into + * "suffixes" and match any "{prefix}/" at check time. + */ +export function compileModelListFilter(list?: string[]): ModelListFilter | undefined { + if (!list || list.length === 0) return undefined; + const exact = new Set(); + const suffixes = new Set(); + for (const id of list) { + if (id.includes("/")) { + exact.add(id); + } else { + suffixes.add(id); + } + } + if (exact.size === 0 && suffixes.size === 0) return undefined; + return { exact, suffixes }; +} + +/** + * Decide whether a raw model ID passes the allowlist/blocklist filter. + * + * Rules (all filters AND together with usableOnly): + * - No visible filter and no hidden filter β†’ keep (passthrough). + * - Visible filter set: id must match either the exact set or the suffix + * set (bare suffix "claude-opus-4-7" matches any "{prefix}/claude-opus-4-7"). + * - Hidden filter set: id must NOT match either the exact or suffix set. + * - If id is in BOTH visible and hidden β†’ DROP (deny wins β€” safer). + * - No-slash ids (e.g. combo names like "claude-primary") are checked + * against the exact set directly, and against the suffix set as a bare + * match. + * + * Pure function β€” exported so static + dynamic hooks share the same + * verdict logic without divergence. + */ +export function passesModelAllowlist( + id: string, + visible?: ModelListFilter, + hidden?: ModelListFilter +): boolean { + // Hidden filter takes precedence (deny wins over allow). + if (hidden) { + if (hidden.exact.has(id) || matchesSuffix(id, hidden.suffixes)) return false; + } + // Visible filter: if set, id must match. + if (visible) { + if (!visible.exact.has(id) && !matchesSuffix(id, visible.suffixes)) return false; + } + return true; +} + +/** + * Decide whether a combo passes the allowlist filter. A combo keeps when + * AT LEAST ONE of its members matches the visible filter. When no visible + * filter is set, all combos pass. Combos with zero resolvable members pass + * (mirrors `isUsableCombo` semantics). + */ +export function passesComboAllowlist( + combo: OmniRouteRawCombo, + visible?: ModelListFilter +): boolean { + if (!visible) return true; + const steps = Array.isArray(combo.models) ? combo.models : []; + if (steps.length === 0) return true; + let sawResolvableMember = false; + for (const step of steps) { + if (step?.kind === "combo-ref") continue; + const modelId = typeof step?.model === "string" ? step.model : ""; + if (modelId.length === 0) continue; + sawResolvableMember = true; + if (visible.exact.has(modelId) || matchesSuffix(modelId, visible.suffixes)) return true; + } + // No resolvable member β†’ can't prove it should be hidden; keep. + if (!sawResolvableMember) return true; + // Every resolvable member failed the allowlist β†’ drop. + return false; +} + +/** + * Check whether a raw model ID matches any suffix in the set. + * For an id like `cc/claude-opus-4-7`, the suffix after the first `/` + * is checked against the suffixes set. For a bare id like `claude-primary`, + * the id itself is checked against the suffixes set. + */ +function matchesSuffix(id: string, suffixes: Set): boolean { + if (suffixes.size === 0) return false; + const slash = id.indexOf("/"); + const suffix = slash > 0 ? id.slice(slash + 1) : id; + return suffixes.has(suffix); +} + /** * Slugify a combo display name into a copy/paste-friendly URL-safe segment. * Lowercases, replaces any run of non-alphanumeric chars with a single dash, @@ -3009,6 +3128,9 @@ export function createOmniRouteProviderHook( const wantCompressionMeta = features.compressionMetadata === true; const wantUsableOnly = features.usableOnly === true; const wantProviderTag = features.providerTag !== false; + // #9473: model allowlist/blocklist β€” compile once per hook instance. + const visibleFilter = compileModelListFilter(features.visibleModels); + const hiddenFilter = compileModelListFilter(features.hiddenModels); const now = deps.now ?? Date.now; // T-07: cache holds RAW fetch results (not pre-derived ModelV2) so that // the config-shim hook can share the same cache and derive its stripped @@ -3243,6 +3365,8 @@ export function createOmniRouteProviderHook( if (!entry.id) continue; if (canonicalDedup.has(entry.id)) continue; if (usable && !isUsableRawModelId(entry.id, usable, rawEnrichment)) continue; + // #9473: allowlist/blocklist filter (AND with usableOnly). + if (!passesModelAllowlist(entry.id, visibleFilter, hiddenFilter)) continue; const model = mapRawModelToModelV2(entry, { // #6859: server-facing id β€” NOT the OC-gate-prefixed `resolved.providerId`. providerId: resolved.omnirouteProviderId, @@ -3318,6 +3442,8 @@ export function createOmniRouteProviderHook( if (!combo.id) return false; if (combo.isHidden === true) return false; if (usable && !isUsableCombo(combo, usable)) return false; + // #9473: combo allowlist β€” drop when no member matches visible filter. + if (visibleFilter && !passesComboAllowlist(combo, visibleFilter)) return false; return true; }); // Resolved nested combos keyed by their friendly name, so parent @@ -4135,6 +4261,9 @@ export function buildStaticProviderEntry( wantUsableOnly && connections && connections.length > 0 ? usableProviderAliasSet(connections, enrichment) : undefined; + // #9473: model allowlist/blocklist β€” compile once per static-block build. + const visibleFilter = compileModelListFilter(opts.features?.visibleModels); + const hiddenFilter = compileModelListFilter(opts.features?.hiddenModels); // Provider-tag suffix β€” default-on, opt-out via `features.providerTag: false`. // Prepends e.g. `Claude - ` to enriched raw-model names so the picker // can tell `cc/claude-opus-4-7` (Anthropic) apart from `kr/claude-opus-4-7` @@ -4172,6 +4301,8 @@ export function buildStaticProviderEntry( // Skip canonical-named twins when the alias-keyed enriched row exists. if (canonicalDedup.has(raw.id)) continue; if (usable && !isUsableRawModelId(raw.id, usable, enrichment)) continue; + // #9473: allowlist/blocklist filter (AND with usableOnly). + if (!passesModelAllowlist(raw.id, visibleFilter, hiddenFilter)) continue; const caps = raw.capabilities ?? {}; // Enrichment overlay: `/api/pricing/models` carries human display names // (e.g. "Claude Opus 4.7" for raw id "cc/claude-opus-4-7"). The OC TUI @@ -4324,6 +4455,8 @@ export function buildStaticProviderEntry( if (!combo.id) return false; if (combo.isHidden === true) return false; if (usable && !isUsableCombo(combo, usable)) return false; + // #9473: combo allowlist β€” drop when no member matches visible filter. + if (visibleFilter && !passesComboAllowlist(combo, visibleFilter)) return false; return true; }); diff --git a/@omniroute/opencode-plugin/tests/model-allowlist.test.ts b/@omniroute/opencode-plugin/tests/model-allowlist.test.ts new file mode 100644 index 0000000000..de22aa2bba --- /dev/null +++ b/@omniroute/opencode-plugin/tests/model-allowlist.test.ts @@ -0,0 +1,317 @@ +/** + * #9473 β€” Model allowlist/blocklist for the opencode-plugin. + * + * Tests for the pure filter helpers (`compileModelListFilter`, + * `passesModelAllowlist`, `passesComboAllowlist`) and the schema + hook-level + * integration. The allowlist/blocklist composes with `usableOnly` (all filters + * AND together), blocklist wins over allowlist (deny takes precedence), and + * bare-suffix entries (e.g. "claude-opus-4-7") match any "{prefix}/claude-opus-4-7". + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + compileModelListFilter, + passesModelAllowlist, + passesComboAllowlist, + parseOmniRoutePluginOptions, + buildStaticProviderEntry, + resolveOmniRoutePluginOptions, + type OmniRouteRawCombo, + type OmniRouteRawModelEntry, +} from "../src/index.js"; + +// ───────────────────────────────────────────────────────────────────────── +// compileModelListFilter +// ───────────────────────────────────────────────────────────────────────── + +test("compileModelListFilter: undefined list β†’ undefined", () => { + assert.equal(compileModelListFilter(undefined), undefined); +}); + +test("compileModelListFilter: empty array β†’ undefined", () => { + assert.equal(compileModelListFilter([]), undefined); +}); + +test("compileModelListFilter: raw IDs with slash β†’ exact set populated", () => { + const f = compileModelListFilter(["cc/claude-opus-4-7", "glm/gpt-5"]); + assert.ok(f); + assert.equal(f.exact.has("cc/claude-opus-4-7"), true); + assert.equal(f.exact.has("glm/gpt-5"), true); + assert.equal(f.suffixes.size, 0); +}); + +test("compileModelListFilter: bare IDs (no slash) β†’ suffixes set populated", () => { + const f = compileModelListFilter(["claude-opus-4-7", "gpt-5"]); + assert.ok(f); + assert.equal(f.suffixes.has("claude-opus-4-7"), true); + assert.equal(f.suffixes.has("gpt-5"), true); + assert.equal(f.exact.size, 0); +}); + +test("compileModelListFilter: mixed raw + bare β†’ both sets populated", () => { + const f = compileModelListFilter(["cc/claude-opus-4-7", "gpt-5"]); + assert.ok(f); + assert.equal(f.exact.has("cc/claude-opus-4-7"), true); + assert.equal(f.suffixes.has("gpt-5"), true); +}); + +// ───────────────────────────────────────────────────────────────────────── +// passesModelAllowlist +// ───────────────────────────────────────────────────────────────────────── + +test("passesModelAllowlist: no visible, no hidden β†’ keep (passthrough)", () => { + assert.equal(passesModelAllowlist("cc/claude-opus-4-7", undefined, undefined), true); +}); + +test("passesModelAllowlist: visible undefined, hidden undefined β†’ keep", () => { + assert.equal(passesModelAllowlist("cc/claude-opus-4-7", undefined, undefined), true); +}); + +test("passesModelAllowlist: visible set, id matches exact β†’ keep", () => { + const vis = compileModelListFilter(["cc/claude-opus-4-7"]); + assert.equal(passesModelAllowlist("cc/claude-opus-4-7", vis, undefined), true); +}); + +test("passesModelAllowlist: visible set, id matches suffix β†’ keep", () => { + const vis = compileModelListFilter(["claude-opus-4-7"]); + assert.equal(passesModelAllowlist("cc/claude-opus-4-7", vis, undefined), true); +}); + +test("passesModelAllowlist: visible set, id does NOT match β†’ drop", () => { + const vis = compileModelListFilter(["cc/claude-opus-4-7"]); + assert.equal(passesModelAllowlist("glm/gpt-5", vis, undefined), false); +}); + +test("passesModelAllowlist: visible set, bare suffix matches different prefix β†’ keep", () => { + const vis = compileModelListFilter(["claude-opus-4-7"]); + assert.equal(passesModelAllowlist("kr/claude-opus-4-7", vis, undefined), true); +}); + +test("passesModelAllowlist: hidden set, id matches exact β†’ drop", () => { + const hid = compileModelListFilter(["cc/claude-opus-4-7"]); + assert.equal(passesModelAllowlist("cc/claude-opus-4-7", undefined, hid), false); +}); + +test("passesModelAllowlist: hidden set, id matches suffix β†’ drop", () => { + const hid = compileModelListFilter(["claude-opus-4-7"]); + assert.equal(passesModelAllowlist("cc/claude-opus-4-7", undefined, hid), false); +}); + +test("passesModelAllowlist: hidden set, id does NOT match β†’ keep", () => { + const hid = compileModelListFilter(["cc/claude-opus-4-7"]); + assert.equal(passesModelAllowlist("glm/gpt-5", undefined, hid), true); +}); + +test("passesModelAllowlist: id in BOTH visible and hidden β†’ DROP (deny wins)", () => { + const vis = compileModelListFilter(["cc/claude-opus-4-7"]); + const hid = compileModelListFilter(["cc/claude-opus-4-7"]); + assert.equal(passesModelAllowlist("cc/claude-opus-4-7", vis, hid), false); +}); + +test("passesModelAllowlist: visible allows, hidden blocks different id β†’ keep the visible one", () => { + const vis = compileModelListFilter(["cc/claude-opus-4-7"]); + const hid = compileModelListFilter(["glm/gpt-5"]); + assert.equal(passesModelAllowlist("cc/claude-opus-4-7", vis, hid), true); + assert.equal(passesModelAllowlist("glm/gpt-5", vis, hid), false); +}); + +test("passesModelAllowlist: bare-suffix hidden blocks exact match too", () => { + const hid = compileModelListFilter(["claude-opus-4-7"]); + assert.equal(passesModelAllowlist("cc/claude-opus-4-7", undefined, hid), false); + assert.equal(passesModelAllowlist("kr/claude-opus-4-7", undefined, hid), false); +}); + +test("passesModelAllowlist: no-slash id, visible set has bare match β†’ keep", () => { + const vis = compileModelListFilter(["claude-primary"]); + assert.equal(passesModelAllowlist("claude-primary", vis, undefined), true); +}); + +test("passesModelAllowlist: no-slash id, visible set has no match β†’ drop", () => { + const vis = compileModelListFilter(["cc/claude-opus-4-7"]); + assert.equal(passesModelAllowlist("claude-primary", vis, undefined), false); +}); + +// ───────────────────────────────────────────────────────────────────────── +// passesComboAllowlist +// ───────────────────────────────────────────────────────────────────────── + +function combo(models: OmniRouteRawCombo["models"]): OmniRouteRawCombo { + return { id: "c1", name: "Test Combo", models }; +} + +test("passesComboAllowlist: visible undefined β†’ keep", () => { + const c = combo([{ kind: "model", model: "cc/claude-opus-4-7" }]); + assert.equal(passesComboAllowlist(c, undefined), true); +}); + +test("passesComboAllowlist: β‰₯1 member matches visible β†’ keep", () => { + const vis = compileModelListFilter(["cc/claude-opus-4-7"]); + const c = combo([ + { kind: "model", model: "dead/legacy" }, + { kind: "model", model: "cc/claude-opus-4-7" }, + ]); + assert.equal(passesComboAllowlist(c, vis), true); +}); + +test("passesComboAllowlist: zero members match visible β†’ drop", () => { + const vis = compileModelListFilter(["cc/claude-opus-4-7"]); + const c = combo([ + { kind: "model", model: "glm/gpt-5" }, + { kind: "model", model: "kr/claude-opus-4-7" }, + ]); + assert.equal(passesComboAllowlist(c, vis), false); +}); + +test("passesComboAllowlist: bare suffix matches any prefix β†’ keep", () => { + const vis = compileModelListFilter(["claude-opus-4-7"]); + const c = combo([{ kind: "model", model: "kr/claude-opus-4-7" }]); + assert.equal(passesComboAllowlist(c, vis), true); +}); + +test("passesComboAllowlist: zero members β†’ keep", () => { + const vis = compileModelListFilter(["cc/claude-opus-4-7"]); + assert.equal(passesComboAllowlist(combo([]), vis), true); + assert.equal(passesComboAllowlist(combo(undefined), vis), true); +}); + +test("passesComboAllowlist: only combo-ref steps β†’ keep", () => { + const vis = compileModelListFilter(["cc/claude-opus-4-7"]); + const c = combo([{ kind: "combo-ref", comboName: "nested" }]); + assert.equal(passesComboAllowlist(c, vis), true); +}); + +// ───────────────────────────────────────────────────────────────────────── +// Schema β€” visibleModels / hiddenModels +// ───────────────────────────────────────────────────────────────────────── + +test("parseOmniRoutePluginOptions: visibleModels string[] β†’ preserved", () => { + const r = parseOmniRoutePluginOptions({ + features: { visibleModels: ["cc/claude-opus-4-7", "gpt-5"] }, + }); + assert.deepEqual(r.features?.visibleModels, ["cc/claude-opus-4-7", "gpt-5"]); +}); + +test("parseOmniRoutePluginOptions: hiddenModels string[] β†’ preserved", () => { + const r = parseOmniRoutePluginOptions({ + features: { hiddenModels: ["glm/gpt-5"] }, + }); + assert.deepEqual(r.features?.hiddenModels, ["glm/gpt-5"]); +}); + +test("parseOmniRoutePluginOptions: both lists together β†’ preserved", () => { + const r = parseOmniRoutePluginOptions({ + features: { + visibleModels: ["cc/claude-opus-4-7"], + hiddenModels: ["glm/gpt-5"], + }, + }); + assert.deepEqual(r.features?.visibleModels, ["cc/claude-opus-4-7"]); + assert.deepEqual(r.features?.hiddenModels, ["glm/gpt-5"]); +}); + +test("parseOmniRoutePluginOptions: empty string in visibleModels β†’ rejects", () => { + assert.throws( + () => + parseOmniRoutePluginOptions({ + features: { visibleModels: [""] }, + }), + /Invalid @omniroute\/opencode-plugin options/ + ); +}); + +test("parseOmniRoutePluginOptions: empty string in hiddenModels β†’ rejects", () => { + assert.throws( + () => + parseOmniRoutePluginOptions({ + features: { hiddenModels: [""] }, + }), + /Invalid @omniroute\/opencode-plugin options/ + ); +}); + +test("parseOmniRoutePluginOptions: unknown features key still rejects (strict invariant)", () => { + assert.throws( + () => + parseOmniRoutePluginOptions({ + features: { visibleModels: ["x"], unknownKey: true }, + }), + /Invalid @omniroute\/opencode-plugin options/ + ); +}); + +// ───────────────────────────────────────────────────────────────────────── +// buildStaticProviderEntry β€” allowlist/blocklist integration +// ───────────────────────────────────────────────────────────────────────── + +const FAKE_RAW_MODELS: OmniRouteRawModelEntry[] = [ + { id: "cc/claude-opus-4-7", owned_by: "anthropic" }, + { id: "glm/gpt-5", owned_by: "openai" }, + { id: "kr/claude-opus-4-7", owned_by: "anthropic" }, + { id: "claude-primary", owned_by: "combo" }, +]; + +test("buildStaticProviderEntry: no allowlist β†’ all models emitted", () => { + const opts = resolveOmniRoutePluginOptions({ features: {} }); + const entry = buildStaticProviderEntry(FAKE_RAW_MODELS, [], opts, "http://localhost:20128/v1", "sk-test"); + const ids = Object.keys(entry.models); + assert.ok(ids.includes("cc/claude-opus-4-7"), "cc/claude-opus-4-7 should be present"); + assert.ok(ids.includes("glm/gpt-5"), "glm/gpt-5 should be present"); + assert.ok(ids.includes("kr/claude-opus-4-7"), "kr/claude-opus-4-7 should be present"); +}); + +test("buildStaticProviderEntry: visibleModels filters to only listed IDs", () => { + const opts = resolveOmniRoutePluginOptions({ + features: { visibleModels: ["cc/claude-opus-4-7"] }, + }); + const entry = buildStaticProviderEntry(FAKE_RAW_MODELS, [], opts, "http://localhost:20128/v1", "sk-test"); + const ids = Object.keys(entry.models); + assert.ok(ids.includes("cc/claude-opus-4-7"), "cc/claude-opus-4-7 should be present"); + assert.equal(ids.includes("glm/gpt-5"), false, "glm/gpt-5 should be filtered out"); + assert.equal(ids.includes("kr/claude-opus-4-7"), false, "kr/claude-opus-4-7 should be filtered out"); +}); + +test("buildStaticProviderEntry: hiddenModels drops listed IDs", () => { + const opts = resolveOmniRoutePluginOptions({ + features: { hiddenModels: ["glm/gpt-5"] }, + }); + const entry = buildStaticProviderEntry(FAKE_RAW_MODELS, [], opts, "http://localhost:20128/v1", "sk-test"); + const ids = Object.keys(entry.models); + assert.ok(ids.includes("cc/claude-opus-4-7"), "cc/claude-opus-4-7 should be present"); + assert.equal(ids.includes("glm/gpt-5"), false, "glm/gpt-5 should be hidden"); + assert.ok(ids.includes("kr/claude-opus-4-7"), "kr/claude-opus-4-7 should be present"); +}); + +test("buildStaticProviderEntry: bare-suffix visibleModels matches any prefix", () => { + const opts = resolveOmniRoutePluginOptions({ + features: { visibleModels: ["claude-opus-4-7"] }, + }); + const entry = buildStaticProviderEntry(FAKE_RAW_MODELS, [], opts, "http://localhost:20128/v1", "sk-test"); + const ids = Object.keys(entry.models); + assert.ok(ids.includes("cc/claude-opus-4-7"), "cc/claude-opus-4-7 should match via suffix"); + assert.ok(ids.includes("kr/claude-opus-4-7"), "kr/claude-opus-4-7 should match via suffix"); + assert.equal(ids.includes("glm/gpt-5"), false, "glm/gpt-5 should be filtered out"); +}); + +test("buildStaticProviderEntry: id in both visible and hidden β†’ hidden wins", () => { + const opts = resolveOmniRoutePluginOptions({ + features: { + visibleModels: ["cc/claude-opus-4-7"], + hiddenModels: ["cc/claude-opus-4-7"], + }, + }); + const entry = buildStaticProviderEntry(FAKE_RAW_MODELS, [], opts, "http://localhost:20128/v1", "sk-test"); + const ids = Object.keys(entry.models); + assert.equal(ids.includes("cc/claude-opus-4-7"), false, "deny takes precedence"); +}); + +test("buildStaticProviderEntry: empty visibleModels β†’ no filter (passthrough)", () => { + const opts = resolveOmniRoutePluginOptions({ + features: { visibleModels: [] }, + }); + const entry = buildStaticProviderEntry(FAKE_RAW_MODELS, [], opts, "http://localhost:20128/v1", "sk-test"); + const ids = Object.keys(entry.models); + assert.ok(ids.includes("cc/claude-opus-4-7"), "empty visibleModels should not filter"); + assert.ok(ids.includes("glm/gpt-5"), "empty visibleModels should not filter"); +}); diff --git a/changelog.d/features/9473-opencode-plugin-model-allowlist.md b/changelog.d/features/9473-opencode-plugin-model-allowlist.md new file mode 100644 index 0000000000..2f0a937812 --- /dev/null +++ b/changelog.d/features/9473-opencode-plugin-model-allowlist.md @@ -0,0 +1 @@ +- **feat(opencode-plugin):** added `features.visibleModels` (allowlist) and `features.hiddenModels` (blocklist) to `@omniroute/opencode-plugin` β€” curate the OpenCode TUI/CLI model picker from 600+ catalog entries down to an operator-defined ID list that persists in `opencode.json` across config resets ([#9473](https://github.com/diegosouzapw/OmniRoute/issues/9473))