Skip to content
Merged
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
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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
Expand Down
53 changes: 50 additions & 3 deletions src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -70,6 +72,7 @@ interface RefreshContext {
baseURL: string
apiKey?: string
customHeaders?: Record<string, string>
filters: ModelFilters
providerId: string
}
const refreshContexts = new Map<string, RefreshContext>()
Expand Down Expand Up @@ -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<string, unknown>): 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
Expand Down Expand Up @@ -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".
*/
Expand All @@ -245,6 +271,7 @@ async function discoverModels(
apiKey: string | undefined,
customHeaders: Record<string, string> | undefined,
providerId: string,
filters: ModelFilters = {},
): Promise<Record<string, unknown> | null> {
if (!(await checkLiteLLMHealth(baseURL, apiKey, customHeaders))) {
log(
Expand Down Expand Up @@ -297,6 +324,7 @@ async function discoverModels(
const built: Record<string, unknown> = {}
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
Expand All @@ -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)
Expand All @@ -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` : '') +
')',
)

Expand Down Expand Up @@ -378,7 +424,7 @@ async function backgroundRefresh(cacheKey: string): Promise<void> {
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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
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'
53 changes: 53 additions & 0 deletions src/utils/model-filter.ts
Original file line number Diff line number Diff line change
@@ -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[]
}
47 changes: 47 additions & 0 deletions test/model-filter.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading