diff --git a/CHANGELOG.md b/CHANGELOG.md index fd26f6d..dbf8cc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 5683414..70a6ad4 100644 --- a/README.md +++ b/README.md @@ -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. | @@ -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 { @@ -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..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`) @@ -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..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. @@ -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. diff --git a/src/plugin/index.ts b/src/plugin/index.ts index ac33651..128ca62 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -10,7 +10,8 @@ import { import { applyModelNameTemplate, DEFAULT_MODEL_NAME_TEMPLATE, - formatModelName, + normalizeModelDisplayName, + resolveModelDisplayName, categorizeModel, type ModelNaming, } from '../utils/format-model-name' @@ -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 }) } /** @@ -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, @@ -326,7 +333,11 @@ export function toConfigModel( const providerId = naming.providerId ?? '' const entry: Record = { 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, @@ -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..name` still wins at merge time. */ export interface DiscoveredModels { models: Record responsesIds: string[] hints: Record + displayNames: Record } /** @@ -492,6 +507,7 @@ export async function discoverModels( const built: Record = {} const responsesIds: string[] = [] const hints: Record = {} + const displayNames: Record = {} let skipped = 0 let wildcards = 0 let filtered = 0 @@ -520,6 +536,13 @@ export async function discoverModels( continue } built[model.id] = entry + // Record the display name for every built model: an explicit + // models..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] = { @@ -558,7 +581,7 @@ export async function discoverModels( ')', ) - return { models: built, responsesIds, hints } + return { models: built, responsesIds, hints, displayNames } } /** @@ -674,7 +697,9 @@ function readHeaderMap(value: unknown): Record { * 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, @@ -682,12 +707,17 @@ function renameForProvider( naming: ModelNaming, providerId: string, providerName: string, + displayName?: string, ): Record { 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, @@ -708,6 +738,7 @@ function injectDiscoveredModels( baseModels: Record, entries: Record, responsesIds: readonly string[], + displayNames: Record, baseURL: string, apiKey: string | undefined, customHeaders: Record | undefined, @@ -778,6 +809,7 @@ function injectDiscoveredModels( naming, responsesProviderId, siblingName, + displayNames[id], ) } added.push(...mergeModels(siblingModels, responsesEntries)) @@ -822,7 +854,13 @@ async function backgroundRefresh(cacheKey: string): Promise { 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)`, @@ -1117,6 +1155,7 @@ export const LiteLLMPlugin: Plugin = async (input: PluginInput) => { models, cached.models, cached.responsesIds, + cached.displayNames, baseURL, runtimeKey, customHeaders, @@ -1154,6 +1193,7 @@ export const LiteLLMPlugin: Plugin = async (input: PluginInput) => { models, discovered.models, discovered.responsesIds, + discovered.displayNames, baseURL, runtimeKey, customHeaders, @@ -1161,7 +1201,13 @@ export const LiteLLMPlugin: Plugin = async (input: PluginInput) => { 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, + ) } } }, diff --git a/src/plugin/v2.ts b/src/plugin/v2.ts index 5fd6b2a..0f2a539 100644 --- a/src/plugin/v2.ts +++ b/src/plugin/v2.ts @@ -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 = [] @@ -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 } @@ -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.`, diff --git a/src/types/index.ts b/src/types/index.ts index 766ceae..8e193b3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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 diff --git a/src/utils/format-model-name.ts b/src/utils/format-model-name.ts index bc04883..b60bd7c 100644 --- a/src/utils/format-model-name.ts +++ b/src/utils/format-model-name.ts @@ -197,13 +197,51 @@ export function formatModelName(model: Pick): 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, + 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 diff --git a/src/utils/litellm-api.ts b/src/utils/litellm-api.ts index 522f798..c3b63b3 100644 --- a/src/utils/litellm-api.ts +++ b/src/utils/litellm-api.ts @@ -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, diff --git a/src/utils/model-cache.ts b/src/utils/model-cache.ts index 1222ea2..2081000 100644 --- a/src/utils/model-cache.ts +++ b/src/utils/model-cache.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto' import { readFileSync, mkdirSync, writeFileSync, renameSync, rmSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' -import type { ModelNaming } from './format-model-name' +import { normalizeModelDisplayName, type ModelNaming } from './format-model-name' /** * Facts about a discovered model's upstream that OpenCode 2 turns into @@ -30,7 +30,7 @@ export interface ModelHints { * from older plugin versions are ignored rather than deserialized into * an incompatible structure. */ -const CACHE_VERSION = 6 +const CACHE_VERSION = 7 /** * Maximum age of a cache entry before it's treated as a miss. Without a @@ -56,6 +56,13 @@ interface ModelCacheFile { * no place in a provider-config entry (see `ModelHints`). */ hints?: Record + /** + * LiteLLM `model_info.display_name` values that were used for a model's + * display name. The templated name is already baked into `models`, but + * OpenCode 1's Responses sibling re-renders names with its own provider + * placeholders and needs the pre-template display name to do so. + */ + displayNames?: Record } /** @@ -183,6 +190,17 @@ function normalizeResponsesIds(value: unknown): string[] { return value.filter((id): id is string => typeof id === 'string') } +/** Keep only non-empty string entries of a cached `displayNames` map. */ +function normalizeDisplayNames(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + const out: Record = {} + for (const [id, name] of Object.entries(value as Record)) { + const normalized = normalizeModelDisplayName(name) + if (normalized !== undefined) out[id] = normalized + } + return out +} + /** Keep only well-formed entries of a cached `hints` map. */ function normalizeHints(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) return {} @@ -220,10 +238,10 @@ export function readModelCache( } /** - * Read the models, Responses-API model ids and model hints for a cache key - * in one parse. Returns `null` under the same conditions as - * {@link readModelCache} (missing/expired cache), so the three can't - * disagree within a single lookup. + * Read the models, Responses-API model ids, model hints and LiteLLM display + * names for a cache key in one parse. Returns `null` under the same + * conditions as {@link readModelCache} (missing/expired cache), so the four + * can't disagree within a single lookup. */ export function readModelCacheEntry( cacheKey: string, @@ -231,6 +249,7 @@ export function readModelCacheEntry( models: Record responsesIds: string[] hints: Record + displayNames: Record } | null { const parsed = readCacheFile(cacheKey) if (!parsed) return null @@ -244,6 +263,7 @@ export function readModelCacheEntry( models: parsed.models, responsesIds: normalizeResponsesIds(parsed.responsesIds), hints: normalizeHints(parsed.hints), + displayNames: normalizeDisplayNames(parsed.displayNames), } } @@ -278,6 +298,7 @@ export function writeModelCache( models: Record, responsesIds: readonly string[] = [], hints: Record = {}, + displayNames: Record = {}, ): void { try { mkdirSync(cacheDir(), { recursive: true }) @@ -287,6 +308,7 @@ export function writeModelCache( models, responsesIds: [...responsesIds], hints, + displayNames, } // Write to a temp file and rename so concurrent OpenCode processes // never observe a partially-written JSON file (rename is atomic on diff --git a/test/litellm-api.test.ts b/test/litellm-api.test.ts index 1640a7a..a4ac9f7 100644 --- a/test/litellm-api.test.ts +++ b/test/litellm-api.test.ts @@ -178,6 +178,25 @@ describe('reasoning-effort discovery', () => { }) }) +describe('display_name discovery', () => { + it('indexes the display name under every alias /v1/models may use', async () => { + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ + data: [ + { + model_name: 'company-alias', + model_info: { key: 'deployment-key', display_name: 'Company Model' }, + litellm_params: { model: 'openai/gpt-4o' }, + }, + ], + }))) + + const discovered = await discoverLiteLLMModelInfo('https://proxy.example.com', 'test-key') + expect(discovered.get('company-alias')?.display_name).toBe('Company Model') + expect(discovered.get('deployment-key')?.display_name).toBe('Company Model') + expect(discovered.get('openai/gpt-4o')?.display_name).toBe('Company Model') + }) +}) + describe('restricted reasoning models', () => { // LiteLLM's Pro entries omit supports_low_reasoning_effort, and older // entries even report minimal=true despite the provider's restricted set. diff --git a/test/model-cache.test.ts b/test/model-cache.test.ts index 778bfbc..37454b4 100644 --- a/test/model-cache.test.ts +++ b/test/model-cache.test.ts @@ -76,6 +76,7 @@ describe('model cache', () => { models: { chat: {}, responses: {} }, responsesIds: ['responses'], hints: {}, + displayNames: {}, }) }) @@ -92,6 +93,27 @@ describe('model cache', () => { }) }) + it('round-trips display names and drops malformed ones', () => { + writeModelCache('display@http://x', { a: {}, b: {} }, [], {}, { a: ' A ', b: '' }) + expect(readModelCacheEntry('display@http://x')?.displayNames).toEqual({ a: 'A' }) + rewriteCacheFile('display@http://x', (parsed) => { + parsed.displayNames = { a: 1, b: null, c: ' C ', d: ' ' } + }) + expect(readModelCacheEntry('display@http://x')?.displayNames).toEqual({ c: 'C' }) + }) + + it('treats caches that predate display names as a miss', () => { + writeModelCache(KEY, { m: {} }) + rewriteCacheFile(KEY, (parsed) => { + // v6 entries cannot carry display names, so a cache written before this + // feature must not keep serving the old formatted names. + parsed.version = 6 + }) + expect(readModelCacheEntry(KEY)).toBeNull() + expect(readModelCache(KEY)).toBeNull() + writeModelCache(KEY, { m: {} }) + }) + it('treats caches that predate the thinking hints as a miss', () => { writeModelCache(KEY, { m: {} }, ['m']) rewriteCacheFile(KEY, (parsed) => { diff --git a/test/plugin-v1.test.ts b/test/plugin-v1.test.ts index b910424..d96f794 100644 --- a/test/plugin-v1.test.ts +++ b/test/plugin-v1.test.ts @@ -68,7 +68,9 @@ describe('OpenCode 1 config hook', () => { process.env.HOME = homeDirectory } - function mockProxy(models: Array<{ id: string; mode?: string; params?: string[] }>): void { + function mockProxy( + models: Array<{ id: string; mode?: string; params?: string[]; displayName?: unknown }>, + ): void { globalThis.fetch = vi.fn(async (input) => { const url = String(input) if (url.endsWith('/v1/model/info')) { @@ -80,6 +82,9 @@ describe('OpenCode 1 config hook', () => { key: model.id, mode: model.mode ?? 'chat', supported_openai_params: model.params, + ...(model.displayName !== undefined + ? { display_name: model.displayName } + : {}), }, })), }), @@ -263,6 +268,236 @@ describe('OpenCode 1 config hook', () => { }) }) + it('uses display_name for display only, leaving ids, routing and curated names alone', async () => { + useIsolatedState() + const baseURL = 'http://127.0.0.1:45563/v1' + mockProxy([ + { id: 'chat-model', displayName: 'Chat Display' }, + { id: 'responses-model', mode: 'responses', displayName: 'Responses Display' }, + { id: 'curated-model', displayName: 'Should Not Win' }, + ]) + + const plugin = await LiteLLMPlugin({ + client: { app: { log: vi.fn(async () => {}) } }, + } as never) + const config: OpenCodeConfig = { + provider: { + 'litellm-team-a': { + npm: '@ai-sdk/openai-compatible', + name: 'Team A', + options: { baseURL, apiKey: 'example-key' }, + models: { 'curated-model': { name: 'Hand-picked' } }, + }, + }, + } + await plugin.config!(config as never) + + const models = config.provider['litellm-team-a'].models ?? {} + // Display names change, ids and routing do not. + expect(Object.keys(models).sort()).toEqual(['chat-model', 'curated-model']) + expect(models['chat-model']).toMatchObject({ name: 'Chat Display' }) + // An explicit models..name still wins. + expect(models['curated-model']).toMatchObject({ name: 'Hand-picked' }) + + const sibling = config.provider['litellm-team-a-responses'] + expect(Object.keys(sibling.models ?? {})).toEqual(['responses-model']) + expect(sibling.models?.['responses-model']).toMatchObject({ name: 'Responses Display' }) + + const cacheKey = buildCacheKey('litellm-team-a', 'http://127.0.0.1:45563', {}, {}, {}, 'example-key') + const cached = readModelCacheEntry(cacheKey) + expect(cached?.responsesIds).toEqual(['responses-model']) + // The upstream display name is cached even for a curated model: the + // explicit `models..name` wins at merge time, not at discovery. + expect(cached?.displayNames).toEqual({ + 'chat-model': 'Chat Display', + 'responses-model': 'Responses Display', + 'curated-model': 'Should Not Win', + }) + }) + + it('keeps display_name when the Responses sibling re-renders the template', async () => { + useIsolatedState() + const baseURL = 'http://127.0.0.1:45564/v1' + mockProxy([ + { id: 'deepseek-v4.1-flash', mode: 'responses', displayName: ' DeepSeek V4.1 Flash Pro ' }, + ]) + + const plugin = await LiteLLMPlugin({ + client: { app: { log: vi.fn(async () => {}) } }, + } as never) + const config: OpenCodeConfig = { + provider: { + 'litellm-team-a': { + npm: '@ai-sdk/openai-compatible', + name: 'Team A', + options: { + baseURL, + apiKey: 'example-key', + modelNameTemplate: '{name} ({provider_id} / {provider_name})', + }, + models: {}, + }, + }, + } + await plugin.config!(config as never) + + expect( + config.provider['litellm-team-a-responses'].models?.['deepseek-v4.1-flash'], + ).toMatchObject({ + name: 'DeepSeek V4.1 Flash Pro (litellm-team-a-responses / Team A Responses)', + }) + }) + + it('keeps a cached display_name when re-rendering the Responses sibling', async () => { + useIsolatedState() + const baseURL = 'http://127.0.0.1:45567/v1' + const cacheKey = buildCacheKey( + 'litellm-team-a', + 'http://127.0.0.1:45567', + {}, + {}, + { + modelNameTemplate: '{name} ({provider_id} / {provider_name})', + providerName: 'Team A', + }, + 'example-key', + ) + writeModelCache( + cacheKey, + { + 'deepseek-v4.1-flash': { + name: 'DeepSeek V4.1 Flash Pro (litellm-team-a / Team A)', + }, + }, + ['deepseek-v4.1-flash'], + {}, + { 'deepseek-v4.1-flash': 'DeepSeek V4.1 Flash Pro' }, + ) + // A cache miss would go to the network; fail fast so the test is offline. + globalThis.fetch = vi.fn( + async () => new Response('unexpected network call', { status: 500 }), + ) + + const plugin = await LiteLLMPlugin({ + client: { app: { log: vi.fn(async () => {}) } }, + } as never) + const config: OpenCodeConfig = { + provider: { + 'litellm-team-a': { + npm: '@ai-sdk/openai-compatible', + name: 'Team A', + options: { + baseURL, + apiKey: 'example-key', + modelNameTemplate: '{name} ({provider_id} / {provider_name})', + }, + models: {}, + }, + }, + } + await plugin.config!(config as never) + + expect( + config.provider['litellm-team-a-responses'].models?.['deepseek-v4.1-flash'], + ).toMatchObject({ + name: 'DeepSeek V4.1 Flash Pro (litellm-team-a-responses / Team A Responses)', + }) + }) + + it('does not record display names when formatting is disabled', async () => { + useIsolatedState() + const baseURL = 'http://127.0.0.1:45568/v1' + mockProxy([{ id: 'deepseek-v4.1-flash', displayName: 'DeepSeek V4.1 Flash Pro' }]) + + const plugin = await LiteLLMPlugin({ + client: { app: { log: vi.fn(async () => {}) } }, + } as never) + const config: OpenCodeConfig = { + provider: { + litellm: { + npm: '@ai-sdk/openai-compatible', + name: 'LiteLLM (proxy)', + options: { baseURL, apiKey: 'example-key', formatModelNames: false }, + models: {}, + }, + }, + } + await plugin.config!(config as never) + + expect(config.provider.litellm.models?.['deepseek-v4.1-flash']).toMatchObject({ + name: 'deepseek-v4.1-flash', + }) + const cacheKey = buildCacheKey( + 'litellm', + 'http://127.0.0.1:45568', + {}, + {}, + { formatModelNames: false }, + 'example-key', + ) + expect(readModelCacheEntry(cacheKey)?.displayNames).toEqual({}) + }) + + it('falls back to id formatting when /v1/model/info is unavailable', async () => { + useIsolatedState() + const baseURL = 'http://127.0.0.1:45565/v1' + globalThis.fetch = vi.fn(async (input) => { + const url = String(input) + if (url.endsWith('/v1/model/info')) { + return new Response('not available', { status: 404 }) + } + return new Response( + JSON.stringify({ data: [{ id: 'claude-3-5-sonnet', object: 'model' }] }), + { status: 200 }, + ) + }) + + const config = await runConfigHook(baseURL) + + expect(config.provider.litellm.models?.['claude-3-5-sonnet']).toMatchObject({ + name: 'Claude 3.5 Sonnet', + }) + }) + + it('refreshes a changed display_name through the existing background refresh', async () => { + useIsolatedState() + const baseURL = 'http://127.0.0.1:45566/v1' + mockProxy([{ id: 'deepseek-v4.1-flash', displayName: 'Old Name' }]) + const input = { client: { app: { log: vi.fn(async () => {}) } } } + const plugin = await LiteLLMPlugin(input as never) + const config: OpenCodeConfig = { + provider: { + litellm: { + npm: '@ai-sdk/openai-compatible', + name: 'LiteLLM (proxy)', + options: { baseURL, apiKey: 'example-key' }, + models: {}, + }, + }, + } + // Cold start, backdated so the next session is allowed to refresh. + const started = Date.now() + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(started - 10 * 60 * 1000) + await plugin.config!(config as never) + dateNow.mockRestore() + + const cacheKey = buildCacheKey('litellm', 'http://127.0.0.1:45566', {}, {}, {}, 'example-key') + expect(readModelCacheEntry(cacheKey)?.models['deepseek-v4.1-flash']).toMatchObject({ + name: 'Old Name', + }) + + mockProxy([{ id: 'deepseek-v4.1-flash', displayName: 'New Name' }]) + await plugin.event!({ event: { type: 'session.created' } } as never) + await vi.waitFor(() => + expect(readModelCacheEntry(cacheKey)?.models['deepseek-v4.1-flash']).toMatchObject({ + name: 'New Name', + }), + ) + expect(readModelCacheEntry(cacheKey)?.displayNames).toEqual({ + 'deepseek-v4.1-flash': 'New Name', + }) + }) + it('routes Responses models to a sibling provider, leaving chat models alone', async () => { useIsolatedState() const baseURL = 'http://127.0.0.1:45551/v1' diff --git a/test/plugin-v2.test.ts b/test/plugin-v2.test.ts index c6dd3a4..b78cb77 100644 --- a/test/plugin-v2.test.ts +++ b/test/plugin-v2.test.ts @@ -1067,6 +1067,177 @@ describe('OpenCode 2 plugin entrypoint', () => { await cleanup?.() }) + it('uses LiteLLM display_name for OpenCode 2 model names without changing ids', async () => { + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-display-name-')) + process.env.XDG_CACHE_HOME = cacheDirectory + const baseURL = 'http://127.0.0.1:44448' + globalThis.fetch = vi.fn(async (input) => { + const url = String(input) + if (url.endsWith('/v1/model/info')) { + return new Response( + JSON.stringify({ + data: [ + { + model_name: 'deepseek-v4.1-flash', + model_info: { + key: 'deepseek-v4.1-flash', + mode: 'chat', + display_name: ' DeepSeek V4.1 Flash Pro ', + }, + }, + ], + }), + { status: 200 }, + ) + } + return new Response( + JSON.stringify({ data: [{ id: 'deepseek-v4.1-flash', object: 'model' }] }), + { status: 200 }, + ) + }) + + const registered: Array<{ + info: Record + models: Array> + }> = [] + const editor = { + list: () => [], + get: () => undefined, + add: (entry: { info: Record; models: Array> }) => { + registered.push(entry) + }, + update: vi.fn(), + remove: vi.fn(), + models: { set: vi.fn(), update: vi.fn(), remove: vi.fn() }, + } + const context = { + app: { name: 'OpenCode', version: '2.0.14', channel: 'stable' }, + options: { + baseURL: `${baseURL}/v1`, + apiKey: 'example-api-key', + modelNameTemplate: '{name} ({provider_name})', + }, + provider: { + list: vi.fn(async () => ({ data: [] })), + transform: vi.fn(async (transform: (editor: unknown) => void) => { + transform(editor as never) + return { dispose: vi.fn(async () => {}) } + }), + reload: vi.fn(async () => {}), + }, + event: { subscribe: () => (async function* () {})() }, + } as unknown as Context + + const cleanup = await plugin.setup(context) + + expect(registered[0].models).toHaveLength(1) + expect(registered[0].models[0]).toMatchObject({ + id: 'deepseek-v4.1-flash', + name: 'DeepSeek V4.1 Flash Pro (litellm)', + }) + // The Responses-API routing and ids are untouched by the display name. + expect(registered[0].models[0]).not.toHaveProperty('package') + + const cacheKey = buildCacheKey( + 'litellm', + baseURL, + {}, + {}, + { modelNameTemplate: '{name} ({provider_name})', providerName: 'litellm' }, + 'example-api-key', + ) + expect(readModelCacheEntry(cacheKey)?.displayNames).toEqual({ + 'deepseek-v4.1-flash': 'DeepSeek V4.1 Flash Pro', + }) + + await cleanup?.() + }) + + it('reloads with a changed display_name through the existing refresh', async () => { + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-display-refresh-')) + process.env.XDG_CACHE_HOME = cacheDirectory + const baseURL = 'http://127.0.0.1:44449' + const cacheKey = buildCacheKey('litellm', baseURL, {}, {}) + const now = Date.now() + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(now - 10 * 60 * 1000) + writeModelCache(cacheKey, { 'deepseek-v4.1-flash': { name: 'Old Name' } }) + dateNow.mockRestore() + + globalThis.fetch = vi.fn(async (input) => { + const url = String(input) + if (url.endsWith('/v1/model/info')) { + return new Response( + JSON.stringify({ + data: [ + { + model_name: 'deepseek-v4.1-flash', + model_info: { + key: 'deepseek-v4.1-flash', + mode: 'chat', + display_name: 'New Name', + }, + }, + ], + }), + { status: 200 }, + ) + } + return new Response( + JSON.stringify({ data: [{ id: 'deepseek-v4.1-flash', object: 'model' }] }), + { status: 200 }, + ) + }) + + const registeredModels: Array>> = [] + let providerTransform: ((editor: unknown) => void) | undefined + const editor = { + list: () => [], + get: () => undefined, + add: (entry: { models: Array> }) => { + registeredModels.push(entry.models) + }, + update: vi.fn(), + remove: vi.fn(), + models: { set: vi.fn(), update: vi.fn(), remove: vi.fn() }, + } + const reload = vi.fn(async () => { + providerTransform?.(editor) + }) + const context = { + app: { name: 'OpenCode', version: '2.0.14', channel: 'stable' }, + options: { baseURL: `${baseURL}/v1` }, + provider: { + list: vi.fn(async () => ({ data: [] })), + transform: vi.fn(async (transform: (editor: unknown) => void) => { + providerTransform = transform + transform(editor) + return { dispose: vi.fn(async () => {}) } + }), + reload, + }, + event: { + subscribe: () => + (async function* () { + yield { type: 'session.created' } + })(), + }, + } as unknown as Context + + const cleanup = await plugin.setup(context) + await vi.waitFor(() => expect(reload).toHaveBeenCalledOnce()) + expect(registeredModels[0].map((model) => [model.id, model.name])).toEqual([ + ['deepseek-v4.1-flash', 'Old Name'], + ]) + expect(registeredModels[1].map((model) => [model.id, model.name])).toEqual([ + ['deepseek-v4.1-flash', 'New Name'], + ]) + expect(readModelCacheEntry(cacheKey)?.displayNames).toEqual({ + 'deepseek-v4.1-flash': 'New Name', + }) + + await cleanup?.() + }) + it('reloads the provider registry when a background discovery changes models', async () => { cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-refresh-test-')) process.env.XDG_CACHE_HOME = cacheDirectory diff --git a/test/to-config-model.test.ts b/test/to-config-model.test.ts index 938ca81..a9750cd 100644 --- a/test/to-config-model.test.ts +++ b/test/to-config-model.test.ts @@ -58,6 +58,66 @@ describe('toConfigModel naming (formatModelNames)', () => { }) }) +describe('toConfigModel naming (LiteLLM display_name)', () => { + const chat = model('deepseek-v4.1-flash') + + it('prefers a valid display_name over id formatting', () => { + expect(toConfigModel(chat, { display_name: 'DeepSeek V4.1 Flash Pro' })?.name).toBe( + 'DeepSeek V4.1 Flash Pro', + ) + // Without a display_name the id is still prettified. + expect(toConfigModel(chat)?.name).toBe('DeepSeek V4.1 Flash') + }) + + it('trims surrounding whitespace from display_name', () => { + expect(toConfigModel(chat, { display_name: ' DeepSeek V4.1 Flash Pro ' })?.name).toBe( + 'DeepSeek V4.1 Flash Pro', + ) + }) + + it('falls back to the formatted id when display_name is missing or invalid', () => { + expect(toConfigModel(chat)?.name).toBe('DeepSeek V4.1 Flash') + expect(toConfigModel(chat, { display_name: null })?.name).toBe('DeepSeek V4.1 Flash') + expect(toConfigModel(chat, { display_name: '' })?.name).toBe('DeepSeek V4.1 Flash') + expect(toConfigModel(chat, { display_name: ' ' })?.name).toBe('DeepSeek V4.1 Flash') + expect( + toConfigModel(chat, { display_name: 42 as unknown as string })?.name, + ).toBe('DeepSeek V4.1 Flash') + // The formatted fallback still collapses dotted versions. + expect( + toConfigModel(model('claude-opus-4-7'), { display_name: null })?.name, + ).toBe('Claude Opus 4.7') + }) + + it('keeps the raw id when formatting is disabled, even with a display_name', () => { + expect( + toConfigModel(chat, { display_name: 'DeepSeek V4.1 Flash Pro' }, { formatModelNames: false }) + ?.name, + ).toBe('deepseek-v4.1-flash') + }) + + it('applies modelNameTemplate to the display_name', () => { + expect( + toConfigModel(chat, { display_name: 'DeepSeek V4.1 Flash Pro' }, { + providerId: 'litellm-team-a', + providerName: 'Team A', + modelNameTemplate: '{name} ({provider_name})', + })?.name, + ).toBe('DeepSeek V4.1 Flash Pro (Team A)') + }) + + it('keeps the raw id + template behavior when formatting is disabled', () => { + expect( + toConfigModel(chat, { display_name: 'DeepSeek V4.1 Flash Pro' }, { + formatModelNames: false, + providerId: 'litellm-team-a', + providerName: 'Team A', + modelNameTemplate: '{name} ({provider_name})', + })?.name, + ).toBe('deepseek-v4.1-flash (Team A)') + }) +}) + describe('toConfigModel naming (modelNameTemplate)', () => { const chat = model('anthropic/claude-3-5-sonnet') const naming = { providerId: 'litellm-team-a', providerName: 'Team A' }