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) + }) +})