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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
### Features

* `modelNameTemplate` setting for discovered model display names, with `{name}`, `{id}`, `{provider_id}` and `{provider_name}` placeholders ([#52](https://github.com/yuseferi/opencode-litellm/issues/52))
* prefer LiteLLM's `model_info.display_name` for discovered model display names, falling back to id formatting when unset or invalid

# [1.9.0](https://github.com/yuseferi/opencode-litellm/compare/v1.8.1...v1.9.0) (2026-11-03)

Expand Down
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ also shows how to configure a provider directly in `opencode.json`.
| 🔍 **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`. |
| ⚡ **Stale-while-revalidate** | A warm disk cache supplies models without a network request. New sessions trigger a background refresh, and cache entries expire after 7 days. |
| 🏷️ **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`. Set `formatModelNames: false` to keep the raw LiteLLM ids instead. |
| 🏷️ **Smart formatting** | Uses LiteLLM's `model_info.display_name` when set; otherwise 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`. Set `formatModelNames: false` to keep the raw LiteLLM ids instead. |
| 🧠 **Modality-aware** | Enriches `/v1/models` entries with `/v1/model/info` (`mode`, token limits, capability flags) and hides embedding / image / audio models from the picker. Declares `temperature` support only for models whose `supported_openai_params` lists it, so OpenCode applies (and the user can set) a sampling temperature on exactly those models. |
| 💵 **Real pricing** | Maps `input_cost_per_token` / `output_cost_per_token` (and cache read/write costs) from `/v1/model/info` into OpenCode's `cost` field, so the picker and `/cost` show what the proxy actually bills instead of `$0.00`. Models LiteLLM has no price for are left unpriced, not falsely marked free. |
| 🧩 **Reasoning-effort variants** | When LiteLLM reports per-model effort support (`supports_low_reasoning_effort`, …), the plugin surfaces each level as a picker variant automatically. |
Expand Down Expand Up @@ -472,7 +472,7 @@ Model classification (tool-call badge, attachments, reasoning, input modalities)

### Keeping raw model ids in the picker (`formatModelNames`)

By default the plugin prettifies each discovered id into a display name — `anthropic/claude-3-5-sonnet` shows up as `Claude 3.5 Sonnet`. If you'd rather see your LiteLLM `model_list` aliases verbatim (for example because your team refers to models by those exact names, or the formatter mangles an internal naming scheme), turn formatting off:
By default the plugin uses the name LiteLLM reports in `model_info.display_name` when it is set, and otherwise prettifies the id — `anthropic/claude-3-5-sonnet` shows up as `Claude 3.5 Sonnet`. If you'd rather see your LiteLLM `model_list` aliases verbatim (for example because your team refers to models by those exact names, or the formatter mangles an internal naming scheme), turn formatting off:

```jsonc
{
Expand All @@ -487,9 +487,11 @@ By default the plugin prettifies each discovered id into a display name — `ant
}
```

- The display name becomes the exact model id as returned by `/v1/models` (provider prefix, version suffixes and all). Nothing else changes — ids, capability flags, pricing and filtering behave exactly as before.
- With formatting off, the display name becomes the exact model id as returned by `/v1/models` (provider prefix, version suffixes and all). Nothing else changes — ids, capability flags, pricing and filtering behave exactly as before.
- With formatting on, a valid `model_info.display_name` replaces the formatted id. Empty, whitespace-only and non-string values are ignored.
- An explicit `models.<id>.name` always wins; `formatModelNames: false` keeps the raw id even when `display_name` is set.
- Only a boolean `false` disables formatting; omitting the option or setting anything else keeps the default.
- Like the other options above, `formatModelNames` is part of the cache identity, so toggling it triggers a fresh discovery on the next start instead of serving previously cached names.
- Like the other options above, `formatModelNames` is part of the cache identity, so toggling it triggers a fresh discovery on the next start instead of serving previously cached names. A changed `display_name` is picked up by the existing background refresh (on OpenCode 1 it appears after the following start).
- To rename just a handful of models while keeping smart formatting for the rest, use the per-model `name` override described in [Overriding or curating individual models](#overriding-or-curating-individual-models-optional).

### Naming models per provider (`modelNameTemplate`)
Expand Down Expand Up @@ -523,13 +525,12 @@ Several providers can point at the same LiteLLM proxy with different keys. Every

| Placeholder | Value |
| --- | --- |
| `{name}` | The display name the plugin would produce anyway — formatted, or the raw id when `formatModelNames: false` |
| `{name}` | The display name the plugin resolved — LiteLLM's `display_name` when valid, otherwise the formatted id, or the raw id when `formatModelNames: false` |
| `{id}` | The model id exactly as `/v1/models` returned it |
| `{provider_id}` | The OpenCode provider id, for example `litellm-team-a` |
| `{provider_name}` | The provider's configured `name`, or its id when no name is set |

- The default is `{name}`, so omitting the setting changes nothing.
- The template runs after name formatting, so it composes with `formatModelNames: false`.
- An explicit `models.<id>.name` still wins and is never templated.
- Unknown placeholders are left as written, and substitution is single-pass: a placeholder that appears inside a resolved value stays literal.
- Non-string and empty values are ignored.
Expand Down Expand Up @@ -604,7 +605,7 @@ sequenceDiagram
2. If `providers.litellm` exists, its `settings.baseURL` is used. Otherwise common ports are probed — that probe is the only health check (3 s fail-fast per port).
3. With a configured `baseURL` the proxy is not contacted during startup unless the cache is cold; the discovery fetch itself fails fast if the proxy is unreachable.
4. **Fast path:** if a fresh on-disk cache exists (≤ 7 days old), its models are registered synchronously — startup does not wait on network discovery.
5. **Cold path:** `/v1/models` and `/v1/model/info` are fetched in parallel. Models are enriched with info metadata (`mode`, token limits, capability flags, per-token pricing — `/v1/models` omits these for database-defined models) and converted into OpenCode model entries with formatted `name`, inferred `modalities`, and `cost` (USD/1M tokens, converted from LiteLLM's USD/token). Non-chat models (embedding / image / audio) are excluded from the picker.
5. **Cold path:** `/v1/models` and `/v1/model/info` are fetched in parallel. Models are enriched with info metadata (`mode`, token limits, capability flags, per-token pricing — `/v1/models` omits these for database-defined models) and converted into OpenCode model entries with a resolved `name`, inferred `modalities`, and `cost` (USD/1M tokens, converted from LiteLLM's USD/token). Non-chat models (embedding / image / audio) are excluded from the picker.
6. Discovered models are added alongside user-defined models — never overwriting them — and persisted to the cache.
7. On `session.created`, the cache is revalidated in the background (throttled to once per 5 minutes). V2 calls `provider.reload()` when the inventory changes; V1 surfaces refreshed entries on the next OpenCode start. The cold path is capped by a 20 s timeout.

Expand Down
74 changes: 60 additions & 14 deletions src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {
import {
applyModelNameTemplate,
DEFAULT_MODEL_NAME_TEMPLATE,
formatModelName,
normalizeModelDisplayName,
resolveModelDisplayName,
categorizeModel,
type ModelNaming,
} from '../utils/format-model-name'
Expand Down Expand Up @@ -297,10 +298,15 @@ const USD_PER_TOKEN_TO_PER_MILLION = 1_000_000

/**
* The display name a model gets before any provider template is applied —
* the plugin's default formatting, or the raw `/v1/models` id.
* LiteLLM's `model_info.display_name` when valid, the plugin's default
* formatting, or the raw `/v1/models` id when formatting is disabled.
*/
function baseDisplayName(model: { id: string }, formatModelNames: boolean): string {
return formatModelNames ? formatModelName(model) : model.id
function baseDisplayName(
model: { id: string },
formatModelNames: boolean,
displayName?: unknown,
): string {
return resolveModelDisplayName(model, { formatModelNames, displayName })
}

/**
Expand All @@ -310,9 +316,10 @@ function baseDisplayName(model: { id: string }, formatModelNames: boolean): stri
* image, audio) — they can't be used as primary chat models and would
* clutter the picker.
*
* Exported for tests: the `naming` options decide the display name — the
* prettified id or the raw `/v1/models` id, optionally wrapped in the
* provider's `modelNameTemplate`.
* Exported for tests: the display name comes from `info.display_name` when
* valid, otherwise the prettified id or the raw `/v1/models` id when
* formatting is disabled, optionally wrapped in the provider's
* `modelNameTemplate`.
*/
export function toConfigModel(
model: LiteLLMModel,
Expand All @@ -326,7 +333,11 @@ export function toConfigModel(
const providerId = naming.providerId ?? ''
const entry: Record<string, unknown> = {
name: applyModelNameTemplate(naming.modelNameTemplate, {
name: baseDisplayName(model, naming.formatModelNames !== false),
name: baseDisplayName(
model,
naming.formatModelNames !== false,
info?.display_name,
),
id: model.id,
providerId,
// Without a configured display name the picker falls back to the id,
Expand Down Expand Up @@ -410,12 +421,16 @@ export function toConfigModel(
* lists the subset that must be invoked through the OpenAI Responses API;
* callers route those accordingly without inspecting each entry. `hints`
* carries per-model upstream facts OpenCode 2 maps to `compatibility` and
* `variants`.
* `variants`. `displayNames` records each model's valid
* `model_info.display_name` (when formatting is enabled), so a cached model
* can be re-rendered for the Responses sibling without losing it; an explicit
* `models.<id>.name` still wins at merge time.
*/
export interface DiscoveredModels {
models: Record<string, unknown>
responsesIds: string[]
hints: Record<string, ModelHints>
displayNames: Record<string, string>
}

/**
Expand Down Expand Up @@ -492,6 +507,7 @@ export async function discoverModels(
const built: Record<string, unknown> = {}
const responsesIds: string[] = []
const hints: Record<string, ModelHints> = {}
const displayNames: Record<string, string> = {}
let skipped = 0
let wildcards = 0
let filtered = 0
Expand Down Expand Up @@ -520,6 +536,13 @@ export async function discoverModels(
continue
}
built[model.id] = entry
// Record the display name for every built model: an explicit
// models.<id>.name still wins at merge time, and with formatting
// disabled the raw id wins, so nothing is recorded.
if (naming.formatModelNames !== false) {
const displayName = normalizeModelDisplayName(info?.display_name)
if (displayName) displayNames[model.id] = displayName
}
if (requiresResponsesAPI(enriched)) responsesIds.push(model.id)
const params = info?.supported_openai_params
hints[model.id] = {
Expand Down Expand Up @@ -558,7 +581,7 @@ export async function discoverModels(
')',
)

return { models: built, responsesIds, hints }
return { models: built, responsesIds, hints, displayNames }
}

/**
Expand Down Expand Up @@ -674,20 +697,27 @@ function readHeaderMap(value: unknown): Record<string, string> {
* sibling, so a template that uses `{provider_id}`/`{provider_name}` must
* resolve to the provider the user actually selects. The entry in the cache
* stays as discovered — OpenCode 2 reads the same cache with a single
* provider — so this returns a copy instead of mutating it.
* provider — so this returns a copy instead of mutating it. `displayName` is
* the LiteLLM value recorded during discovery (when any), so re-rendering
* does not lose it.
*/
function renameForProvider(
entry: Record<string, unknown>,
modelId: string,
naming: ModelNaming,
providerId: string,
providerName: string,
displayName?: string,
): Record<string, unknown> {
if (naming.modelNameTemplate === undefined) return entry
return {
...entry,
name: applyModelNameTemplate(naming.modelNameTemplate, {
name: baseDisplayName({ id: modelId }, naming.formatModelNames !== false),
name: baseDisplayName(
{ id: modelId },
naming.formatModelNames !== false,
displayName,
),
id: modelId,
providerId,
providerName,
Expand All @@ -708,6 +738,7 @@ function injectDiscoveredModels(
baseModels: Record<string, unknown>,
entries: Record<string, unknown>,
responsesIds: readonly string[],
displayNames: Record<string, string>,
baseURL: string,
apiKey: string | undefined,
customHeaders: Record<string, string> | undefined,
Expand Down Expand Up @@ -778,6 +809,7 @@ function injectDiscoveredModels(
naming,
responsesProviderId,
siblingName,
displayNames[id],
)
}
added.push(...mergeModels(siblingModels, responsesEntries))
Expand Down Expand Up @@ -822,7 +854,13 @@ async function backgroundRefresh(cacheKey: string): Promise<void> {
DISCOVERY_TIMEOUT_MS,
)
if (discovered && Object.keys(discovered.models).length > 0) {
writeModelCache(cacheKey, discovered.models, discovered.responsesIds, discovered.hints)
writeModelCache(
cacheKey,
discovered.models,
discovered.responsesIds,
discovered.hints,
discovered.displayNames,
)
log(
'info',
`[opencode-litellm] Background-refreshed model cache for ${ctx.baseURL} (${Object.keys(discovered.models).length} models)`,
Expand Down Expand Up @@ -1117,6 +1155,7 @@ export const LiteLLMPlugin: Plugin = async (input: PluginInput) => {
models,
cached.models,
cached.responsesIds,
cached.displayNames,
baseURL,
runtimeKey,
customHeaders,
Expand Down Expand Up @@ -1154,14 +1193,21 @@ export const LiteLLMPlugin: Plugin = async (input: PluginInput) => {
models,
discovered.models,
discovered.responsesIds,
discovered.displayNames,
baseURL,
runtimeKey,
customHeaders,
runtimeFetch,
naming,
)
injectedModelIds.set(cacheKey, new Set(added))
writeModelCache(cacheKey, discovered.models, discovered.responsesIds, discovered.hints)
writeModelCache(
cacheKey,
discovered.models,
discovered.responsesIds,
discovered.hints,
discovered.displayNames,
)
}
}
},
Expand Down
18 changes: 15 additions & 3 deletions src/plugin/v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -626,7 +626,7 @@ async function makeProviderSource(
entries = discovered.models
responsesIds = discovered.responsesIds
hints = discovered.hints
writeModelCache(cacheKey, entries, responsesIds, hints)
writeModelCache(cacheKey, entries, responsesIds, hints, discovered.displayNames)
} else {
entries = null
responsesIds = []
Expand Down Expand Up @@ -746,7 +746,13 @@ async function refreshProviderSource(
compatibility: source.compatibility,
})
if (JSON.stringify(models) === JSON.stringify(source.models)) {
writeModelCache(source.cacheKey, discovered.models, discovered.responsesIds, discovered.hints)
writeModelCache(
source.cacheKey,
discovered.models,
discovered.responsesIds,
discovered.hints,
discovered.displayNames,
)
source.refreshRequired = false
return
}
Expand All @@ -760,7 +766,13 @@ async function refreshProviderSource(
throw error
}

writeModelCache(source.cacheKey, discovered.models, discovered.responsesIds, discovered.hints)
writeModelCache(
source.cacheKey,
discovered.models,
discovered.responsesIds,
discovered.hints,
discovered.displayNames,
)
source.refreshRequired = false
logInfo(
`[opencode-litellm] Refreshed ${models.length} models for provider "${source.id}"; the provider registry was reloaded.`,
Expand Down
6 changes: 6 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ export interface LiteLLMModelInfo {
db_model?: boolean
/** Alias LiteLLM assigns to the model; mirrors the `/v1/models` id. */
key?: string
/**
* Human-readable name configured on the deployment
* (`model_info.display_name`). Display-only: never used as the model id or
* in API requests. Absent or malformed values fall back to id formatting.
*/
display_name?: string | null
mode?: string
max_tokens?: number
max_input_tokens?: number
Expand Down
40 changes: 39 additions & 1 deletion src/utils/format-model-name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,13 +197,51 @@ export function formatModelName(model: Pick<LiteLLMModel, 'id'>): string {
return tokens.join(' ') || id
}

/**
* Normalise a LiteLLM `model_info.display_name` into a display name.
*
* Only a non-empty string counts. The value is trimmed so surrounding
* whitespace never reaches the picker; everything else (undefined, null,
* empty, whitespace-only, numbers, objects) is treated as "no display name"
* and lets the caller fall back to id formatting.
*/
export function normalizeModelDisplayName(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
return trimmed === '' ? undefined : trimmed
}

/**
* Resolve the display name for a discovered model before any provider
* template is applied.
*
* Precedence:
* 1. `formatModelNames: false` keeps the raw `/v1/models` id, as before.
* 2. A valid LiteLLM `model_info.display_name`.
* 3. The prettified id (`formatModelName`).
*
* `display_name` is display-only: callers keep using `model.id` for the
* model id, requests, routing and filtering.
*/
export function resolveModelDisplayName(
model: Pick<LiteLLMModel, 'id'>,
options: { formatModelNames?: boolean; displayName?: unknown } = {},
): string {
if (options.formatModelNames === false) return model.id
return normalizeModelDisplayName(options.displayName) ?? formatModelName(model)
}

/**
* The naming choices that decide how a discovered model is displayed. They
* are baked into cached model entries, so the same object also feeds the
* cache identity (see `buildCacheKey` in `model-cache.ts`).
*/
export interface ModelNaming {
/** Prettify a model id into a display name; `false` keeps the raw id. */
/**
* Prettify a model id into a display name. A valid LiteLLM
* `model_info.display_name` takes precedence; `false` keeps the raw id and
* ignores the display name too.
*/
formatModelNames?: boolean
/**
* Template applied to every discovered model's display name. Unset (or
Expand Down
9 changes: 5 additions & 4 deletions src/utils/litellm-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,11 @@ export async function discoverLiteLLMModels(
}

/**
* 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.
* Fetch per-model metadata (`mode`, token limits, capability flags and the
* optional `display_name`) 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,
Expand Down
Loading
Loading