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
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,53 @@ 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 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

### Changed (BREAKING)
Expand Down
22 changes: 12 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,12 @@ 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`. |
| 🌐 **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. |
Expand Down Expand Up @@ -251,21 +251,23 @@ 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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
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 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 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 5 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

Expand Down Expand Up @@ -391,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.
Expand Down
4 changes: 2 additions & 2 deletions src/plugin/build-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@ export function buildModelV2(
name: formatModelName(model),
capabilities: {
temperature: true,
reasoning: false,
reasoning: !!model.supports_reasoning,
attachment: isVision || isAudio,
toolcall: !!model.supports_function_calling,
input: {
text: true,
audio: isAudio,
image: isVision,
video: false,
pdf: false,
pdf: !!model.supports_pdf_input,
},
output: {
text: !isImageOut,
Expand Down
134 changes: 115 additions & 19 deletions src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,28 @@ import type { Plugin, PluginInput } from '@opencode-ai/plugin'
import {
autoDetectLiteLLM,
checkLiteLLMHealth,
discoverLiteLLMModelInfo,
discoverLiteLLMModels,
normalizeBaseURL,
} from '../utils/litellm-api'
import {
formatModelName,
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
// 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
* 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<string, Set<string>>()

/**
* Read `customHeaders` from a provider options block.
Expand All @@ -32,13 +42,39 @@ 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,
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,
}
}

/**
* 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<string, unknown> {
function toConfigModel(model: LiteLLMModel): Record<string, unknown> | null {
const type = categorizeModel(model)
if (type === 'embedding' || type === 'image' || type === 'audio') {
return null
}
const entry: Record<string, unknown> = {
name: formatModelName(model),
}
Expand All @@ -51,13 +87,18 @@ function toConfigModel(model: LiteLLMModel): Record<string, unknown> {
if (model.supports_function_calling) {
entry.tool_call = true
}
if (model.supports_reasoning) {
entry.reasoning = true
}
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
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
}
Expand Down Expand Up @@ -158,48 +199,103 @@ 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}`,
)
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),
)
return
}

const discovered = modelsResult.value
let infoByName: Map<string, LiteLLMModelInfo> | 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(
'[opencode-litellm] LiteLLM responded but exposed zero models.',
)
return
}

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
models[model.id] = toConfigModel(model)
const info = infoByName?.get(model.id)
if (infoByName && !info) unmatched.push(model.id)
const entry = toConfigModel(info ? enrichModel(model, info) : model)
if (!entry) {
skipped++
continue
}
models[model.id] = entry
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 ${discovered.length} models from ${baseURL}`,
`[opencode-litellm] Discovered ${discovered.length} models from ${baseURL} ` +
`(${added} added` +
(skipped > 0 ? `, ${skipped} non-chat hidden` : '') +
(wildcards > 0 ? `, ${wildcards} wildcard ignored` : '') +
')',
)
}

Expand Down
Loading
Loading