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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,17 @@ 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`. 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

### Fixed
Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -114,10 +114,50 @@ 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.

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",
"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
90 changes: 90 additions & 0 deletions src/catalog.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,9 @@ import { describe, expect, test } from "bun:test"
import {
discoverModelProtocols,
discoverModels,
filterModels,
globToRegExp,
hasModelFilter,
normalizeBaseURL,
parseCatalog,
parseModelProtocolCatalog,
Expand DownExpand Up@@ -137,3 +140,90 @@ 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)
})

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", () => {
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([])
})
})
48 changes: 48 additions & 0 deletions src/catalog.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,11 @@ export type ModelProtocolCatalog = Record<
}
>

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

type CatalogResponse = {
data?: unknown
}
Expand DownExpand Up@@ -111,6 +116,49 @@ 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 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) {
if (char === "*") {
source += ".*"
} else if (char === "?") {
source += "."
} else {
source += escapeRegExpChar(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 escapeRegExpChar(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)
}
Loading