From be84aaad9a5a89009b42c9847891ce7e6df600d3 Mon Sep 17 00:00:00 2001 From: fsx8 Date: Thu, 6 Aug 2026 11:44:57 +0200 Subject: [PATCH 1/2] feat: filter discovered models with include/exclude globs Add includeModels and excludeModels plugin options so only a subset of the models discovered from CLIProxyAPI's /v1/models endpoint is exposed to OpenCode. Entries are glob patterns (* and ?) matched against model IDs; excludeModels takes precedence over includeModels. Filtering to zero models throws at startup so misconfiguration is not silently ignored. --- CHANGELOG.md | 7 +++ README.md | 34 ++++++++++++++ src/catalog.test.ts | 68 +++++++++++++++++++++++++++ src/catalog.ts | 44 ++++++++++++++++++ src/index.test.ts | 109 ++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 34 ++++++++++++-- 6 files changed, 293 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a2d8f5..3b4c58a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- `includeModels` and `excludeModels` plugin options to filter the discovered + CLIProxyAPI catalog so only a subset of models is exposed to OpenCode. + Entries are glob patterns; `excludeModels` takes precedence over + `includeModels`. Filtering to zero models fails loudly at startup. + ## [0.1.2] - 2026-07-28 ### Fixed diff --git a/README.md b/README.md index ffd1ee7..3d78529 100644 --- a/README.md +++ b/README.md @@ -114,10 +114,44 @@ The recommended configuration is the global plugin entry shown above: | `protocol` | `chat` | Default protocol: `chat` uses `/chat/completions`; `responses` uses `/responses`. Models marked as Anthropic-compatible by dynamic metadata override this per model. | | `modelMetadataURL` | `https://models.dev/api.json` | Dynamic model-level protocol metadata. Set to `false` to disable enrichment and use only the default protocol. | | `discoveryTimeoutMs` | `10000` | Startup model-discovery timeout | +| `includeModels` | _(all)_ | Glob allowlist of discovered model IDs to expose (e.g. `["claude-*", "gpt-5.*"]`). When set, only matching models reach OpenCode. | +| `excludeModels` | _(none)_ | Glob denylist of discovered model IDs to hide (e.g. `["*-image", "gpt-4-*"]`). Takes precedence over `includeModels`. | If model metadata cannot be reached, the plugin logs a warning and keeps the CLIProxyAPI-discovered models available with the configured default protocol. +### Filtering discovered models + +When CLIProxyAPI exposes many models but you only want a subset in OpenCode, +filter the discovered catalog with `includeModels` and `excludeModels`. Entries +are glob patterns matched against the model IDs reported by CLIProxyAPI: `*` +matches any run of characters, `?` matches a single character, and all other +characters (including `.`) are matched literally. + +`includeModels` keeps only matching models; `excludeModels` drops matching +models and takes precedence over `includeModels` when both match. Filtering +happens after discovery, so the provider still talks to CLIProxyAPI normally. + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + [ + "opencode-cliproxyapi", + { + "baseURL": "http://your-server:8317", + "apiKey": "your-cli-proxy-api-key", + "includeModels": ["claude-*", "gpt-5.*"], + "excludeModels": ["*-image"] + } + ] + ] +} +``` + +If a filter would remove every discovered model, the plugin fails loudly at +startup with an error so the misconfiguration is not silently ignored. + ### Optional environment variables Environment variables remain available for containers, CI, or users who prefer diff --git a/src/catalog.test.ts b/src/catalog.test.ts index 7e6befc..a13ed13 100644 --- a/src/catalog.test.ts +++ b/src/catalog.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test" import { discoverModelProtocols, discoverModels, + filterModels, + globToRegExp, normalizeBaseURL, parseCatalog, parseModelProtocolCatalog, @@ -137,3 +139,69 @@ describe("discoverModelProtocols", () => { }) }) }) + +describe("globToRegExp", () => { + test.each([ + ["claude-sonnet-4-6", "claude-sonnet-4-6", true], + ["claude-sonnet-4-6", "claude-sonnet-4-7", false], + ["claude-*", "claude-sonnet-4-6", true], + ["claude-*", "gpt-5.6-terra", false], + ["*-image", "gemini-3.1-flash-image", true], + ["gpt-5.?-*", "gpt-5.6-terra", true], + ["gpt-5.?-*", "gpt-5.50-terra", false], + ["gpt-5.*", "gpt-5.6-terra", true], + ])("pattern %s against %s => %s", (pattern, value, expected) => { + expect(globToRegExp(pattern).test(value)).toBe(expected) + }) + + test("escapes regex metacharacters literally", () => { + expect(globToRegExp("a.b+c").test("a.b+c")).toBe(true) + expect(globToRegExp("a.b+c").test("axbxc")).toBe(false) + }) +}) + +describe("filterModels", () => { + const models = [ + { id: "claude-sonnet-4-6", ownedBy: "anthropic" }, + { id: "claude-opus-4-2", ownedBy: "anthropic" }, + { id: "gpt-5.6-terra" }, + { id: "gpt-5.6-mini" }, + { id: "gemini-3.1-flash-image" }, + ] + + test("returns the catalog unchanged when no filter is provided", () => { + expect(filterModels(models, {})).toBe(models) + expect(filterModels(models, { include: [], exclude: [] })).toBe(models) + }) + + test("include keeps only matching models and preserves metadata", () => { + expect(filterModels(models, { include: ["claude-*"] })).toEqual([ + { id: "claude-sonnet-4-6", ownedBy: "anthropic" }, + { id: "claude-opus-4-2", ownedBy: "anthropic" }, + ]) + }) + + test("exclude removes matching models", () => { + expect(filterModels(models, { exclude: ["*-image", "gpt-5.6-mini"] })).toEqual([ + { id: "claude-sonnet-4-6", ownedBy: "anthropic" }, + { id: "claude-opus-4-2", ownedBy: "anthropic" }, + { id: "gpt-5.6-terra" }, + ]) + }) + + test("exclude wins when a model matches both include and exclude", () => { + expect( + filterModels(models, { include: ["claude-*"], exclude: ["claude-opus-*"] }), + ).toEqual([{ id: "claude-sonnet-4-6", ownedBy: "anthropic" }]) + }) + + test("ignores empty or whitespace-only filter entries", () => { + expect(filterModels(models, { include: ["", " ", "gpt-5.6-terra"] })).toEqual([ + { id: "gpt-5.6-terra" }, + ]) + }) + + test("returns an empty array when nothing matches", () => { + expect(filterModels(models, { include: ["does-not-exist"] })).toEqual([]) + }) +}) diff --git a/src/catalog.ts b/src/catalog.ts index d948871..1efc8d6 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -11,6 +11,11 @@ export type ModelProtocolCatalog = Record< } > +export type ModelFilter = { + include?: string[] + exclude?: string[] +} + type CatalogResponse = { data?: unknown } @@ -111,6 +116,45 @@ export async function discoverModelProtocols(input: { return parseModelProtocolCatalog(await response.json()) } +export function filterModels(models: CatalogModel[], filter: ModelFilter): CatalogModel[] { + const include = nonEmpty(filter.include) + const exclude = nonEmpty(filter.exclude) + if (!include && !exclude) return models + + const includeRE = include ? include.map(globToRegExp) : [] + const excludeRE = exclude ? exclude.map(globToRegExp) : [] + + return models.filter((model) => { + if (include && !includeRE.some((re) => re.test(model.id))) return false + if (excludeRE.some((re) => re.test(model.id))) return false + return true + }) +} + +export function globToRegExp(pattern: string): RegExp { + let source = "" + for (const char of pattern) { + if (char === "*") { + source += ".*" + } else if (char === "?") { + source += "." + } else { + source += escapeRegExp(char) + } + } + return new RegExp(`^${source}$`) +} + +function nonEmpty(values: string[] | undefined): string[] | undefined { + if (!values || values.length === 0) return undefined + const trimmed = values.map((value) => value.trim()).filter((value) => value !== "") + return trimmed.length > 0 ? trimmed : undefined +} + +function escapeRegExp(char: string): string { + return /[$()*+.?[\\\]^{|}-]/.test(char) ? `\\${char}` : char +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } diff --git a/src/index.test.ts b/src/index.test.ts index c90351d..8581097 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -191,4 +191,113 @@ describe("CLIProxyAPIPlugin", () => { globalThis.fetch = originalFetch } }) + + test("includeModels exposes only matching models", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = async () => + Response.json({ + data: [ + { id: "claude-sonnet-4-6" }, + { id: "claude-opus-4-2" }, + { id: "gpt-5.6-terra" }, + ], + }) + + try { + const plugin = await CLIProxyAPIPlugin( + { + client: { app: { log: async () => ({}) } }, + } as PluginInput, + { + baseURL: "http://cliproxy.test:8317", + apiKey: "secret", + includeModels: ["claude-*"], + }, + ) + const config: Config = {} + + await plugin.config?.(config) + + const models = config.provider?.cliproxyapi?.models ?? {} + expect(Object.keys(models).sort()).toEqual(["claude-opus-4-2", "claude-sonnet-4-6"]) + } finally { + globalThis.fetch = originalFetch + } + }) + + test("excludeModels drops matching models and exclude wins over include", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = async () => + Response.json({ + data: [ + { id: "claude-sonnet-4-6" }, + { id: "claude-opus-4-2" }, + { id: "gpt-5.6-terra" }, + { id: "gemini-3.1-flash-image" }, + ], + }) + + try { + const plugin = await CLIProxyAPIPlugin( + { + client: { app: { log: async () => ({}) } }, + } as PluginInput, + { + baseURL: "http://cliproxy.test:8317", + apiKey: "secret", + includeModels: ["claude-*"], + excludeModels: ["claude-opus-*", "*-image"], + }, + ) + const config: Config = {} + + await plugin.config?.(config) + + expect(Object.keys(config.provider?.cliproxyapi?.models ?? {})).toEqual([ + "claude-sonnet-4-6", + ]) + } finally { + globalThis.fetch = originalFetch + } + }) + + test("fails loudly when the filter matches no discovered models", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = async () => + Response.json({ data: [{ id: "claude-sonnet-4-6" }] }) + + try { + const logs: unknown[] = [] + const plugin = await CLIProxyAPIPlugin( + { + client: { + app: { + log: async (input: unknown) => { + logs.push(input) + return {} + }, + }, + }, + } as PluginInput, + { + baseURL: "http://cliproxy.test:8317", + apiKey: "secret", + includeModels: ["does-not-exist-*"], + }, + ) + const config: Config = {} + + await expect(plugin.config?.(config)).rejects.toThrow("matched none of the 1 discovered") + + expect(config.provider?.cliproxyapi).toBeUndefined() + expect(logs.at(-1)).toMatchObject({ + body: { + level: "error", + message: expect.stringContaining("model filter matched none"), + }, + }) + } finally { + globalThis.fetch = originalFetch + } + }) }) diff --git a/src/index.ts b/src/index.ts index a35dd02..cfaf62c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,8 +2,10 @@ import type { Config, Plugin, PluginModule, PluginOptions } from "@opencode-ai/p import { discoverModelProtocols, discoverModels, + filterModels, normalizeBaseURL, type CatalogModel, + type ModelFilter, type ModelProtocolCatalog, } from "./catalog.js" @@ -21,6 +23,8 @@ type ConnectorOptions = { protocol?: "chat" | "responses" modelMetadataURL?: string | false discoveryTimeoutMs?: number + includeModels?: string[] + excludeModels?: string[] } type ProviderConfig = NonNullable[string] @@ -66,13 +70,25 @@ export const CLIProxyAPIPlugin: Plugin = async ({ client }, rawOptions) => { }) } + const modelFilter: ModelFilter = { + include: options.includeModels, + exclude: options.excludeModels, + } + const filteredCatalog = filterModels(catalog, modelFilter) + const filtering = Boolean(modelFilter.include || modelFilter.exclude) + if (filtering && filteredCatalog.length === 0) { + throw new Error( + `CLIProxyAPI model filter matched none of the ${catalog.length} discovered models; check includeModels/excludeModels`, + ) + } + addProvider(config, { providerID, providerName: options.providerName ?? DEFAULT_PROVIDER_NAME, baseURL, apiKey, protocol: options.protocol ?? "chat", - catalog, + catalog: filteredCatalog, protocolCatalog: protocolDiscovery.catalog, }) @@ -80,7 +96,9 @@ export const CLIProxyAPIPlugin: Plugin = async ({ client }, rawOptions) => { body: { service: "opencode-cliproxyapi", level: "info", - message: `Discovered ${catalog.length} CLIProxyAPI models from ${baseURL}`, + message: filtering + ? `Discovered ${catalog.length} CLIProxyAPI models from ${baseURL}; ${filteredCatalog.length} exposed after filtering` + : `Discovered ${catalog.length} CLIProxyAPI models from ${baseURL}`, }, }) } catch (error) { @@ -104,11 +122,13 @@ export default { export { discoverModelProtocols, discoverModels, + filterModels, + globToRegExp, normalizeBaseURL, parseCatalog, parseModelProtocolCatalog, } from "./catalog.js" -export type { CatalogModel, ModelProtocolCatalog } from "./catalog.js" +export type { CatalogModel, ModelFilter, ModelProtocolCatalog } from "./catalog.js" function addProvider( config: Config, @@ -214,6 +234,8 @@ function readOptions(input?: PluginOptions): ConnectorOptions { typeof input.discoveryTimeoutMs === "number" && input.discoveryTimeoutMs > 0 ? input.discoveryTimeoutMs : undefined, + includeModels: stringArrayOption(input.includeModels), + excludeModels: stringArrayOption(input.excludeModels), } } @@ -238,6 +260,12 @@ function stringOption(value: unknown) { return typeof value === "string" && value.trim() !== "" ? value : undefined } +function stringArrayOption(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined + const items = value.filter((item): item is string => typeof item === "string" && item.trim() !== "") + return items.length > 0 ? items : undefined +} + function displayName(modelID: string) { return modelID .split("-") From fcb45124eecfb3d6f9f02811c4c07bfce8bfe39f Mon Sep 17 00:00:00 2001 From: fsx8 Date: Thu, 6 Aug 2026 12:09:41 +0200 Subject: [PATCH 2/2] fix: drop existing overrides for filtered-out models mergeModels previously re-merged every entry from existing provider model config, so a user override for a model the filter excludes (e.g. includeModels: ["claude-*"] with an existing gpt-5.6-terra override) was still exposed. Pass an allowlist of surviving model IDs (only when a filter is active) so overrides are preserved only for models that survive filtering. No behavior change when no filter is configured. --- CHANGELOG.md | 3 ++- src/index.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ src/index.ts | 8 +++++++- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b4c58a..fad528d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,8 @@ and this project follows [Semantic Versioning](https://semver.org/). - `includeModels` and `excludeModels` plugin options to filter the discovered CLIProxyAPI catalog so only a subset of models is exposed to OpenCode. Entries are glob patterns; `excludeModels` takes precedence over - `includeModels`. Filtering to zero models fails loudly at startup. + `includeModels`. Existing model overrides are only preserved for models that + survive filtering. Filtering to zero models fails loudly at startup. ## [0.1.2] - 2026-07-28 diff --git a/src/index.test.ts b/src/index.test.ts index 8581097..00a6214 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -261,6 +261,45 @@ describe("CLIProxyAPIPlugin", () => { } }) + test("filtering drops existing overrides for excluded models but keeps included ones", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = async () => + Response.json({ + data: [{ id: "claude-sonnet-4-6" }, { id: "gpt-5.6-terra" }], + }) + + try { + const plugin = await CLIProxyAPIPlugin( + { + client: { app: { log: async () => ({}) } }, + } as PluginInput, + { + baseURL: "http://cliproxy.test:8317", + apiKey: "secret", + includeModels: ["claude-*"], + }, + ) + const config: Config = { + provider: { + cliproxyapi: { + models: { + "gpt-5.6-terra": { name: "My Terra" }, + "claude-sonnet-4-6": { name: "My Claude" }, + }, + }, + }, + } + + await plugin.config?.(config) + + const models = config.provider?.cliproxyapi?.models ?? {} + expect(Object.keys(models).sort()).toEqual(["claude-sonnet-4-6"]) + expect(models["claude-sonnet-4-6"]?.name).toBe("My Claude") + } finally { + globalThis.fetch = originalFetch + } + }) + test("fails loudly when the filter matches no discovered models", async () => { const originalFetch = globalThis.fetch globalThis.fetch = async () => diff --git a/src/index.ts b/src/index.ts index cfaf62c..8f321fc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -90,6 +90,9 @@ export const CLIProxyAPIPlugin: Plugin = async ({ client }, rawOptions) => { protocol: options.protocol ?? "chat", catalog: filteredCatalog, protocolCatalog: protocolDiscovery.catalog, + allowedModelIDs: filtering + ? new Set(filteredCatalog.map((model) => model.id)) + : undefined, }) await client.app.log({ @@ -140,6 +143,7 @@ function addProvider( protocol: "chat" | "responses" catalog: CatalogModel[] protocolCatalog: ModelProtocolCatalog + allowedModelIDs?: Set }, ) { const existing = config.provider?.[input.providerID] @@ -186,7 +190,7 @@ function addProvider( baseURL: input.baseURL, ...(input.apiKey ? { apiKey: input.apiKey } : {}), }, - models: mergeModels(discovered, existing?.models), + models: mergeModels(discovered, existing?.models, input.allowedModelIDs), }, } } @@ -203,10 +207,12 @@ function resolveModelNpm( function mergeModels( discovered: Record, existing: ProviderConfig["models"], + allowedModelIDs?: Set, ): Record { const merged = { ...discovered } for (const [modelID, model] of Object.entries(existing ?? {})) { + if (allowedModelIDs && !allowedModelIDs.has(modelID)) continue const discoveredModel = merged[modelID] const providerNpm = model.provider?.npm ?? discoveredModel?.provider?.npm merged[modelID] = {