From 0d4fc7485a4e91614a76ed0c8ffc2b2c554159aa Mon Sep 17 00:00:00 2001 From: Daniele Iasella <2861984+overbit@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:28:55 +0100 Subject: [PATCH 1/5] fix(litellm): enrich model discovery with info --- src/plugin/index.ts | 72 ++++++++++++++++++++++++++++++---------- src/types/index.ts | 33 ++++++++++++++++-- src/utils/litellm-api.ts | 35 ++++++++++++++++++- 3 files changed, 118 insertions(+), 22 deletions(-) diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 6d40f0e..0f8173d 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -2,6 +2,7 @@ import type { Plugin, PluginInput } from '@opencode-ai/plugin' import { autoDetectLiteLLM, checkLiteLLMHealth, + discoverLiteLLMModelInfo, discoverLiteLLMModels, normalizeBaseURL, } from '../utils/litellm-api' @@ -10,7 +11,7 @@ import { extractModelOwner, categorizeModel, } from '../utils/format-model-name' -import type { LiteLLMModel } from '../types' +import type { LiteLLMModel, LiteLLMModelInfo } from '../types' const CHAT_PROVIDER_ID = 'litellm' const DISCOVERY_TIMEOUT_MS = 5000 @@ -32,13 +33,36 @@ function readCustomHeaders( return undefined } +/** + * Overlay metadata from `/v1/model/info` onto a `/v1/models` entry. + * Fields already present on the lean entry win; the info block only + * fills gaps (notably `mode`, which `/v1/models` omits for + * database-defined models). + */ +function enrichModel(model: LiteLLMModel, info: LiteLLMModelInfo): LiteLLMModel { + return { + ...model, + mode: model.mode ?? info.mode, + max_tokens: model.max_tokens ?? info.max_tokens, + max_input_tokens: model.max_input_tokens ?? info.max_input_tokens, + max_output_tokens: model.max_output_tokens ?? info.max_output_tokens, + supports_function_calling: model.supports_function_calling ?? info.supports_function_calling, + supports_vision: model.supports_vision ?? info.supports_vision, + } +} + /** * Convert a discovered LiteLLM model into an OpenCode config-level * model entry (the shape used in `provider.*.models` inside - * `opencode.json`). + * `opencode.json`). Returns `null` for non-chat models (embedding, + * image, audio) โ€” they can't be used as primary chat models and would + * clutter the picker. */ -function toConfigModel(model: LiteLLMModel): Record { +function toConfigModel(model: LiteLLMModel): Record | null { const type = categorizeModel(model) + if (type === 'embedding' || type === 'image' || type === 'audio') { + return null + } const entry: Record = { name: formatModelName(model), } @@ -54,11 +78,6 @@ function toConfigModel(model: LiteLLMModel): Record { if (model.supports_vision) { entry.attachment = true } - if (type === 'embedding' || type === 'image' || type === 'audio') { - // skip non-chat models from the config โ€” they can't be used as - // primary chat models and would clutter the picker. - return entry - } return entry } @@ -165,14 +184,17 @@ export const LiteLLMPlugin: Plugin = async (_input: PluginInput) => { return } - let discovered: LiteLLMModel[] - try { - discovered = await discoverLiteLLMModels( - baseURL!, - apiKey, - customHeaders, - ) - } catch (error) { + // `/v1/models` omits `mode` and capability metadata for + // database-defined models, so fetch `/v1/model/info` alongside + // it. The info call is best-effort: without it, classification + // falls back to id heuristics. + const [modelsResult, infoResult] = await Promise.allSettled([ + discoverLiteLLMModels(baseURL!, apiKey, customHeaders), + discoverLiteLLMModelInfo(baseURL!, apiKey, customHeaders), + ]) + + if (modelsResult.status === 'rejected') { + const error = modelsResult.reason console.warn( '[opencode-litellm] Model discovery failed:', error instanceof Error ? error.message : String(error), @@ -180,6 +202,10 @@ export const LiteLLMPlugin: Plugin = async (_input: PluginInput) => { return } + const discovered = modelsResult.value + const infoByName = + infoResult.status === 'fulfilled' ? infoResult.value : null + if (discovered.length === 0) { console.warn( '[opencode-litellm] LiteLLM responded but exposed zero models.', @@ -187,10 +213,19 @@ export const LiteLLMPlugin: Plugin = async (_input: PluginInput) => { return } + let added = 0 + let skipped = 0 for (const model of discovered) { // Don't overwrite user-curated entries if (models[model.id]) continue - models[model.id] = toConfigModel(model) + const info = infoByName?.get(model.id) + const entry = toConfigModel(info ? enrichModel(model, info) : model) + if (!entry) { + skipped++ + continue + } + models[model.id] = entry + added++ } // Remove the seed placeholder if real models were discovered @@ -199,7 +234,8 @@ export const LiteLLMPlugin: Plugin = async (_input: PluginInput) => { } console.log( - `[opencode-litellm] Discovered ${discovered.length} models from ${baseURL}`, + `[opencode-litellm] Discovered ${added} models from ${baseURL}` + + (skipped > 0 ? ` (${skipped} non-chat models hidden)` : ''), ) } diff --git a/src/types/index.ts b/src/types/index.ts index c670fb2..aa3fbed 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -15,9 +15,9 @@ export interface LiteLLMModel { */ litellm_provider?: string /** - * Optional capability metadata exposed by some LiteLLM versions - * via `/model/info` (we ignore it for the lean discovery endpoint - * but keep the type around for forward-compat). + * Optional capability metadata. Present on `/v1/models` only for some + * deployments; reliably available via `/v1/model/info` and merged onto + * the discovered entry by the plugin. * * Newer LiteLLM versions may expose `'responses'` here for models * that must be routed through the OpenAI Responses API rather than @@ -36,6 +36,33 @@ export interface LiteLLMModelsResponse { data: LiteLLMModel[] } +/** + * The `model_info` block of a `/v1/model/info` entry. This endpoint + * reliably carries `mode` (and token limits) even for database-defined + * models, where `/v1/models` only returns the lean OpenAI schema. + */ +export interface LiteLLMModelInfo { + id?: string + db_model?: boolean + mode?: string + max_tokens?: number + max_input_tokens?: number + max_output_tokens?: number + supports_function_calling?: boolean + supports_vision?: boolean +} + +/** A single entry returned by LiteLLM's `/v1/model/info` endpoint. */ +export interface LiteLLMModelInfoEntry { + model_name: string + litellm_params?: Record + model_info?: LiteLLMModelInfo +} + +export interface LiteLLMModelInfoResponse { + data?: LiteLLMModelInfoEntry[] +} + export type ModelType = 'chat' | 'embedding' | 'image' | 'audio' | 'unknown' /** diff --git a/src/utils/litellm-api.ts b/src/utils/litellm-api.ts index 633d9b1..e6c5cf9 100644 --- a/src/utils/litellm-api.ts +++ b/src/utils/litellm-api.ts @@ -1,7 +1,8 @@ -import type { LiteLLMModel, LiteLLMModelsResponse } from '../types' +import type { LiteLLMModel, LiteLLMModelInfo, LiteLLMModelInfoResponse, LiteLLMModelsResponse } from '../types' export const DEFAULT_LITELLM_URL = 'http://localhost:4000' const MODELS_ENDPOINT = '/v1/models' +const MODEL_INFO_ENDPOINT = '/v1/model/info' const REQUEST_TIMEOUT_MS = 3000 /** @@ -77,6 +78,38 @@ export async function discoverLiteLLMModels( return data.data ?? [] } +/** + * Fetch per-model metadata (`mode`, token limits, capability flags) + * from `/v1/model/info`, keyed by model name. `/v1/models` omits these + * fields for database-defined models, so classification (e.g. filtering + * out embedding models) relies on this endpoint. + */ +export async function discoverLiteLLMModelInfo( + baseURL: string = DEFAULT_LITELLM_URL, + apiKey?: string, + customHeaders?: Record, +): Promise> { + const url = buildAPIURL(baseURL, MODEL_INFO_ENDPOINT) + const response = await fetch(url, { + method: 'GET', + headers: buildHeaders(apiKey, customHeaders), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + + if (!response.ok) { + throw new Error(`LiteLLM responded with HTTP ${response.status} ${response.statusText}`) + } + + const data = (await response.json()) as LiteLLMModelInfoResponse + const infoByName = new Map() + for (const entry of data.data ?? []) { + if (entry.model_name && entry.model_info) { + infoByName.set(entry.model_name, entry.model_info) + } + } + return infoByName +} + /** * Try the most common ports a LiteLLM proxy is started on. * The default `litellm --port` is 4000, but 8000 is also widely used From 203e42332eb77de4e78c3ad435d141fa57c53299 Mon Sep 17 00:00:00 2001 From: Daniele Iasella <2861984+overbit@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:25:27 +0100 Subject: [PATCH 2/5] doc: update documentation --- CHANGELOG.md | 19 +++++++++++++++++++ README.md | 8 +++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06cfaaa..6621cd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **Embedding / image / audio models no longer appear in the OpenCode + model picker.** The non-chat filter in `toConfigModel()` was a dead + code path that returned the model entry either way, so models like + `mistral/mistral-embed` showed up as selectable chat models. +- **Model classification now works for database-defined models.** + `/v1/models` omits the `mode` field for DB-defined models, so + mode-based classification never fired. The plugin now fetches + `/v1/model/info` alongside `/v1/models` and enriches each discovered + model with its `mode`, token limits (`max_input_tokens` / + `max_output_tokens`), and capability flags + (`supports_function_calling`, `supports_vision`). Fields already + present on the `/v1/models` entry take precedence. The info call is + best-effort โ€” if the endpoint is unavailable, classification falls + back to the previous id heuristics. +- The startup log now reports how many non-chat models were hidden, + e.g. `Discovered 12 models from http://localhost:4000 (2 non-chat + models hidden)`. + ## [0.5.0] โ€” 2026-05-11 ### Changed (BREAKING) diff --git a/README.md b/README.md index 000abdf..03179b4 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ opencode | ๐Ÿ” **Auto-detection** | Probes `localhost:4000`, `:8000`, `:8080` and adopts the first responsive proxy. | | ๐Ÿ“ก **Dynamic discovery** | Queries `/v1/models` so your OpenCode model picker always reflects your live `model_list`. | | ๐Ÿท๏ธ **Smart formatting** | Turns `anthropic/claude-3-5-sonnet` into `Claude 3 5 Sonnet` in the picker โ€” handles versions, sizes, quantizations, and brand-cased names like `gpt-4o`. | -| ๐Ÿง  **Modality-aware** | Infers `chat` / `embedding` / `image` / `audio` from the model `mode` field or id, and writes proper `modalities` metadata. | +| ๐Ÿง  **Modality-aware** | Enriches `/v1/models` entries with `/v1/model/info` (`mode`, token limits, capability flags) and hides embedding / image / audio models from the picker. | | ๐Ÿงช **Reasoning-aware routing** | Auto-routes `gpt-5*` / `o1`/`o3`/`o4*` models through a sibling `litellm-responses` provider that uses `/v1/responses`, so tools + `reasoning_effort` actually work. Override per model via `responsesApiModels` / `chatApiModels`. | | ๐Ÿข **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`. | @@ -251,7 +251,9 @@ sequenceDiagram Plugin->>Plugin: auto-create provider entry end Plugin->>LL: GET /v1/models (with auth if set) - LL-->>Plugin: { data: [...models] } + Plugin->>LL: GET /v1/model/info (best-effort, for `mode` + limits) + LL-->>Plugin: { data: [...models] } + per-model info + Plugin->>Plugin: enrich models, hide non-chat (embedding/image/audio) Plugin->>Plugin: format names, infer modalities, extract owner Plugin->>Plugin: bucket each model by transport (chat vs responses) Plugin->>OC: merge chat-completions models into provider.litellm @@ -262,7 +264,7 @@ sequenceDiagram 1. On OpenCode startup the `config` lifecycle hook fires. 2. If `provider.litellm` exists, its `baseURL` is used. Otherwise common ports are probed. 3. A health check (`GET /v1/models`) verifies the proxy is reachable and authorized. -4. Models from the response are converted into OpenCode model entries with `id`, formatted `name`, `organizationOwner`, and inferred `modalities`. +4. Models from the response are enriched with `/v1/model/info` metadata (`mode`, token limits, capability flags โ€” `/v1/models` omits these for database-defined models) and converted into OpenCode model entries with `id`, formatted `name`, `organizationOwner`, and inferred `modalities`. Non-chat models (embedding / image / audio) are excluded from the picker. 5. Each model is bucketed by transport โ€” reasoning-tier models (`gpt-5*`, `o1`/`o3`/`o4*`, or anything with `mode === 'responses'`) go into the `litellm-responses` provider; everything else goes into `litellm`. Per-model overrides via `responsesApiModels` / `chatApiModels` win. 6. Discovered models are merged on top of any user-defined ones โ€” never overwriting them. A model is skipped if its key already exists under **either** provider. 7. The whole flow is wrapped in a `Promise.race` against a 5 s timeout so a slow proxy never blocks boot. From a2d4f2d77fbb36cff2ed920aaf4ecb958676e1e8 Mon Sep 17 00:00:00 2001 From: Daniele Iasella <2861984+overbit@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:37:40 +0100 Subject: [PATCH 3/5] chore: remove wildcards and fix model config --- CHANGELOG.md | 34 +++++++++++++++++-- README.md | 12 +++---- src/plugin/build-model.ts | 4 +-- src/plugin/index.ts | 69 ++++++++++++++++++++++++++++++++++++--- src/types/index.ts | 8 +++++ src/utils/litellm-api.ts | 36 ++++++++++++++++---- 6 files changed, 141 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6621cd9..70fc58f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,9 +22,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 present on the `/v1/models` entry take precedence. The info call is best-effort โ€” if the endpoint is unavailable, classification falls back to the previous id heuristics. -- The startup log now reports how many non-chat models were hidden, - e.g. `Discovered 12 models from http://localhost:4000 (2 non-chat - models hidden)`. +- The startup log now reports totals, additions, and hidden non-chat + models, e.g. `Discovered 12 models from http://localhost:4000 (10 + added, 2 non-chat hidden)`. +- **A warning is logged when `/v1/model/info` is unreachable**, so + degraded (id-heuristic-only) classification is no longer silent. +- **The config hook is now idempotent within a process.** OpenCode + invokes the hook several times per run with a cumulative config; + repeat invocations used to re-query the proxy each time. Already- + injected model sets are now skipped entirely. +- **Enrichment works when the `/v1/model/info` alias differs from the + `/v1/models` id.** Info entries are now indexed by `model_name`, + `model_info.key`, and `litellm_params.model`, so deployments whose + public alias differs from the upstream model string still get `mode`, + limits, and capability flags. +- **`supports_vision` set on `litellm_params`** (instead of inside + `model_info`) is now honoured. +- **Wildcard model entries (e.g. `deepseek/*`) are hidden from the + picker.** They are access rules, not callable models โ€” selecting one + would send a literal `*` as the model name upstream. +- **Discovery fetch timeout raised from 3 s to 15 s** (health checks + stay at 3 s fail-fast). Remote proxies with many database-defined + models generate large `/v1/model/info` payloads that could exceed the + old budget, silently degrading enrichment to id heuristics. The + overall discovery cap is now 15 s. +- **Reasoning and modality capabilities are now propagated to + OpenCode.** `supports_reasoning` maps to the model's `reasoning` + flag, and `modalities.input` is emitted from `supports_vision` + (`image`), `supports_pdf_input` (`pdf`), and `supports_audio_input` + (`audio`). Previously models like `moonshot/kimi-k3` showed as + text-only, non-reasoning in the picker despite LiteLLM reporting the + capabilities. ## [0.5.0] โ€” 2026-05-11 diff --git a/README.md b/README.md index 03179b4..9913e5e 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,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. | -| โฑ๏ธ **Non-blocking startup** | Discovery is capped at **5 s** โ€” a slow or offline proxy never delays OpenCode boot. | +| โฑ๏ธ **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. | | ๐Ÿ”’ **TypeScript strict** | Strict-mode compiled, fully typed public API. | @@ -267,7 +267,7 @@ sequenceDiagram 4. Models from the response are enriched with `/v1/model/info` metadata (`mode`, token limits, capability flags โ€” `/v1/models` omits these for database-defined models) and converted into OpenCode model entries with `id`, formatted `name`, `organizationOwner`, and inferred `modalities`. Non-chat models (embedding / image / audio) are excluded from the picker. 5. Each model is bucketed by transport โ€” reasoning-tier models (`gpt-5*`, `o1`/`o3`/`o4*`, or anything with `mode === 'responses'`) go into the `litellm-responses` provider; everything else goes into `litellm`. Per-model overrides via `responsesApiModels` / `chatApiModels` win. 6. Discovered models are merged on top of any user-defined ones โ€” never overwriting them. A model is skipped if its key already exists under **either** provider. -7. The whole flow is wrapped in a `Promise.race` against a 5 s timeout so a slow proxy never blocks boot. +7. The whole flow is wrapped in a `Promise.race` against a 15 s timeout so a slow proxy never blocks boot. ## ๐Ÿ“‹ Requirements @@ -393,12 +393,12 @@ src/ โ”œโ”€โ”€ index.ts # Public exports โ”œโ”€โ”€ types/index.ts # LiteLLM API types โ”œโ”€โ”€ utils/ -โ”‚ โ”œโ”€โ”€ litellm-api.ts # health check, discovery, auto-detect +โ”‚ โ”œโ”€โ”€ litellm-api.ts # health check, discovery (/v1/models + /v1/model/info), auto-detect โ”‚ โ””โ”€โ”€ format-model-name.ts # owner extraction, name formatting, categorization โ””โ”€โ”€ plugin/ - โ”œโ”€โ”€ index.ts # LiteLLMPlugin entry - โ”œโ”€โ”€ config-hook.ts # OpenCode config-lifecycle hook (5 s timeout) - โ””โ”€โ”€ enhance-config.ts # core merge logic + โ”œโ”€โ”€ index.ts # LiteLLMPlugin entry (config hook, enrichment, filtering) + โ”œโ”€โ”€ discover.ts # V2 Model bucketing (unused by the config hook) + โ””โ”€โ”€ build-model.ts # V2 Model entry builder ``` See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the full contributor workflow. diff --git a/src/plugin/build-model.ts b/src/plugin/build-model.ts index a77f038..e8a270f 100644 --- a/src/plugin/build-model.ts +++ b/src/plugin/build-model.ts @@ -33,7 +33,7 @@ export function buildModelV2( name: formatModelName(model), capabilities: { temperature: true, - reasoning: false, + reasoning: !!model.supports_reasoning, attachment: isVision || isAudio, toolcall: !!model.supports_function_calling, input: { @@ -41,7 +41,7 @@ export function buildModelV2( audio: isAudio, image: isVision, video: false, - pdf: false, + pdf: !!model.supports_pdf_input, }, output: { text: !isImageOut, diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 0f8173d..1126ec5 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -14,7 +14,15 @@ import { import type { LiteLLMModel, LiteLLMModelInfo } from '../types' const CHAT_PROVIDER_ID = 'litellm' -const DISCOVERY_TIMEOUT_MS = 5000 +const DISCOVERY_TIMEOUT_MS = 15000 + +/** + * 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. + */ +const injectedModelIds = new Map>() /** * Read `customHeaders` from a provider options block. @@ -48,6 +56,9 @@ function enrichModel(model: LiteLLMModel, info: LiteLLMModelInfo): LiteLLMModel max_output_tokens: model.max_output_tokens ?? info.max_output_tokens, supports_function_calling: model.supports_function_calling ?? info.supports_function_calling, supports_vision: model.supports_vision ?? info.supports_vision, + supports_reasoning: model.supports_reasoning ?? info.supports_reasoning, + supports_pdf_input: model.supports_pdf_input ?? info.supports_pdf_input, + supports_audio_input: model.supports_audio_input ?? info.supports_audio_input, } } @@ -75,9 +86,19 @@ function toConfigModel(model: LiteLLMModel): Record | null { if (model.supports_function_calling) { entry.tool_call = true } + if (model.supports_reasoning) { + entry.reasoning = true + } if (model.supports_vision) { entry.attachment = true } + const input: Array<'text' | 'image' | 'pdf' | 'audio'> = ['text'] + if (model.supports_vision) input.push('image') + if (model.supports_pdf_input) input.push('pdf') + if (model.supports_audio_input) input.push('audio') + if (input.length > 1) { + entry.modalities = { input, output: ['text'] } + } return entry } @@ -177,6 +198,14 @@ export const LiteLLMPlugin: Plugin = async (_input: PluginInput) => { // Discover models with timeout const work = async () => { + const alreadyInjected = injectedModelIds.get(baseURL!) + if ( + alreadyInjected && + [...alreadyInjected].every((id) => models[id]) + ) { + return + } + if (!(await checkLiteLLMHealth(baseURL!, apiKey, customHeaders))) { console.warn( `[opencode-litellm] LiteLLM appears offline or unauthorized at ${baseURL}`, @@ -203,8 +232,16 @@ export const LiteLLMPlugin: Plugin = async (_input: PluginInput) => { } const discovered = modelsResult.value - const infoByName = - infoResult.status === 'fulfilled' ? infoResult.value : null + let infoByName: Map | null = null + if (infoResult.status === 'fulfilled') { + infoByName = infoResult.value + } else { + const reason = infoResult.reason + console.warn( + '[opencode-litellm] /v1/model/info unavailable; non-chat model filtering will use id heuristics only:', + reason instanceof Error ? reason.message : String(reason), + ) + } if (discovered.length === 0) { console.warn( @@ -215,10 +252,19 @@ export const LiteLLMPlugin: Plugin = async (_input: PluginInput) => { let added = 0 let skipped = 0 + let wildcards = 0 + const unmatched: string[] = [] for (const model of discovered) { + // Wildcard entries (`deepseek/*`) are access rules, not + // callable models โ€” invoking one sends a literal `*` upstream. + if (model.id.includes('*')) { + wildcards++ + continue + } // Don't overwrite user-curated entries if (models[model.id]) continue const info = infoByName?.get(model.id) + if (infoByName && !info) unmatched.push(model.id) const entry = toConfigModel(info ? enrichModel(model, info) : model) if (!entry) { skipped++ @@ -228,14 +274,27 @@ export const LiteLLMPlugin: Plugin = async (_input: PluginInput) => { added++ } + if (unmatched.length > 0) { + console.warn( + `[opencode-litellm] /v1/model/info has no entry for ${unmatched.length} model(s); ` + + `classification uses id heuristics for: ${unmatched.slice(0, 5).join(', ')}` + + (unmatched.length > 5 ? `, +${unmatched.length - 5} more` : ''), + ) + } + // 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))) + console.log( - `[opencode-litellm] Discovered ${added} models from ${baseURL}` + - (skipped > 0 ? ` (${skipped} non-chat models hidden)` : ''), + `[opencode-litellm] Discovered ${discovered.length} models from ${baseURL} ` + + `(${added} added` + + (skipped > 0 ? `, ${skipped} non-chat hidden` : '') + + (wildcards > 0 ? `, ${wildcards} wildcard ignored` : '') + + ')', ) } diff --git a/src/types/index.ts b/src/types/index.ts index aa3fbed..26ec7c7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -29,6 +29,9 @@ export interface LiteLLMModel { max_output_tokens?: number supports_function_calling?: boolean supports_vision?: boolean + supports_reasoning?: boolean + supports_pdf_input?: boolean + supports_audio_input?: boolean } export interface LiteLLMModelsResponse { @@ -44,12 +47,17 @@ export interface LiteLLMModelsResponse { export interface LiteLLMModelInfo { id?: string db_model?: boolean + /** Alias LiteLLM assigns to the model; mirrors the `/v1/models` id. */ + key?: string mode?: string max_tokens?: number max_input_tokens?: number max_output_tokens?: number supports_function_calling?: boolean supports_vision?: boolean + supports_reasoning?: boolean + supports_pdf_input?: boolean + supports_audio_input?: boolean } /** A single entry returned by LiteLLM's `/v1/model/info` endpoint. */ diff --git a/src/utils/litellm-api.ts b/src/utils/litellm-api.ts index e6c5cf9..a65c6a9 100644 --- a/src/utils/litellm-api.ts +++ b/src/utils/litellm-api.ts @@ -3,7 +3,12 @@ import type { LiteLLMModel, LiteLLMModelInfo, LiteLLMModelInfoResponse, LiteLLMM export const DEFAULT_LITELLM_URL = 'http://localhost:4000' const MODELS_ENDPOINT = '/v1/models' const MODEL_INFO_ENDPOINT = '/v1/model/info' -const REQUEST_TIMEOUT_MS = 3000 +// Health checks fail fast so auto-detection stays snappy; the actual +// discovery fetches get a generous budget because `/v1/model/info` +// payloads from remote proxies with many database-defined models can +// be large and slow to generate. +const HEALTH_TIMEOUT_MS = 3000 +const FETCH_TIMEOUT_MS = 15000 /** * Normalise a base URL so the rest of the plugin can rely on a @@ -46,7 +51,7 @@ export async function checkLiteLLMHealth( const response = await fetch(buildAPIURL(baseURL), { method: 'GET', headers: buildHeaders(apiKey, customHeaders), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS), }) // 401 still means a server is alive โ€” we just don't have the right // credentials. Surface that as "unhealthy" so the user is prompted @@ -67,7 +72,7 @@ export async function discoverLiteLLMModels( const response = await fetch(url, { method: 'GET', headers: buildHeaders(apiKey, customHeaders), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }) if (!response.ok) { @@ -93,7 +98,7 @@ export async function discoverLiteLLMModelInfo( const response = await fetch(url, { method: 'GET', headers: buildHeaders(apiKey, customHeaders), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }) if (!response.ok) { @@ -103,8 +108,27 @@ export async function discoverLiteLLMModelInfo( const data = (await response.json()) as LiteLLMModelInfoResponse const infoByName = new Map() for (const entry of data.data ?? []) { - if (entry.model_name && entry.model_info) { - infoByName.set(entry.model_name, entry.model_info) + if (!entry.model_info) continue + // Some deployments set capability flags on the params block rather + // than inside model_info (e.g. `supports_vision: true` next to the + // upstream model string). Fill the gap so enrichment sees them. + const info: LiteLLMModelInfo = { ...entry.model_info } + const paramsVision = entry.litellm_params?.supports_vision + if (info.supports_vision == null && typeof paramsVision === 'boolean') { + info.supports_vision = paramsVision + } + // Index under every alias LiteLLM may use for this model โ€” the + // `/v1/models` id can match any of them depending on how the + // deployment names its entries (alias vs upstream model string). + const keys = [ + entry.model_name, + entry.model_info.key, + typeof entry.litellm_params?.model === 'string' ? entry.litellm_params.model : undefined, + ] + for (const key of keys) { + if (key && !infoByName.has(key)) { + infoByName.set(key, info) + } } } return infoByName From 7676ed15ff778b1b8d3e456a4e8ec78fed6a1ac9 Mon Sep 17 00:00:00 2001 From: Daniele Iasella <2861984+overbit@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:59:17 +0100 Subject: [PATCH 4/5] fix(litellm): address model discovery review feedback --- src/plugin/index.ts | 5 +++-- src/utils/litellm-api.ts | 18 +++++++++++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 1126ec5..acf5d06 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -8,13 +8,14 @@ import { } from '../utils/litellm-api' import { formatModelName, - extractModelOwner, categorizeModel, } from '../utils/format-model-name' import type { LiteLLMModel, LiteLLMModelInfo } from '../types' const CHAT_PROVIDER_ID = 'litellm' -const DISCOVERY_TIMEOUT_MS = 15000 +// Covers the sequential 3 s health check plus the parallel 15 s +// models/model-info fetch phase, with headroom. +const DISCOVERY_TIMEOUT_MS = 20000 /** * OpenCode invokes the `config` hook several times per run with a diff --git a/src/utils/litellm-api.ts b/src/utils/litellm-api.ts index a65c6a9..3b46b0c 100644 --- a/src/utils/litellm-api.ts +++ b/src/utils/litellm-api.ts @@ -110,12 +110,20 @@ export async function discoverLiteLLMModelInfo( for (const entry of data.data ?? []) { if (!entry.model_info) continue // Some deployments set capability flags on the params block rather - // than inside model_info (e.g. `supports_vision: true` next to the - // upstream model string). Fill the gap so enrichment sees them. + // than inside model_info. Fill those gaps so enrichment sees them. const info: LiteLLMModelInfo = { ...entry.model_info } - const paramsVision = entry.litellm_params?.supports_vision - if (info.supports_vision == null && typeof paramsVision === 'boolean') { - info.supports_vision = paramsVision + const capabilityFlags = [ + 'supports_vision', + 'supports_function_calling', + 'supports_reasoning', + 'supports_pdf_input', + 'supports_audio_input', + ] as const + for (const flag of capabilityFlags) { + const paramsValue = entry.litellm_params?.[flag] + if (info[flag] == null && typeof paramsValue === 'boolean') { + info[flag] = paramsValue + } } // Index under every alias LiteLLM may use for this model โ€” the // `/v1/models` id can match any of them depending on how the From 01ea9fc1befdc22482bda1a0294763bfed3e3209 Mon Sep 17 00:00:00 2001 From: Daniele Iasella <2861984+overbit@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:59:36 +0100 Subject: [PATCH 5/5] docs: clarify filtered model discovery --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9913e5e..1c8fa60 100644 --- a/README.md +++ b/README.md @@ -258,16 +258,16 @@ sequenceDiagram Plugin->>Plugin: bucket each model by transport (chat vs responses) Plugin->>OC: merge chat-completions models into provider.litellm Plugin->>OC: merge responses models into provider.litellm-responses (lazy) - OC->>OC: render model picker with all discovered models + OC->>OC: render model picker with all eligible discovered chat models ``` 1. On OpenCode startup the `config` lifecycle hook fires. 2. If `provider.litellm` exists, its `baseURL` is used. Otherwise common ports are probed. 3. A health check (`GET /v1/models`) verifies the proxy is reachable and authorized. -4. Models from the response are enriched with `/v1/model/info` metadata (`mode`, token limits, capability flags โ€” `/v1/models` omits these for database-defined models) and converted into OpenCode model entries with `id`, formatted `name`, `organizationOwner`, and inferred `modalities`. Non-chat models (embedding / image / audio) are excluded from the picker. +4. Models from the response are enriched with `/v1/model/info` metadata (`mode`, token limits, capability flags โ€” `/v1/models` omits these for database-defined models) and converted into OpenCode model entries keyed by `id`, with formatted `name`, `organizationOwner`, and inferred `modalities`. Non-chat models (embedding / image / audio) are excluded from the picker. 5. Each model is bucketed by transport โ€” reasoning-tier models (`gpt-5*`, `o1`/`o3`/`o4*`, or anything with `mode === 'responses'`) go into the `litellm-responses` provider; everything else goes into `litellm`. Per-model overrides via `responsesApiModels` / `chatApiModels` win. 6. Discovered models are merged on top of any user-defined ones โ€” never overwriting them. A model is skipped if its key already exists under **either** provider. -7. The whole flow is wrapped in a `Promise.race` against a 15 s timeout so a slow proxy never blocks boot. +7. The whole flow is wrapped in a `Promise.race` against a 20 s timeout so a slow proxy never blocks boot. ## ๐Ÿ“‹ Requirements