Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ 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`. 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

### Fixed
Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions src/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test"
import {
discoverModelProtocols,
discoverModels,
filterModels,
globToRegExp,
normalizeBaseURL,
parseCatalog,
parseModelProtocolCatalog,
Expand Down Expand Up @@ -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([])
})
})
44 changes: 44 additions & 0 deletions src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export type ModelProtocolCatalog = Record<
}
>

export type ModelFilter = {
include?: string[]
exclude?: string[]
}

type CatalogResponse = {
data?: unknown
}
Expand Down Expand Up @@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
148 changes: 148 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,4 +191,152 @@ 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("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 () =>
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
}
})
})
Loading