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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)


Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/plugin/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -173,7 +174,18 @@ export async function discoverBucket(
const resolvedApi = { ...api, url: `${baseURL}/v1` }

const routing = readRoutingOptions(provider)
const options = (provider?.options ?? {}) as Record<string, unknown>
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,
Expand Down
45 changes: 41 additions & 4 deletions src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<string, Set<string>>()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -265,9 +273,18 @@ export const LiteLLMPlugin: Plugin = async (_input: PluginInput) => {

const models = actualProvider.models as Record<string, unknown>

// 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])
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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` : '') +
')',
)
}
Expand Down
21 changes: 21 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,25 @@ export interface LiteLLMOptions {
* ```
*/
customHeaders?: Record<string, string>
/**
* 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[]
}
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './litellm-api'
export * from './format-model-name'
export * from './model-filter'
43 changes: 43 additions & 0 deletions src/utils/model-filter.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading