diff --git a/CHANGELOG.md b/CHANGELOG.md index 820efb7..85fc941 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [Unreleased] + +### Features + +* **plugin:** filter discovered models via includeModels/excludeModels + # [0.8.0](https://github.com/yuseferi/opencode-litellm/compare/v0.7.1...v0.8.0) (2026-08-04) diff --git a/README.md b/README.md index c63cd4d..b1b75d1 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ opencode | ðŸĒ **Provider extraction** | Pulls `litellm_provider` (or the `provider/model` prefix) into `organizationOwner` so models group correctly in the UI. | | 🔐 **Auth-aware** | Honours `LITELLM_API_KEY` / `LITELLM_MASTER_KEY` env vars or `provider.litellm.options.apiKey`. | | 🌐 **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** for slow remote proxies. Repeat config-hook invocations are a no-op. | | ðŸĪ **Non-destructive merge** | Only adds models you don't already have configured. Hand-curated entries are preserved verbatim. | | ðŸŠķ **Zero runtime deps** | Only depends on `@opencode-ai/plugin`. No build step, no bundler. | @@ -270,6 +271,38 @@ 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 of the catalog: + +```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. + ## 🔧 How it works ```mermaid diff --git a/src/plugin/discover.ts b/src/plugin/discover.ts index 4978902..b177726 100644 --- a/src/plugin/discover.ts +++ b/src/plugin/discover.ts @@ -7,6 +7,7 @@ import { normalizeBaseURL, } from '../utils/litellm-api' import { requiresResponsesAPI } from '../utils/format-model-name' +import { passesModelFilter } from '../utils/model-filter' import type { LiteLLMModel, LiteLLMModelInfo, Transport, TransportPolicy } from '../types' import { buildModelV2 } from './build-model' @@ -173,7 +174,18 @@ export async function discoverBucket( const resolvedApi = { ...api, url: `${baseURL}/v1` } const routing = readRoutingOptions(provider) + const options = (provider?.options ?? {}) as Record + const includeModels = Array.isArray(options.includeModels) + ? options.includeModels.filter((v): v is string => typeof v === 'string') + : undefined + const excludeModels = Array.isArray(options.excludeModels) + ? options.excludeModels.filter((v): v is string => typeof v === 'string') + : undefined for (const model of models) { + // `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, includeModels, excludeModels)) continue if (bucket !== 'all') { const transport = pickTransport( model, diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 888e61e..8cbbc3d 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -10,6 +10,7 @@ import { formatModelName, categorizeModel, } from '../utils/format-model-name' +import { passesModelFilter } from '../utils/model-filter' import type { LiteLLMModel, LiteLLMModelInfo } from '../types' const CHAT_PROVIDER_ID = 'litellm' @@ -20,8 +21,9 @@ const DISCOVERY_TIMEOUT_MS = 20000 /** * OpenCode invokes the `config` hook several times per run with a * cumulative config object. Track which model ids we already injected - * per baseURL so repeat invocations can return early instead of - * re-querying the proxy. + * per `providerId:baseURL` (not baseURL alone -- two providers can + * share a proxy, see the `cacheKey` comment below) so repeat + * invocations can return early instead of re-querying the proxy. */ const injectedModelIds = new Map>() @@ -224,6 +226,12 @@ export const LiteLLMPlugin: Plugin = async (_input: PluginInput) => { process.env.LITELLM_API_KEY ?? process.env.LITELLM_MASTER_KEY const apiKey = configuredKey ?? envKey const customHeaders = readCustomHeaders(options) + const includeModels = Array.isArray(options.includeModels) + ? options.includeModels.filter((v): v is string => typeof v === 'string') + : undefined + const excludeModels = Array.isArray(options.excludeModels) + ? options.excludeModels.filter((v): v is string => typeof v === 'string') + : undefined // Resolve base URL let baseURL: string | null = null @@ -265,9 +273,18 @@ export const LiteLLMPlugin: Plugin = async (_input: PluginInput) => { const models = actualProvider.models as Record + // Keyed by providerId, not just baseURL. Two providers already + // shared a baseURL before this change (the litellm/litellm- + // responses pair, see README "Reasoning models"), so this map + // could already be clobbered between them; includeModels/ + // excludeModels makes it worse by giving each such pair its own + // filtered model set to track, which a shared, baseURL-only key + // can't distinguish at all. + const cacheKey = `${providerId}:${baseURL}` + // Discover models with timeout const work = async () => { - const alreadyInjected = injectedModelIds.get(baseURL!) + const alreadyInjected = injectedModelIds.get(cacheKey) if ( alreadyInjected && [...alreadyInjected].every((id) => models[id]) @@ -322,6 +339,7 @@ export const LiteLLMPlugin: Plugin = async (_input: PluginInput) => { let added = 0 let skipped = 0 let wildcards = 0 + let filtered = 0 const unmatched: string[] = [] for (const model of discovered) { // Wildcard entries (`deepseek/*`) are access rules, not @@ -330,6 +348,13 @@ export const LiteLLMPlugin: Plugin = async (_input: PluginInput) => { 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, includeModels, excludeModels)) { + filtered++ + continue + } // Don't overwrite user-curated entries if (models[model.id]) continue const info = infoByName?.get(model.id) @@ -354,18 +379,30 @@ export const LiteLLMPlugin: Plugin = async (_input: PluginInput) => { ) } + // Only blame includeModels/excludeModels when every non-wildcard + // discovered model was actually removed by it -- if some models + // instead hit `skipped` (non-chat) or were already user-curated, + // `added === 0` has a cause unrelated to the filter and this + // warning would misdirect the user. + if (filtered > 0 && filtered + wildcards === discovered.length) { + console.warn( + `[opencode-litellm] includeModels/excludeModels filtered out all ${filtered} model(s) discovered for provider "${providerId}" — check the glob patterns in options.includeModels/options.excludeModels.`, + ) + } + // Remove the seed placeholder if real models were discovered if (models['_'] && Object.keys(models).length > 1) { delete models['_'] } - injectedModelIds.set(baseURL!, new Set(Object.keys(models))) + injectedModelIds.set(cacheKey, new Set(Object.keys(models))) console.log( `[opencode-litellm] Discovered ${discovered.length} models for provider "${providerId}" from ${baseURL} ` + `(${added} added` + (skipped > 0 ? `, ${skipped} non-chat hidden` : '') + (wildcards > 0 ? `, ${wildcards} wildcard ignored` : '') + + (filtered > 0 ? `, ${filtered} filtered out by includeModels/excludeModels` : '') + ')', ) } diff --git a/src/types/index.ts b/src/types/index.ts index 03cb8ca..d108314 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -143,4 +143,25 @@ export interface LiteLLMOptions { * ``` */ customHeaders?: Record + /** + * Only inject discovered models whose `id` matches at least one of + * these glob patterns (`*` = any run of characters, e.g. + * `"anthropic/*"`). Evaluated before `excludeModels`. Lets a + * single LiteLLM proxy be split across several OpenCode providers — + * e.g. one provider per upstream naming prefix — without hand- + * maintaining a model list. + * + * An empty or omitted array means "don't filter" (every id passes), + * not "match nothing." Non-string entries are silently dropped + * before matching, so a typo'd non-string entry degrades toward "no + * filter" rather than raising an error. + */ + includeModels?: string[] + /** + * Drop discovered models whose `id` matches any of these glob + * patterns, even if they passed `includeModels`. Same pattern syntax + * and the same empty-array/non-string-entry behavior as + * `includeModels` above. + */ + excludeModels?: string[] } 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..50eaa74 --- /dev/null +++ b/src/utils/model-filter.ts @@ -0,0 +1,43 @@ +/** + * 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 +}