From be84aaad9a5a89009b42c9847891ce7e6df600d3 Mon Sep 17 00:00:00 2001 From: fsx8 Date: Thu, 6 Aug 2026 11:44:57 +0200 Subject: [PATCH 1/3] 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/3] 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] = { From a86c41c17c8307eb488596cff24e12cffd095c6a Mon Sep 17 00:00:00 2001 From: fsx8 Date: Fri, 21 Aug 2026 21:59:39 +0200 Subject: [PATCH 3/3] fix: apply model filter to existing entries, not the discovered set Gating mergeModels on the surviving discovered IDs dropped hand-added models the filter never targeted: an exclude-only filter removed non-matching manual entries, and an include filter dropped manual entries matching the include glob that CLIProxyAPI does not report. Pass the ModelFilter down instead and test each existing entry against it, so overrides survive exactly when they match the filter. Also derive the filtering flag via hasModelFilter to share emptiness logic, and make glob escaping unicode-mode-safe. --- CHANGELOG.md | 7 ++- README.md | 6 +++ src/catalog.test.ts | 22 +++++++++ src/catalog.ts | 10 ++-- src/index.test.ts | 112 ++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 18 +++---- 6 files changed, 161 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fad528d..d902a37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,11 @@ 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`. Existing model overrides are only preserved for models that - survive filtering. Filtering to zero models fails loudly at startup. + `includeModels`. The filter also applies to existing + `provider.cliproxyapi.models` entries — including models added by hand that + CLIProxyAPI does not report — so matching entries are kept with their + customizations and non-matching entries are hidden. Filtering to zero + models fails loudly at startup. ## [0.1.2] - 2026-07-28 diff --git a/README.md b/README.md index 3d78529..44ed42e 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,12 @@ characters (including `.`) are matched literally. models and takes precedence over `includeModels` when both match. Filtering happens after discovery, so the provider still talks to CLIProxyAPI normally. +The same filter also applies to entries under `provider.cliproxyapi.models` +in your own config, including models CLIProxyAPI does not report: entries +that match the filter are kept (with your customizations applied), while +entries the filter excludes are hidden from OpenCode. Without a filter, +existing entries are preserved unchanged. + ```json { "$schema": "https://opencode.ai/config.json", diff --git a/src/catalog.test.ts b/src/catalog.test.ts index a13ed13..303c279 100644 --- a/src/catalog.test.ts +++ b/src/catalog.test.ts @@ -4,6 +4,7 @@ import { discoverModels, filterModels, globToRegExp, + hasModelFilter, normalizeBaseURL, parseCatalog, parseModelProtocolCatalog, @@ -158,6 +159,27 @@ describe("globToRegExp", () => { expect(globToRegExp("a.b+c").test("a.b+c")).toBe(true) expect(globToRegExp("a.b+c").test("axbxc")).toBe(false) }) + + test("produces unicode-mode-safe sources", () => { + for (const pattern of ["a.b+c", "x-y_z", "p(q)r", "s[t]u", "v{w}z", "gpt-5.6-terra"]) { + expect(() => new RegExp(globToRegExp(pattern).source, "u")).not.toThrow() + } + }) +}) + +describe("hasModelFilter", () => { + test("false without patterns or with only blank entries", () => { + expect(hasModelFilter({})).toBe(false) + expect(hasModelFilter({ include: [], exclude: [] })).toBe(false) + expect(hasModelFilter({ include: ["", " "] })).toBe(false) + expect(hasModelFilter({ include: undefined, exclude: [" "] })).toBe(false) + }) + + test("true when any pattern survives trimming", () => { + expect(hasModelFilter({ include: ["claude-*"] })).toBe(true) + expect(hasModelFilter({ exclude: ["*-image"] })).toBe(true) + expect(hasModelFilter({ include: [" "], exclude: ["gpt-*"] })).toBe(true) + }) }) describe("filterModels", () => { diff --git a/src/catalog.ts b/src/catalog.ts index 1efc8d6..118748e 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -131,6 +131,10 @@ export function filterModels(models: CatalogModel[], filter: ModelFilter): Catal }) } +export function hasModelFilter(filter: ModelFilter): boolean { + return nonEmpty(filter.include) !== undefined || nonEmpty(filter.exclude) !== undefined +} + export function globToRegExp(pattern: string): RegExp { let source = "" for (const char of pattern) { @@ -139,7 +143,7 @@ export function globToRegExp(pattern: string): RegExp { } else if (char === "?") { source += "." } else { - source += escapeRegExp(char) + source += escapeRegExpChar(char) } } return new RegExp(`^${source}$`) @@ -151,8 +155,8 @@ function nonEmpty(values: string[] | undefined): string[] | undefined { return trimmed.length > 0 ? trimmed : undefined } -function escapeRegExp(char: string): string { - return /[$()*+.?[\\\]^{|}-]/.test(char) ? `\\${char}` : char +function escapeRegExpChar(char: string): string { + return /^[\^$\\.*+?()\[\]{}|\/]$/.test(char) ? `\\${char}` : char } function isRecord(value: unknown): value is Record { diff --git a/src/index.test.ts b/src/index.test.ts index 00a6214..da32660 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -300,6 +300,118 @@ describe("CLIProxyAPIPlugin", () => { } }) + test("exclude-only filters keep hand-added models that do not match", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = async () => + Response.json({ + data: [{ id: "claude-sonnet-4-6" }, { id: "gemini-3.1-flash-image" }], + }) + + try { + const plugin = await CLIProxyAPIPlugin( + { + client: { app: { log: async () => ({}) } }, + } as PluginInput, + { + baseURL: "http://cliproxy.test:8317", + apiKey: "secret", + excludeModels: ["*-image"], + }, + ) + const config: Config = { + provider: { + cliproxyapi: { + models: { + "my-hand-added-model": { name: "Hand Added" }, + "gemini-3.1-flash-image": { name: "My Image" }, + }, + }, + }, + } + + await plugin.config?.(config) + + const models = config.provider?.cliproxyapi?.models ?? {} + expect(Object.keys(models).sort()).toEqual(["claude-sonnet-4-6", "my-hand-added-model"]) + expect(models["my-hand-added-model"]?.name).toBe("Hand Added") + } finally { + globalThis.fetch = originalFetch + } + }) + + test("include filters keep matching hand-added models and drop non-matching ones", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = async () => + Response.json({ data: [{ id: "claude-sonnet-4-6" }] }) + + 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: { + "claude-experimental-preview": { name: "Preview" }, + "gpt-5.6-terra": { name: "My Terra" }, + }, + }, + }, + } + + await plugin.config?.(config) + + const models = config.provider?.cliproxyapi?.models ?? {} + expect(Object.keys(models).sort()).toEqual(["claude-experimental-preview", "claude-sonnet-4-6"]) + expect(models["claude-experimental-preview"]?.name).toBe("Preview") + } finally { + globalThis.fetch = originalFetch + } + }) + + test("filters drop hand-added models that match an exclude pattern", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = async () => + Response.json({ data: [{ id: "claude-sonnet-4-6" }] }) + + try { + const plugin = await CLIProxyAPIPlugin( + { + client: { app: { log: async () => ({}) } }, + } as PluginInput, + { + baseURL: "http://cliproxy.test:8317", + apiKey: "secret", + excludeModels: ["my-hand-added-*"], + }, + ) + const config: Config = { + provider: { + cliproxyapi: { + models: { + "my-hand-added-model": { name: "Hand Added" }, + }, + }, + }, + } + + 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 () => diff --git a/src/index.ts b/src/index.ts index 8f321fc..2b74032 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { discoverModelProtocols, discoverModels, filterModels, + hasModelFilter, normalizeBaseURL, type CatalogModel, type ModelFilter, @@ -74,8 +75,8 @@ export const CLIProxyAPIPlugin: Plugin = async ({ client }, rawOptions) => { include: options.includeModels, exclude: options.excludeModels, } - const filteredCatalog = filterModels(catalog, modelFilter) - const filtering = Boolean(modelFilter.include || modelFilter.exclude) + const filtering = hasModelFilter(modelFilter) + const filteredCatalog = filtering ? filterModels(catalog, modelFilter) : catalog if (filtering && filteredCatalog.length === 0) { throw new Error( `CLIProxyAPI model filter matched none of the ${catalog.length} discovered models; check includeModels/excludeModels`, @@ -90,9 +91,7 @@ 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, + modelFilter: filtering ? modelFilter : undefined, }) await client.app.log({ @@ -127,6 +126,7 @@ export { discoverModels, filterModels, globToRegExp, + hasModelFilter, normalizeBaseURL, parseCatalog, parseModelProtocolCatalog, @@ -143,7 +143,7 @@ function addProvider( protocol: "chat" | "responses" catalog: CatalogModel[] protocolCatalog: ModelProtocolCatalog - allowedModelIDs?: Set + modelFilter?: ModelFilter }, ) { const existing = config.provider?.[input.providerID] @@ -190,7 +190,7 @@ function addProvider( baseURL: input.baseURL, ...(input.apiKey ? { apiKey: input.apiKey } : {}), }, - models: mergeModels(discovered, existing?.models, input.allowedModelIDs), + models: mergeModels(discovered, existing?.models, input.modelFilter), }, } } @@ -207,12 +207,12 @@ function resolveModelNpm( function mergeModels( discovered: Record, existing: ProviderConfig["models"], - allowedModelIDs?: Set, + modelFilter?: ModelFilter, ): Record { const merged = { ...discovered } for (const [modelID, model] of Object.entries(existing ?? {})) { - if (allowedModelIDs && !allowedModelIDs.has(modelID)) continue + if (modelFilter && filterModels([{ id: modelID }], modelFilter).length === 0) continue const discoveredModel = merged[modelID] const providerNpm = model.provider?.npm ?? discoveredModel?.provider?.npm merged[modelID] = {