From d0a4bc1151a7fc2166883f70bf63b304a1384365 Mon Sep 17 00:00:00 2001 From: Yusef Mohamadi Date: Fri, 11 Sep 2026 21:40:21 +0200 Subject: [PATCH] feat: filter discovered models via includeModels/excludeModels Per-provider glob filters (only `*`, everything else literal) let one LiteLLM proxy be split across several OpenCode providers without hand-maintained model lists. includeModels is evaluated first, excludeModels always wins, and empty/omitted arrays mean no filtering. Based on #21 by Cleverson Sacramento, with two changes for the post-v1.0 architecture: the filter is applied inside discoverModels() rather than at merge time, so the SWR cache and background refresh write the same filtered view instead of the filter silently lapsing after the first refresh; and the dead V2-schema path it also touched no longer exists. Co-authored-by: Cleverson Sacramento --- README.md | 34 +++++++++++++++++++++++++ src/plugin/index.ts | 53 ++++++++++++++++++++++++++++++++++++--- src/utils/index.ts | 1 + src/utils/model-filter.ts | 53 +++++++++++++++++++++++++++++++++++++++ test/model-filter.test.ts | 47 ++++++++++++++++++++++++++++++++++ 5 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 src/utils/model-filter.ts create mode 100644 test/model-filter.test.ts diff --git a/README.md b/README.md index 2c6013b..31e24e2 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ opencode | 🧩 **Reasoning-effort variants** | When LiteLLM reports per-model effort support (`supports_low_reasoning_effort`, …), the plugin surfaces each level as a picker variant automatically. | | 🔐 **Auth-aware** | Honours `LITELLM_API_KEY` / `LITELLM_MASTER_KEY` env vars, `provider.litellm.options.apiKey`, or the key you stored via OpenCode's `/connect`. | | 🌐 **Gateway-friendly** | Supports `customHeaders` for proxies behind Cloudflare Access or other API gateways requiring extra HTTP headers. | +| 🧩 **Splittable catalog** | `includeModels` / `excludeModels` (glob patterns) let one LiteLLM proxy be divided into several OpenCode providers — e.g. by naming prefix — without hand-maintaining a model list. | | ⏱️ **Non-blocking startup** | Health checks fail fast (3 s); discovery fetches are capped at **15 s** (configurable via `LITELLM_REQUEST_TIMEOUT_MS`) for slow remote proxies. Repeat config-hook invocations are a no-op. | | 📝 **TUI-safe logging** | All plugin logs go through OpenCode's log API (into OpenCode's own log files), never to stdout — the TUI stays intact. | | 🤝 **Non-destructive merge** | Only adds models you don't already have configured. Hand-curated entries are preserved verbatim. | @@ -246,6 +247,39 @@ If your LiteLLM proxy is behind Cloudflare Access or another gateway that requir These headers are included in every request the plugin makes during model discovery (health check and `/v1/models`). To obtain a Cloudflare Access Service Token, follow the [Cloudflare docs](https://developers.cloudflare.com/cloudflare-one/identity/service-tokens/). +### Splitting one proxy into multiple providers (`includeModels` / `excludeModels`) + +If your LiteLLM catalog mixes naming conventions from different teams or environments (e.g. `prod/*` and `staging/*`), you can point two OpenCode providers at the *same* proxy and have each one surface only its slice: + +```jsonc +{ + "provider": { + "litellm": { + "npm": "@ai-sdk/openai-compatible", + "name": "Prod", + "options": { + "baseURL": "http://localhost:4000/v1", + "includeModels": ["prod/*"] + } + }, + "litellm-staging": { + "npm": "@ai-sdk/openai-compatible", + "name": "Staging", + "options": { + "baseURL": "http://localhost:4000/v1", + "includeModels": ["staging/*"], + "excludeModels": ["staging/*-canary"] + } + } + } +} +``` + +- `includeModels` is evaluated first — only ids matching at least one pattern are kept. Omit it to keep everything. +- `excludeModels` is evaluated after and always wins, even over `includeModels`. +- Patterns support only `*` (any run of characters); everything else is matched literally, so dots in ids like `gpt-4.1` need no escaping. +- Filtering happens before the on-disk cache is written, so each provider's cached view respects its own filters. + ## 🔧 How it works ```mermaid diff --git a/src/plugin/index.ts b/src/plugin/index.ts index edde7ea..641213f 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -14,6 +14,8 @@ import { import type { LiteLLMModel, LiteLLMModelInfo } from '../types' import { getOpenCodeStoredApiKey } from '../utils/opencode-auth' import { readModelCache, writeModelCache, readModelCacheSavedAt } from '../utils/model-cache' +import { passesModelFilter } from '../utils/model-filter' +import type { ModelFilters } from '../utils/model-filter' const CHAT_PROVIDER_ID = 'litellm' // Covers the 3 s health check plus the parallel models/model-info fetch @@ -70,6 +72,7 @@ interface RefreshContext { baseURL: string apiKey?: string customHeaders?: Record + filters: ModelFilters providerId: string } const refreshContexts = new Map() @@ -127,6 +130,24 @@ function readCustomHeaders( return undefined } +/** + * Read the `includeModels`/`excludeModels` glob filters from a provider + * options block (issue #21's feature: split one proxy's catalog across + * several OpenCode providers). Non-string entries are dropped; an empty + * result means "don't filter". + */ +function readModelFilters(options: Record): ModelFilters { + const readPatterns = (raw: unknown): string[] | undefined => { + if (!Array.isArray(raw)) return undefined + const out = raw.filter((v): v is string => typeof v === 'string') + return out.length > 0 ? out : undefined + } + return { + includeModels: readPatterns(options.includeModels), + excludeModels: readPatterns(options.excludeModels), + } +} + /** * Overlay metadata from `/v1/model/info` onto a `/v1/models` entry. * Fields already present on the lean entry win; the info block only @@ -237,6 +258,11 @@ function toConfigModel( * * Pure with respect to plugin config: it performs the network calls, * classifies + formats each model, and returns a `{ id -> entry }` map. + * The provider's `includeModels`/`excludeModels` filters are applied + * here (not at merge time) so every path that persists or serves a + * cache — cold discovery and background refresh — writes the same + * filtered view. + * * Returns `null` when the proxy is unreachable/unauthorized or exposes * no models, so callers can distinguish "no data" from "empty result". */ @@ -245,6 +271,7 @@ async function discoverModels( apiKey: string | undefined, customHeaders: Record | undefined, providerId: string, + filters: ModelFilters = {}, ): Promise | null> { if (!(await checkLiteLLMHealth(baseURL, apiKey, customHeaders))) { log( @@ -297,6 +324,7 @@ async function discoverModels( const built: Record = {} let skipped = 0 let wildcards = 0 + let filtered = 0 const unmatched: string[] = [] for (const model of discovered) { // `deepseek/*` is an access rule, not a callable model. But a @@ -306,6 +334,13 @@ async function discoverModels( wildcards++ continue } + // `includeModels`/`excludeModels` let one LiteLLM proxy be split + // across several OpenCode providers (e.g. by upstream naming + // prefix) without hand-maintaining a model list. + if (!passesModelFilter(model.id, filters.includeModels, filters.excludeModels)) { + filtered++ + continue + } const info = infoByName?.get(model.id) if (infoByName && !info) unmatched.push(model.id) const entry = toConfigModel(info ? enrichModel(model, info) : model, info) @@ -325,12 +360,23 @@ async function discoverModels( ) } + // Only blame the filters when every non-wildcard model was rejected by + // them — if some hit `skipped` (non-chat) instead, `built` being empty + // has an unrelated cause and this warning would misdirect the user. + if (filtered > 0 && filtered + wildcards === discovered.length) { + log( + 'warn', + `[opencode-litellm] includeModels/excludeModels filtered out all ${filtered} model(s) discovered for provider "${providerId}" — check the glob patterns in options.includeModels/options.excludeModels.`, + ) + } + log( 'info', `[opencode-litellm] Discovered ${discovered.length} models for provider "${providerId}" from ${baseURL} ` + `(${Object.keys(built).length} built` + (skipped > 0 ? `, ${skipped} non-chat hidden` : '') + (wildcards > 0 ? `, ${wildcards} wildcard ignored` : '') + + (filtered > 0 ? `, ${filtered} filtered by includeModels/excludeModels` : '') + ')', ) @@ -378,7 +424,7 @@ async function backgroundRefresh(cacheKey: string): Promise { refreshInFlight.add(cacheKey) try { const built = await withTimeout( - discoverModels(ctx.baseURL, ctx.apiKey, ctx.customHeaders, ctx.providerId), + discoverModels(ctx.baseURL, ctx.apiKey, ctx.customHeaders, ctx.providerId, ctx.filters), DISCOVERY_TIMEOUT_MS, ) if (built && Object.keys(built).length > 0) { @@ -478,6 +524,7 @@ export const LiteLLMPlugin: Plugin = async (input: PluginInput) => { const storedKey = await getOpenCodeStoredApiKey(providerId) const apiKey = configuredKey ?? envKey ?? storedKey const customHeaders = readCustomHeaders(options) + const filters = readModelFilters(options) // Resolve base URL let baseURL: string | null = null @@ -537,7 +584,7 @@ export const LiteLLMPlugin: Plugin = async (input: PluginInput) => { // Remember how to reach this proxy so the `event` hook can // revalidate its cache in the background on new sessions. - refreshContexts.set(cacheKey, { baseURL, apiKey, customHeaders, providerId }) + refreshContexts.set(cacheKey, { baseURL, apiKey, customHeaders, filters, providerId }) // Repeat config-hook invocations within a run are a no-op once // we've injected this provider's models. @@ -569,7 +616,7 @@ export const LiteLLMPlugin: Plugin = async (input: PluginInput) => { // persist for subsequent startups. Capped by a timeout so a slow // proxy never blocks boot. const built = await withTimeout( - discoverModels(baseURL, apiKey, customHeaders, providerId), + discoverModels(baseURL, apiKey, customHeaders, providerId, filters), DISCOVERY_TIMEOUT_MS, ) if (built && Object.keys(built).length > 0) { diff --git a/src/utils/index.ts b/src/utils/index.ts index 3497a2a..7343ed7 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,2 +1,3 @@ export * from './litellm-api' export * from './format-model-name' +export * from './model-filter' diff --git a/src/utils/model-filter.ts b/src/utils/model-filter.ts new file mode 100644 index 0000000..323cf26 --- /dev/null +++ b/src/utils/model-filter.ts @@ -0,0 +1,53 @@ +/** + * Minimal glob matching for model-id filters (`includeModels`/ + * `excludeModels`). Supports only `*` (any run of characters, including + * none) — enough for the common case (`"anthropic/*"`) without pulling + * in a glob dependency. Everything else in the pattern is matched + * literally. + */ +function globToRegExp(pattern: string): RegExp { + const escaped = pattern + .split('*') + .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&')) + .join('.*') + return new RegExp(`^${escaped}$`) +} + +function matchesAny(id: string, patterns: readonly string[]): boolean { + return patterns.some((pattern) => globToRegExp(pattern).test(id)) +} + +/** + * Decide whether a discovered model id should be injected into a + * provider, given that provider's `includeModels`/`excludeModels` + * options. + * + * - No `includeModels` → every id passes the include step (default: + * don't filter). + * - `includeModels` present → only ids matching at least one pattern + * pass. + * - `excludeModels` is applied after, and always wins over `include`. + */ +export function passesModelFilter( + id: string, + includeModels?: readonly string[], + excludeModels?: readonly string[], +): boolean { + if (includeModels && includeModels.length > 0 && !matchesAny(id, includeModels)) { + return false + } + if (excludeModels && excludeModels.length > 0 && matchesAny(id, excludeModels)) { + return false + } + return true +} + +/** + * The parsed `includeModels`/`excludeModels` options for one provider. + * `undefined` (or empty) arrays mean "don't filter" — a misconfigured + * option degrades toward no filtering rather than an empty provider. + */ +export interface ModelFilters { + includeModels?: string[] + excludeModels?: string[] +} diff --git a/test/model-filter.test.ts b/test/model-filter.test.ts new file mode 100644 index 0000000..ed4ef7a --- /dev/null +++ b/test/model-filter.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { passesModelFilter } from '../src/utils/model-filter' + +describe('passesModelFilter', () => { + it('passes everything when no filters are set', () => { + expect(passesModelFilter('claude-opus-4-5')).toBe(true) + expect(passesModelFilter('anything', undefined, undefined)).toBe(true) + }) + + it('treats empty arrays as no filter', () => { + expect(passesModelFilter('gpt-5', [], [])).toBe(true) + }) + + it('keeps only ids matching at least one includeModels pattern', () => { + expect(passesModelFilter('prod/claude-opus-4-5', ['prod/*'])).toBe(true) + expect(passesModelFilter('staging/claude-opus-4-5', ['prod/*'])).toBe(false) + expect(passesModelFilter('prod/gpt-5', ['prod/*', 'canary/*'])).toBe(true) + expect(passesModelFilter('canary/gpt-5', ['prod/*', 'canary/*'])).toBe(true) + }) + + it('drops ids matching excludeModels, which always wins', () => { + expect(passesModelFilter('prod/gpt-5-canary', undefined, ['*-canary'])).toBe(false) + expect(passesModelFilter('prod/gpt-5', ['prod/*'], ['*-canary'])).toBe(true) + expect(passesModelFilter('prod/gpt-5-canary', ['prod/*'], ['*-canary'])).toBe(false) + }) + + it('supports stars anywhere in a pattern, including no-character matches', () => { + expect(passesModelFilter('claude-opus-4-5', ['claude*4-5'])).toBe(true) + expect(passesModelFilter('gpt5', ['gpt*'])).toBe(true) + expect(passesModelFilter('gpt4o', ['gpt*'])).toBe(true) + }) + + it('matches regex metacharacters literally', () => { + // The dot in `gpt-4.1` is a literal dot, not a regex wildcard. + expect(passesModelFilter('gpt-4.1', ['gpt-4.1'])).toBe(true) + expect(passesModelFilter('gpt-4x1', ['gpt-4.1'])).toBe(false) + // Parentheses, brackets and plus signs are also literal. + expect(passesModelFilter('model(1)', ['model(1)'])).toBe(true) + expect(passesModelFilter('modelx1', ['model(1)'])).toBe(false) + expect(passesModelFilter('a+b', ['a+b'])).toBe(true) + expect(passesModelFilter('aab', ['a+b'])).toBe(false) + }) + + it('matching is case-sensitive', () => { + expect(passesModelFilter('PROD/model', ['prod/*'])).toBe(false) + }) +})