diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ec7f28..07250d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [Unreleased] + +### 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)) + # [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 0196359..5683414 100644 --- a/README.md +++ b/README.md @@ -492,6 +492,51 @@ By default the plugin prettifies each discovered id into a display name — `ant - 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. - 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`) + +Several providers can point at the same LiteLLM proxy with different keys. Every key then exposes the same model ids, so the picker shows identical names under each provider and there is no way to tell which key a model will use. `modelNameTemplate` wraps every discovered model's display name with that provider's context: + +```jsonc +{ + "providers": { + "litellm-team-a": { + "name": "Team A", + "settings": { + "baseURL": "https://litellm.example.com/v1", + "apiKey": "{env:KEY_A}", + "modelNameTemplate": "{name} ({provider_name})" + } + }, + "litellm-team-b": { + "name": "Team B", + "settings": { + "baseURL": "https://litellm.example.com/v1", + "apiKey": "{env:KEY_B}", + "modelNameTemplate": "[B] {name}" + } + } + } +} +``` + +`Claude 3.5 Sonnet` shows up as `Claude 3.5 Sonnet (Team A)` under the first provider and `[B] Claude 3.5 Sonnet` under the second. + +| Placeholder | Value | +| --- | --- | +| `{name}` | The display name the plugin would produce anyway — formatted, 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. +- On OpenCode 1, models that need the Responses API are served by a `-responses` sibling, so `{provider_id}` and `{provider_name}` resolve to that sibling for those models (for example `litellm-team-a-responses`). +- Like `formatModelNames`, the template is part of the cache identity — and so is the resolved provider name — so changing either starts a fresh discovery on the next start instead of serving previously cached names. +- Model ids, capability flags, pricing and filtering are unaffected. + ### Prompt caching (`modelCompatibility`) On OpenCode 2.0.28+ (earlier 2.x releases ignore the setting), the OpenAI-compatible Chat Completions runtime only places Anthropic-style `cache_control` breakpoints when a model declares `compatibility.cacheFormat: "anthropic"`. The plugin sets that for every discovered Chat Completions model whose `/v1/model/info` entry lists `cache_control` under `supported_openai_params` — which LiteLLM reports for Anthropic and Vertex AI Anthropic deployments — so prompt caching keeps working for Claude behind the proxy. Only the Chat Completions runtimes read this setting: the Anthropic Messages runtime adds its own cache breakpoints, and the Responses runtimes do not use it, so models on those packages are left alone. diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 7aa04ba..ac33651 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -8,8 +8,11 @@ import { normalizeBaseURL, } from '../utils/litellm-api' import { + applyModelNameTemplate, + DEFAULT_MODEL_NAME_TEMPLATE, formatModelName, categorizeModel, + type ModelNaming, } from '../utils/format-model-name' import { requiresResponsesAPI } from '../utils/transport' import type { LiteLLMModel, LiteLLMModelInfo } from '../types' @@ -114,6 +117,8 @@ interface RefreshContext { filters: ModelFilters capabilities: ModelCapabilities formatModelNames: boolean + modelNameTemplate?: string + providerName: string providerId: string } const refreshContexts = new Map() @@ -231,6 +236,20 @@ export function readFormatModelNames(options: Record): boolean return options.formatModelNames !== false } +/** + * Read the optional display-name template. Non-strings are ignored. An empty + * string, or the `"{name}"` default spelled out, counts as unset so those + * configs keep the same cache identity (and the same warm cache) as before + * the setting existed. + */ +export function readModelNameTemplate(options: Record): string | undefined { + const value = options.modelNameTemplate + if (typeof value !== 'string') return undefined + const trimmed = value.trim() + if (trimmed === '' || trimmed === DEFAULT_MODEL_NAME_TEMPLATE) return undefined + return value +} + /** * Overlay metadata onto a `/v1/models` entry in three tiers: the entry's * own fields win, `/v1/model/info` fills gaps (notably `mode`, which @@ -276,6 +295,14 @@ function enrichModel( */ 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. + */ +function baseDisplayName(model: { id: string }, formatModelNames: boolean): string { + return formatModelNames ? formatModelName(model) : model.id +} + /** * Convert a discovered LiteLLM model into an OpenCode config-level * model entry (the shape used in `provider.*.models` inside @@ -283,20 +310,29 @@ const USD_PER_TOKEN_TO_PER_MILLION = 1_000_000 * image, audio) — they can't be used as primary chat models and would * clutter the picker. * - * Exported for tests: `formatModelNames` decides whether the display - * name is the prettified id or the raw `/v1/models` id verbatim. + * 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`. */ export function toConfigModel( model: LiteLLMModel, info?: LiteLLMModelInfo, - formatModelNames = true, + naming: ModelNaming = {}, ): Record | null { const type = categorizeModel(model) if (type === 'embedding' || type === 'image' || type === 'audio') { return null } + const providerId = naming.providerId ?? '' const entry: Record = { - name: formatModelNames ? formatModelName(model) : model.id, + name: applyModelNameTemplate(naming.modelNameTemplate, { + name: baseDisplayName(model, naming.formatModelNames !== false), + id: model.id, + providerId, + // Without a configured display name the picker falls back to the id, + // so the placeholder resolves to the same thing the user sees. + providerName: naming.providerName ?? providerId, + }), } // Some deployments only report the OpenAI-style `max_tokens` total; // use it as the context limit when `max_input_tokens` is absent. @@ -389,9 +425,9 @@ export interface DiscoveredModels { * classifies + formats each model, and returns the built entries plus the * ids that require the Responses API and per-model hints. The provider's * `includeModels`/`excludeModels` filters, `modelCapabilities` overrides and - * `formatModelNames` choice are applied here (not at merge time) so every - * path that persists or serves a cache — cold discovery and background - * refresh — writes the same adjusted view. + * naming choices (`formatModelNames`, `modelNameTemplate`) are applied here + * (not at merge time) so every path that persists or serves a cache — cold + * discovery and background refresh — writes the same adjusted view. * * Returns `null` when the proxy is unreachable/unauthorized or exposes * no models, so callers can distinguish "no data" from "empty result". @@ -403,7 +439,7 @@ export async function discoverModels( providerId: string, filters: ModelFilters = {}, capabilities: ModelCapabilities = {}, - formatModelNames = true, + naming: ModelNaming = {}, ): Promise { if (!(await checkLiteLLMHealth(baseURL, apiKey, customHeaders))) { log( @@ -478,7 +514,7 @@ export async function discoverModels( const info = infoByName?.get(model.id) if (infoByName && !info) unmatched.push(model.id) const enriched = enrichModel(model, info, capabilities[model.id]) - const entry = toConfigModel(enriched, info, formatModelNames) + const entry = toConfigModel(enriched, info, { ...naming, providerId }) if (!entry) { skipped++ continue @@ -573,10 +609,14 @@ function ensureResponsesProvider( const provider = config.provider[id] as Record if (!provider.npm) provider.npm = RESPONSES_NPM if (typeof provider.name !== 'string' || !provider.name) { + // Mirror how the base provider is labelled: its configured name, or its + // id when none is set (which is what the picker falls back to). This keeps + // `{provider_name}` in the sibling's model names equal to the sibling's + // visible label. const baseName = typeof baseProvider.name === 'string' && baseProvider.name ? baseProvider.name - : 'LiteLLM (proxy)' + : baseProviderId provider.name = `${baseName} Responses` } if (!provider.options || typeof provider.options !== 'object') { @@ -626,6 +666,35 @@ function readHeaderMap(value: unknown): Record { return out } +/** + * Re-render one discovered entry's name for the provider it is about to be + * injected into. + * + * OpenCode 1 moves Responses models into a `${baseProviderId}-responses` + * 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. + */ +function renameForProvider( + entry: Record, + modelId: string, + naming: ModelNaming, + providerId: string, + providerName: string, +): Record { + if (naming.modelNameTemplate === undefined) return entry + return { + ...entry, + name: applyModelNameTemplate(naming.modelNameTemplate, { + name: baseDisplayName({ id: modelId }, naming.formatModelNames !== false), + id: modelId, + providerId, + providerName, + }), + } +} + /** * Split freshly discovered entries into the chat provider and the Responses * sibling, injecting each side without clobbering curated entries. Returns @@ -642,27 +711,38 @@ function injectDiscoveredModels( baseURL: string, apiKey: string | undefined, customHeaders: Record | undefined, - runtimeFetch?: typeof fetch, + runtimeFetch: typeof fetch | undefined, + naming: ModelNaming, ): string[] { const responsesSet = new Set(responsesIds) - const existingSibling = config.provider[`${baseProviderId}${RESPONSES_PROVIDER_SUFFIX}`] + const responsesProviderId = `${baseProviderId}${RESPONSES_PROVIDER_SUFFIX}` + const existingSibling = + config.provider[responsesProviderId] && + typeof config.provider[responsesProviderId] === 'object' + ? (config.provider[responsesProviderId] as Record) + : undefined + // A name the user (or an earlier run) already gave the sibling, captured + // before `ensureResponsesProvider` generates one. + const configuredSiblingName = + typeof existingSibling?.name === 'string' && existingSibling.name + ? existingSibling.name + : undefined const siblingModelsExisting: Record = existingSibling && - typeof existingSibling === 'object' && - (existingSibling as Record).models && - typeof (existingSibling as Record).models === 'object' - ? ((existingSibling as Record).models as Record) + existingSibling.models && + typeof existingSibling.models === 'object' + ? (existingSibling.models as Record) : {} const chatEntries: Record = {} - const responsesEntries: Record = {} + const responsesIdsToInject: string[] = [] for (const [id, entry] of Object.entries(entries)) { // A model already present under the other provider was curated there (or // injected by an earlier run); leave it alone instead of duplicating it // across both providers. if (responsesSet.has(id)) { if (Object.hasOwn(baseModels, id)) continue - responsesEntries[id] = entry + responsesIdsToInject.push(id) } else { if (Object.hasOwn(siblingModelsExisting, id)) continue chatEntries[id] = entry @@ -670,7 +750,7 @@ function injectDiscoveredModels( } const added = mergeModels(baseModels, chatEntries) - if (Object.keys(responsesEntries).length > 0) { + if (responsesIdsToInject.length > 0) { const siblingModels = ensureResponsesProvider( config, baseProviderId, @@ -680,6 +760,26 @@ function injectDiscoveredModels( customHeaders, runtimeFetch, ) + // The sibling generates its name from the base provider's, so read it + // back; a user-configured name (from this run or an earlier one) wins. + // `{provider_name}` must equal the label the picker shows for the sibling + // because that is the provider the model is selected through. + const sibling = config.provider[responsesProviderId] as Record | undefined + const siblingName = + configuredSiblingName ?? + (sibling && typeof sibling.name === 'string' && sibling.name + ? sibling.name + : responsesProviderId) + const responsesEntries: Record = {} + for (const id of responsesIdsToInject) { + responsesEntries[id] = renameForProvider( + entries[id] as Record, + id, + naming, + responsesProviderId, + siblingName, + ) + } added.push(...mergeModels(siblingModels, responsesEntries)) } return added @@ -712,7 +812,12 @@ async function backgroundRefresh(cacheKey: string): Promise { ctx.providerId, ctx.filters, ctx.capabilities, - ctx.formatModelNames, + { + formatModelNames: ctx.formatModelNames, + modelNameTemplate: ctx.modelNameTemplate, + providerId: ctx.providerId, + providerName: ctx.providerName, + }, ), DISCOVERY_TIMEOUT_MS, ) @@ -870,6 +975,12 @@ export const LiteLLMPlugin: Plugin = async (input: PluginInput) => { const filters = readModelFilters(options) const capabilities = parseModelCapabilities(options.modelCapabilities) const formatModelNames = readFormatModelNames(options) + const modelNameTemplate = readModelNameTemplate(options) + // `{provider_name}` resolves to what the picker labels the provider + // with; OpenCode falls back to the id when no name is configured. + const providerName = + typeof provider.name === 'string' && provider.name ? provider.name : providerId + const naming = { formatModelNames, modelNameTemplate, providerId, providerName } // Resolve base URL let baseURL: string | null = null @@ -944,6 +1055,8 @@ export const LiteLLMPlugin: Plugin = async (input: PluginInput) => { // the provider keeps its models and can be reconnected from `/connect`. const cacheKey = buildCacheKey(providerId, baseURL, filters, capabilities, { formatModelNames, + modelNameTemplate, + providerName, }, sso ? ssoIdentity(sso) : apiKey) // Remember how to reach this proxy so the `event` hook can @@ -960,6 +1073,8 @@ export const LiteLLMPlugin: Plugin = async (input: PluginInput) => { filters, capabilities, formatModelNames, + modelNameTemplate, + providerName, providerId, }) @@ -1006,6 +1121,7 @@ export const LiteLLMPlugin: Plugin = async (input: PluginInput) => { runtimeKey, customHeaders, runtimeFetch, + naming, ) injectedModelIds.set(cacheKey, new Set(added)) log( @@ -1026,7 +1142,7 @@ export const LiteLLMPlugin: Plugin = async (input: PluginInput) => { providerId, filters, capabilities, - formatModelNames, + { formatModelNames, modelNameTemplate, providerId, providerName }, ), DISCOVERY_TIMEOUT_MS, ) @@ -1042,6 +1158,7 @@ export const LiteLLMPlugin: Plugin = async (input: PluginInput) => { runtimeKey, customHeaders, runtimeFetch, + naming, ) injectedModelIds.set(cacheKey, new Set(added)) writeModelCache(cacheKey, discovered.models, discovered.responsesIds, discovered.hints) diff --git a/src/plugin/v2.ts b/src/plugin/v2.ts index 1382c48..5fd6b2a 100644 --- a/src/plugin/v2.ts +++ b/src/plugin/v2.ts @@ -9,6 +9,7 @@ import { readCustomHeaders, readFormatModelNames, readModelFilters, + readModelNameTemplate, withTimeout, LiteLLMPlugin, } from './index' @@ -109,14 +110,40 @@ interface ProviderSource { capabilities: ModelCapabilities compatibility: ModelCompatibility formatModelNames: boolean + modelNameTemplate?: string + providerName: string cacheKey: string models: Model.Info[] /** * Model ids this plugin injected into the provider registry (as opposed * to ids the user curated in config). Only these are eligible to be - * removed when a later refresh no longer discovers them. + * removed when a later refresh no longer discovers them — and even then + * only while the merge can match the live entry against what the plugin + * last published for the id (see mergeProviderModels). */ ownedModelIds: Set + /** + * Snapshots of the registry entries this plugin last published for the + * ids in {@link ownedModelIds}, as far as the last merge could recognize + * them. Bookkeeping for replacements: a source replacing a predecessor + * under the same id inherits this record so its first merge can tell the + * predecessor's own output from entries that were edited afterwards + * (see {@link supersededModels}). Content comparison is a heuristic, not + * proof of authorship — see mergeProviderModels for what that does and + * does not guarantee. + */ + publishedModels: Map + /** + * Snapshot record inherited from the source this one replaced, spent by + * the first merge that runs after the swap. A live entry that still + * matches its snapshot is treated as the predecessor's own output and is + * re-rendered from the new source (or dropped when the new inventory no + * longer serves it); an entry that differs was changed afterwards — user + * curation or a host edit, the merge cannot tell which — and is kept + * untouched, with the id released. Undefined outside that first + * post-swap merge. + */ + supersededModels: ReadonlyMap | undefined } function asRecord(value: unknown): Record { @@ -259,39 +286,176 @@ function numberOrDefault(value: unknown, fallback: number): number { : fallback } +/** + * Host-side id stamps: the registry keys entries by provider and model id and + * the host fills these fields itself when reading entries back, so they are + * excluded from content identity. + */ +const EXCLUDED_IDENTITY_FIELDS: ReadonlySet = new Set(['providerID', 'modelID']) + +/** + * Content comparison between a live registry entry and a recorded snapshot. + * The plugin renders identical facts into structurally identical entries, + * so a match is treated as an indication the entry is this plugin's own + * earlier output — a heuristic, not proof. A user-curated entry almost + * always differs (most visibly in `name`, but also e.g. `enabled` or + * `settings`); a curated entry that happens to exactly match the plugin's + * rendering would be treated as plugin output and could be replaced or + * removed. An unanticipated host edit of + * any other field can only cause a missed match — the entry is then kept + * rather than replaced, which fails safe: user content is never deleted on + * a missed match, but could be replaced or removed on a spurious one. + */ +function sameEntry(a: Model.Info, b: Model.Info): boolean { + const keys = new Set([ + ...Object.keys(a as Record), + ...Object.keys(b as Record), + ]) + for (const key of keys) { + if (EXCLUDED_IDENTITY_FIELDS.has(key)) continue + if (!deeplyEqual((a as Record)[key], (b as Record)[key])) { + return false + } + } + return true +} + +function deeplyEqual(a: unknown, b: unknown): boolean { + if (a === b) return true + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) { + return false + } + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false + return a.every((item, index) => deeplyEqual(item, b[index])) + } + const recordA = a as Record + const recordB = b as Record + const keysA = Object.keys(recordA).sort() + const keysB = Object.keys(recordB).sort() + if (keysA.length !== keysB.length) return false + return keysA.every( + (key, index) => keysB[index] === key && deeplyEqual(recordA[key], recordB[key]), + ) +} + +/** + * Snapshot a registry entry for the provenance record. A deep clone, so a + * host (or a later transform) mutating the published entry in place cannot + * drag the record along with it — the recorded content stays what was + * actually published, and any such edit reads back as "no longer ours". + * Model entries are plain JSON-shaped schema data, so a JSON round-trip + * is a faithful clone. + */ +function recordEntry(model: Model.Info): Model.Info { + return JSON.parse(JSON.stringify(model)) as Model.Info +} + /** * Merge freshly discovered models into a provider's current model set. * - * Previously-discovered ids owned by this plugin are replaced/removed so a - * refresh reflects LiteLLM's current inventory; ids the user curated - * themselves are never removed. Returns the merged list plus the set of - * ids the plugin now owns. + * Previously-discovered ids owned by this plugin are kept/dropped so a + * refresh reflects LiteLLM's current inventory; distinguishable curated + * entries are preserved. Returns the merged list, the set of ids + * the plugin now owns, and the provenance record: snapshots of the last + * published entries for every owned id the merge could still recognize + * as the plugin's own output. Entries the merge kept but could no longer + * match (the live content changed after publication) stay owned per the + * steady-state semantics but drop out of the record, so a later + * replacement treats them as user-edited. + * + * A source that just replaced a predecessor carries the predecessor's + * provenance record in `superseded`. A live entry that matches its + * snapshot is treated as the predecessor's own stale output — the fresh + * discovery takes the id over (re-rendering it with the new provider's + * `providerName`/`modelNameTemplate`), or drops it when the new gateway + * no longer serves it. An entry that differs was changed after the + * predecessor published it — user curation or a host edit, which the + * merge cannot distinguish — and survives untouched, with the id + * released so later refreshes never remove it. The snapshot record is + * spent after this first post-swap merge, restoring the steady-state + * semantics for every later merge. + * + * Both recognitions rely on content comparison, a heuristic rather than + * proof of authorship. It errs toward keeping entries: deletion requires + * an owned id that still matches its last published snapshot, so user + * content is never deleted on a missed match. A curated entry that exactly + * matches the plugin's rendering can be replaced or removed. */ function mergeProviderModels( configured: Iterable, discovered: readonly Model.Info[], owned: ReadonlySet, -): { models: Model.Info[]; owned: Set } { + superseded?: ReadonlyMap, + published?: ReadonlyMap, +): { models: Model.Info[]; owned: Set; published: Map } { const discoveredIds = new Set(discovered.map((model) => model.id)) const result = new Map() const nextOwned = new Set() + // Snapshots carried into this merge for owned ids: the predecessor's + // publications (first post-swap merge) or the plugin's own last published + // snapshots (steady state). + const carriedSnapshots = new Map() + for (const [id, snapshot] of superseded ?? published ?? []) carriedSnapshots.set(id, snapshot) for (const model of configured) { - // Drop plugin-injected models that LiteLLM no longer returns; curated - // models are not in `owned`, so they always survive. - if (owned.has(model.id) && !discoveredIds.has(model.id)) continue + const snapshot = superseded?.get(model.id) + if (snapshot !== undefined) { + if (sameEntry(model, snapshot)) { + // Treated as the predecessor's own output: get out of the way; the + // discovery pass below takes the id over, or nothing does when the + // new gateway no longer serves it. + continue + } + // The entry differs from what the predecessor published, so it was + // changed afterwards - curated by the user or edited by the host, + // which this merge cannot tell apart. Keep it untouched and release + // the id from plugin ownership. + carriedSnapshots.delete(model.id) + result.set(model.id, model) + continue + } + if (!owned.has(model.id)) { + result.set(model.id, model) + continue + } + const carriedSnapshot = carriedSnapshots.get(model.id) + if (!discoveredIds.has(model.id)) { + // Only drop an undiscovered entry while it still matches the plugin's + // last published snapshot for the id. An owned id without a match + // (e.g. ownership inherited by a replacement while the user had + // already curated the id) has no evidence it is plugin output, so it + // is kept and released, never deleted. + if (carriedSnapshot !== undefined && sameEntry(model, carriedSnapshot)) continue + result.set(model.id, model) + continue + } + // Still discovered and owned: keep the live entry (steady-state + // semantics keep it even when discovery would render different content) + // and keep the carried snapshot so provenance survives content changes. result.set(model.id, model) - if (owned.has(model.id)) nextOwned.add(model.id) + nextOwned.add(model.id) + if (carriedSnapshot !== undefined) carriedSnapshots.set(model.id, carriedSnapshot) } for (const model of discovered) { if (!result.has(model.id)) { result.set(model.id, model) nextOwned.add(model.id) + carriedSnapshots.set(model.id, recordEntry(model)) } } - return { models: [...result.values()], owned: nextOwned } + // Rolling provenance record: every owned id keeps the snapshot of the + // last entry the plugin published for it. + const nextPublished = new Map() + for (const [id, model] of result) { + if (!nextOwned.has(id)) continue + const snapshot = carriedSnapshots.get(id) + if (snapshot !== undefined) nextPublished.set(id, snapshot) + } + + return { models: [...result.values()], owned: nextOwned, published: nextPublished } } function runtimeSettings(source: ProviderSource): Record { @@ -307,6 +471,7 @@ function runtimeSettings(source: ProviderSource): Record { 'modelCapabilities', 'modelCompatibility', 'formatModelNames', + 'modelNameTemplate', 'providerID', ]) { delete settings[key] @@ -409,6 +574,12 @@ async function makeProviderSource( const capabilities = parseModelCapabilities(settings.modelCapabilities) const compatibility = parseModelCompatibility(settings.modelCompatibility) const formatModelNames = readFormatModelNames(settings) + const modelNameTemplate = readModelNameTemplate(settings) + // The provider's configured name, or its id when none is set — the same + // fallback `{provider_name}` documents, so two unnamed providers stay + // distinguishable. + const providerName = provider?.name || id + const naming = { formatModelNames, modelNameTemplate, providerId: id, providerName } // Explicit config beats the URL typed into the dialog, which beats the // environment and local port probing. const detectedBaseURL = configuredBaseURL @@ -432,9 +603,7 @@ async function makeProviderSource( // the raw key): a credential change starts a fresh discovery instead of // serving another credential's cached model list. A sign-in is keyed by its // credential id, because its access token rotates on every refresh. - const cacheKey = buildCacheKey(id, baseURL, filters, capabilities, { - formatModelNames, - }, (usesConnectionCredential ? connected?.credentialID : undefined) ?? apiKey) + const cacheKey = buildCacheKey(id, baseURL, filters, capabilities, naming, (usesConnectionCredential ? connected?.credentialID : undefined) ?? apiKey) const cacheEntry = readModelCacheEntry(cacheKey) let entries = cacheEntry?.models ?? null let responsesIds = cacheEntry?.responsesIds ?? [] @@ -449,7 +618,7 @@ async function makeProviderSource( id, filters, capabilities, - formatModelNames, + naming, ), DISCOVERY_TIMEOUT_MS, ) @@ -490,6 +659,8 @@ async function makeProviderSource( capabilities, compatibility, formatModelNames, + modelNameTemplate, + providerName, cacheKey, models: toProviderModels(id, entries ?? {}, { providerPackage, @@ -498,6 +669,8 @@ async function makeProviderSource( compatibility, }), ownedModelIds: new Set(), + publishedModels: new Map(), + supersededModels: undefined, } } @@ -549,7 +722,12 @@ async function refreshProviderSource( source.id, source.filters, source.capabilities, - source.formatModelNames, + { + formatModelNames: source.formatModelNames, + modelNameTemplate: source.modelNameTemplate, + providerId: source.id, + providerName: source.providerName, + }, ), DISCOVERY_TIMEOUT_MS, ) @@ -717,6 +895,18 @@ async function reconcileProviderSources( if (existing && !existing.fromFallback && !existing.pendingPublication) continue const source = await makeProviderSource(context, provider, pluginOptions) if (!source) continue + // A source replacing this id's predecessor inherits its provenance + // record: the exact entries the predecessor published (or an inherited + // snapshot the predecessor itself had not spent yet). `providerName` and + // `modelNameTemplate` are baked into discovered names at discovery time, + // and ids alone cannot distinguish the plugin's stale rendering from an + // entry the user curated under the same id — so the successor carries the + // entries themselves, and its first merge replaces only entries that + // still match (see mergeProviderModels). For a fallback predecessor this + // is what re-renders its output with the configured provider's naming; + // for a pending configured one it can only affect entries the failed + // attempt actually published. + if (existing) source.supersededModels = existing.supersededModels ?? existing.publishedModels sources.set(source.id, source) changed.push(source) } @@ -736,8 +926,9 @@ async function reconcileProviderSources( * options/config): a fallback only stands in until a real endpoint is * known, so the dialog's answers take over from it. Explicit option/env * baseURLs — and host-registered providers — keep winning and are never - * replaced. Replaced sources' owned model ids carry over so models the - * gateway no longer serves are dropped on the next merge, and sources + * replaced. Replaced sources carry their provenance record (the exact + * entries they published) so the successor's first merge re-renders its own + * stale output while preserving curated entries, and sources * replaced under a different id are dropped from `sources` and marked in * `staleProviderIds` for removal from the provider registry. */ @@ -754,7 +945,13 @@ async function publishConnectedSource( const existing = sources.get(source.id) if (existing && !(existing.fromFallback && !readString(existing.settings.baseURL))) return const previous = existing ?? replacing.find((candidate) => candidate.id === source.id) - if (previous) source.ownedModelIds = new Set(previous.ownedModelIds) + // Same carry as a configured provider replacing a fallback: the exact + // entries the predecessor published let the first merge replace only + // the predecessor's own output and preserve curated entries. + if (previous) { + source.ownedModelIds = new Set(previous.ownedModelIds) + source.supersededModels = previous.supersededModels ?? previous.publishedModels + } for (const previousSource of replacing) { if (previousSource.id === source.id) continue const mapped = sources.get(previousSource.id) @@ -851,11 +1048,23 @@ const definition = Plugin.define({ current.models.values(), source.models, source.ownedModelIds, + source.supersededModels, + source.publishedModels, ) source.ownedModelIds = merged.owned + source.publishedModels = merged.published + // The one-shot replacement snapshot is spent: later merges on this + // source use the steady-state semantics alone. + source.supersededModels = undefined editor.models.set(source.id, merged.models) } else { source.ownedModelIds = new Set(source.models.map((model) => model.id)) + source.publishedModels = new Map( + source.models.map((model) => [model.id, recordEntry(model)]), + ) + // Nothing to replace under a brand-new provider id: any inherited + // snapshot is spent here too, so it never leaks into a later merge. + source.supersededModels = undefined editor.add({ info: { ...Provider.Info.empty(Provider.ID.make(source.id)), diff --git a/src/utils/format-model-name.ts b/src/utils/format-model-name.ts index a9dae20..bc04883 100644 --- a/src/utils/format-model-name.ts +++ b/src/utils/format-model-name.ts @@ -140,7 +140,7 @@ export function categorizeModel(model: LiteLLMModel): 'chat' | 'embedding' | 'im * "litellm/kimi-k2.5" -> "Kimi K2.5" * "litellm/minimax-m2.5" -> "MiniMax M2.5" */ -export function formatModelName(model: LiteLLMModel): string { +export function formatModelName(model: Pick): string { const { id } = model // Drop provider prefix when present, but only the FIRST segment, so @@ -196,3 +196,56 @@ export function formatModelName(model: LiteLLMModel): string { return tokens.join(' ') || id } + +/** + * 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. */ + formatModelNames?: boolean + /** + * Template applied to every discovered model's display name. Unset (or + * `"{name}"`) leaves today's names untouched. Known placeholders: + * `{name}`, `{id}`, `{provider_id}` and `{provider_name}`. + */ + modelNameTemplate?: string + /** Provider id/name used by the template's provider placeholders. */ + providerId?: string + providerName?: string +} + +/** The template that reproduces today's names, so it counts as "unset". */ +export const DEFAULT_MODEL_NAME_TEMPLATE = '{name}' + +const NAME_TEMPLATE_PLACEHOLDER = /\{(name|id|provider_id|provider_name)\}/g + +/** + * Fill a `modelNameTemplate` with one model's values. + * + * Substitution is single-pass, so a value that itself contains a placeholder + * (`{id}` inside a display name, say) is inserted literally rather than + * expanded again. Unknown placeholders stay as written, which makes a typo + * visible in the picker instead of silently dropping the model's name. + */ +export function applyModelNameTemplate( + template: string | undefined, + values: { name: string; id: string; providerId: string; providerName: string }, +): string { + if (template === undefined || template.trim() === '') return values.name + return template.replace(NAME_TEMPLATE_PLACEHOLDER, (placeholder: string, key: string) => { + switch (key) { + case 'name': + return values.name + case 'id': + return values.id + case 'provider_id': + return values.providerId + case 'provider_name': + return values.providerName + default: + return placeholder + } + }) +} diff --git a/src/utils/model-cache.ts b/src/utils/model-cache.ts index c6c48ae..1222ea2 100644 --- a/src/utils/model-cache.ts +++ b/src/utils/model-cache.ts @@ -2,6 +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' /** * Facts about a discovered model's upstream that OpenCode 2 turns into @@ -98,19 +99,23 @@ function canonicalize(value: unknown): string { /** * Cache identity for a provider's model view. `providerId@baseURL` * alone isn't enough: `includeModels`/`excludeModels`, - * `modelCapabilities` and `formatModelNames` are baked into cached - * entries by discovery, so a config change must not reuse the old cache - * — it would keep serving the previous adjusted view until a second - * restart. A fingerprint is appended whenever adjustments exist - * (pattern order ignored); default configs keep the plain key so - * existing caches stay warm across plugin upgrades. + * `modelCapabilities` and the naming choices (`formatModelNames`, + * `modelNameTemplate`) are baked into cached entries by discovery, so a + * config change must not reuse the old cache — it would keep serving the + * previous adjusted view until a second restart. A fingerprint is appended + * whenever adjustments exist (pattern order ignored); default configs keep + * the plain key so existing caches stay warm across plugin upgrades. + * + * `providerName` only matters when a template is set: `{provider_name}` + * resolves to it, so renaming a provider must start a fresh discovery too. + * The provider id is already part of the base key. */ export function buildCacheKey( providerId: string, baseURL: string, filters: { includeModels?: string[]; excludeModels?: string[] }, capabilities: Record>, - naming: { formatModelNames?: boolean } = {}, + naming: ModelNaming = {}, credential?: string, ): string { let base = `${providerId}@${baseURL}` @@ -122,21 +127,31 @@ export function buildCacheKey( base += `@${createHash('sha256').update(credential).digest('hex').slice(0, 12)}` } const rawNames = naming.formatModelNames === false + const template = naming.modelNameTemplate const hasAdjustments = (filters.includeModels?.length ?? 0) > 0 || (filters.excludeModels?.length ?? 0) > 0 || Object.keys(capabilities).length > 0 || - rawNames + rawNames || + template !== undefined if (!hasAdjustments) return base + // Omitted keys stay out of the hash entirely: `undefined` would serialize + // as the literal string "undefined" and change every existing cache key. + const namingFingerprint: Record = {} + // Omitted (not `true`) when formatting is on, so keys for configs that + // never touch this option are unchanged. + if (rawNames) namingFingerprint.formatModelNames = false + if (template !== undefined) { + namingFingerprint.modelNameTemplate = template + namingFingerprint.providerName = naming.providerName + } const fingerprint = createHash('sha256') .update( canonicalize({ includeModels: filters.includeModels ? [...filters.includeModels].sort() : undefined, excludeModels: filters.excludeModels ? [...filters.excludeModels].sort() : undefined, capabilities, - // Omitted (not `true`) when formatting is on, so keys for - // configs that never touch this option are unchanged. - formatModelNames: rawNames ? false : undefined, + ...namingFingerprint, }), ) .digest('hex') diff --git a/test/auth-v1.test.ts b/test/auth-v1.test.ts index ac638e8..7f6e76d 100644 --- a/test/auth-v1.test.ts +++ b/test/auth-v1.test.ts @@ -619,7 +619,7 @@ describe('OpenCode 1 LiteLLM sign-in', () => { ).rejects.toThrow('LiteLLM sign-in expired; connect LiteLLM again') }) - it('gives the Responses sibling the sign-in fetch and a placeholder key its runtime requires', async () => { + it('gives the templated Responses sibling the sign-in fetch and a placeholder key its runtime requires', async () => { writeAuthFile(pkceEntry()) mockFetch((request) => { if (request.url.endsWith('/v1/models')) { @@ -638,11 +638,22 @@ describe('OpenCode 1 LiteLLM sign-in', () => { return undefined }) const hooks = await LiteLLMPlugin(createInput().input) - const config = createConfig({}) as { provider: Record } + const config = createConfig({ + modelNameTemplate: '{name} ({provider_id} / {provider_name})', + }) as { provider: Record } await hooks.config!(config as never) + // The template resolves against the provider the user selects through: + // the unnamed base falls back to its id, the Responses sibling to the + // generated sibling identity. + expect(config.provider['litellm'].models?.['example-chat']).toMatchObject({ + name: 'Example Chat (litellm / litellm)', + }) const sibling = config.provider['litellm-responses'] expect(Object.keys(sibling.models ?? {})).toEqual(['example-responses']) + expect(sibling.models?.['example-responses']).toMatchObject({ + name: 'Example Responses (litellm-responses / litellm Responses)', + }) expect(sibling.options?.baseURL).toBe(`${BASE}/v1`) // A placeholder the runtime package insists on; the fetch replaces it with the real token. const placeholder = sibling.options?.apiKey diff --git a/test/model-cache.test.ts b/test/model-cache.test.ts index 231a743..778bfbc 100644 --- a/test/model-cache.test.ts +++ b/test/model-cache.test.ts @@ -159,6 +159,23 @@ describe('buildCacheKey', () => { expect(keyA).not.toContain('sk-key-a') }) + it('combines the credential fingerprint with the naming fingerprint', () => { + // Rebase of the SSO credential keying (main) onto the modelNameTemplate + // keying (#55): both dimensions isolate the cached model view together — + // a different credential under the same template, or the same credential + // under a different template, each start a fresh discovery. + const template = { modelNameTemplate: '{name} ({provider_name})', providerName: 'Team A' } + const keyA = buildCacheKey('litellm', 'http://localhost:4000', {}, {}, template, 'sk-key-a') + const keyA2 = buildCacheKey('litellm', 'http://localhost:4000', {}, {}, template, 'sk-key-a') + const keyB = buildCacheKey('litellm', 'http://localhost:4000', {}, {}, template, 'sk-key-b') + const keyATemplateless = buildCacheKey('litellm', 'http://localhost:4000', {}, {}, {}, 'sk-key-a') + expect(keyA).toBe(keyA2) + expect(keyA).not.toBe(keyB) + expect(keyA).not.toBe(keyATemplateless) + // The fingerprint never carries the raw key. + expect(keyA).not.toContain('sk-key-a') + }) + it('changes the key when capability overrides are added or changed', () => { // Regression for the CodeRabbit review finding: a model previously // cached with `supports_vision: false` must not be served again @@ -213,6 +230,36 @@ describe('buildCacheKey', () => { expect(rawWithFilters).not.toBe(formattedWithFilters) }) + it('keeps the plain key when no template is set, whatever the provider name', () => { + // Provider id is already part of the base key; the display name only + // matters to `{provider_name}`, so renaming without a template must not + // throw away a warm cache. + expect( + buildCacheKey('litellm', 'http://localhost:4000', {}, {}, { providerName: 'Team A' }), + ).toBe(KEY) + }) + + it('changes the key when a modelNameTemplate is set', () => { + // Templated names are baked into cached entries, so an unset or changed + // template must start a fresh discovery instead of serving stale names. + const templated = buildCacheKey('litellm', 'http://localhost:4000', {}, {}, { + modelNameTemplate: '{name} ({provider_name})', + providerName: 'Team A', + }) + expect(templated).not.toBe(KEY) + expect(templated.startsWith(KEY)).toBe(true) + }) + + it('changes the key when the provider name changes under a template', () => { + const key = (providerName: string) => + buildCacheKey('litellm', 'http://localhost:4000', {}, {}, { + modelNameTemplate: '{name} ({provider_name})', + providerName, + }) + expect(key('Team A')).not.toBe(key('Team B')) + expect(key('Team A')).toBe(key('Team A')) + }) + it('changes the key when includeModels/excludeModels are added', () => { const plain = buildCacheKey('litellm', 'http://localhost:4000', {}, {}) const withFilters = buildCacheKey( diff --git a/test/plugin-v1.test.ts b/test/plugin-v1.test.ts index 30951b9..b910424 100644 --- a/test/plugin-v1.test.ts +++ b/test/plugin-v1.test.ts @@ -112,6 +112,157 @@ describe('OpenCode 1 config hook', () => { return config } + it('applies modelNameTemplate to discovered models and leaves curated names alone', async () => { + useIsolatedState() + const baseURL = 'http://127.0.0.1:45559/v1' + mockProxy([{ id: 'claude-3-5-sonnet' }, { id: 'curated-model' }]) + + 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})', + }, + models: { 'curated-model': { name: 'Hand-picked' } }, + }, + }, + } + await plugin.config!(config as never) + + const models = config.provider['litellm-team-a'].models ?? {} + expect(models['claude-3-5-sonnet']).toMatchObject({ + name: 'Claude 3.5 Sonnet (litellm-team-a)', + }) + // An explicit `models..name` wins and is never templated. + expect(models['curated-model']).toMatchObject({ name: 'Hand-picked' }) + }) + + it('keys the model cache by the template, not by the previous names', async () => { + useIsolatedState() + const baseURL = 'http://127.0.0.1:45560/v1' + mockProxy([{ id: 'claude-3-5-sonnet' }]) + + const plugin = await LiteLLMPlugin({ + client: { app: { log: vi.fn(async () => {}) } }, + } as never) + const config: OpenCodeConfig = { + provider: { + 'litellm-team-b': { + npm: '@ai-sdk/openai-compatible', + name: 'Team B', + options: { + baseURL, + apiKey: 'example-key', + modelNameTemplate: '[B] {name}', + }, + models: {}, + }, + }, + } + await plugin.config!(config as never) + + const templatedKey = buildCacheKey( + 'litellm-team-b', + 'http://127.0.0.1:45560', + {}, + {}, + { modelNameTemplate: '[B] {name}', providerName: 'Team B' }, + 'example-key', + ) + expect(readModelCacheEntry(templatedKey)?.models['claude-3-5-sonnet']).toMatchObject({ + name: '[B] Claude 3.5 Sonnet', + }) + // The untemplated identity stays empty, so a template change cannot + // resurrect the names discovered without it. + const plainKey = buildCacheKey( + 'litellm-team-b', + 'http://127.0.0.1:45560', + {}, + {}, + {}, + 'example-key', + ) + expect(readModelCacheEntry(plainKey)).toBeNull() + }) + + it('resolves provider placeholders against the Responses sibling that owns the model', async () => { + useIsolatedState() + const baseURL = 'http://127.0.0.1:45561/v1' + mockProxy([ + { id: 'chat-model', mode: 'chat' }, + { id: 'responses-model', mode: 'responses' }, + ]) + + 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'].models?.['chat-model']).toMatchObject({ + name: 'Chat Model (litellm-team-a / Team A)', + }) + // Responses models are served by the sibling provider, so the template + // must name the provider the user will actually select. + expect( + config.provider['litellm-team-a-responses'].models?.['responses-model'], + ).toMatchObject({ + name: 'Responses Model (litellm-team-a-responses / Team A Responses)', + }) + }) + + it('labels the Responses sibling from the provider id when the base has no name', async () => { + useIsolatedState() + const baseURL = 'http://127.0.0.1:45562/v1' + mockProxy([{ id: 'responses-model', mode: 'responses' }]) + + const plugin = await LiteLLMPlugin({ + client: { app: { log: vi.fn(async () => {}) } }, + } as never) + const config: OpenCodeConfig = { + provider: { + 'litellm-team-c': { + npm: '@ai-sdk/openai-compatible', + options: { + baseURL, + apiKey: 'example-key', + modelNameTemplate: '{name} ({provider_name})', + }, + models: {}, + }, + }, + } + await plugin.config!(config as never) + + // Unnamed: the picker falls back to ids, so `{provider_name}` does too. + expect(config.provider['litellm-team-c-responses'].name).toBe('litellm-team-c Responses') + expect( + config.provider['litellm-team-c-responses'].models?.['responses-model'], + ).toMatchObject({ + name: 'Responses Model (litellm-team-c Responses)', + }) + }) + 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 f746447..c6dd3a4 100644 --- a/test/plugin-v2.test.ts +++ b/test/plugin-v2.test.ts @@ -901,6 +901,172 @@ describe('OpenCode 2 plugin entrypoint', () => { await cleanup?.() }) + it('applies modelNameTemplate to discovered names without leaking the setting', async () => { + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-template-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + const baseURL = 'http://127.0.0.1:44446' + globalThis.fetch = vi.fn(async (input) => { + const url = String(input) + if (url.endsWith('/v1/model/info')) { + return new Response( + JSON.stringify({ + data: [ + { + model_name: 'anthropic/claude-3-5-sonnet', + model_info: { key: 'anthropic/claude-3-5-sonnet', mode: 'chat' }, + }, + ], + }), + { status: 200 }, + ) + } + return new Response( + JSON.stringify({ data: [{ id: 'anthropic/claude-3-5-sonnet', object: 'model' }] }), + { status: 200 }, + ) + }) + + const configuredProvider = { + id: 'litellm-team-a', + name: 'Team A', + activation: 'enabled', + package: '@opencode/ai/providers/openai-compatible', + settings: { + baseURL: `${baseURL}/v1`, + apiKey: 'example-api-key', + modelNameTemplate: '{name} ({provider_id})', + }, + headers: {}, + } + const curatedModel = { id: 'curated-model', name: 'Hand-picked' } + const configuredRecord = { + provider: configuredProvider, + models: new Map([['curated-model', curatedModel]]), + } + let updatedModels: Array> = [] + const editor = { + list: () => [configuredRecord], + get: () => configuredRecord, + add: vi.fn(), + update: vi.fn((_id, update) => update(configuredProvider)), + remove: vi.fn(), + models: { + set: vi.fn((_id, models: Array>) => { + updatedModels = models + }), + update: vi.fn(), + remove: vi.fn(), + }, + } + const context = { + app: { name: 'OpenCode', version: '2.0.14', channel: 'stable' }, + options: {}, + provider: { + list: vi.fn(async () => ({ data: [configuredProvider] })), + 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) + + const discovered = updatedModels.find( + (model) => model.id === 'anthropic/claude-3-5-sonnet', + ) + expect(discovered?.name).toBe('Claude 3.5 Sonnet (litellm-team-a)') + expect(updatedModels.find((model) => model.id === 'curated-model')?.name).toBe('Hand-picked') + // The host's own settings are preserved so later reconciles can re-derive + // the naming; only plugin-level options are kept out (see the fallback test). + expect(configuredProvider.settings).toMatchObject({ + modelNameTemplate: '{name} ({provider_id})', + }) + + const cacheKey = buildCacheKey( + 'litellm-team-a', + baseURL, + {}, + {}, + { modelNameTemplate: '{name} ({provider_id})', providerName: 'Team A' }, + 'example-api-key', + ) + expect(readModelCacheEntry(cacheKey)?.models['anthropic/claude-3-5-sonnet']).toMatchObject({ + name: 'Claude 3.5 Sonnet (litellm-team-a)', + }) + + await cleanup?.() + }) + + it('falls back to the provider id for {provider_name} on an unnamed provider', async () => { + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-template-fallback-')) + process.env.XDG_CACHE_HOME = cacheDirectory + globalThis.fetch = vi.fn(async (input) => { + const url = String(input) + if (url.endsWith('/v1/model/info')) { + return new Response( + JSON.stringify({ + data: [ + { + model_name: 'company/chat', + model_info: { key: 'company/chat', mode: 'chat' }, + }, + ], + }), + { status: 200 }, + ) + } + return new Response( + JSON.stringify({ data: [{ id: 'company/chat', 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: 'http://127.0.0.1:44447/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) + + // No configured name: the id is more useful than a shared label, and two + // unnamed providers stay distinguishable. + expect(registered[0].models.map((model) => model.name)).toEqual(['Chat (litellm)']) + // A plugin-level option never lands in the host's provider settings. + expect(registered[0].info.settings).not.toHaveProperty('modelNameTemplate') + + 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 @@ -1386,6 +1552,639 @@ describe('OpenCode 2 plugin entrypoint', () => { await cleanup?.() }) + it('re-renders fallback-generated names when a configured provider replaces the fallback', async () => { + // Regression for the CodeRabbit review finding (PR #55): a configured + // provider replacing a published fallback kept the fallback's model entry + // for any id both inventories serve. `providerName` and the model name are + // baked in at discovery time, so the stale entry kept the fallback's + // rendering (template + unnamed-provider label) forever. + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-provenance-replace-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + process.env.LITELLM_BASE_URL = 'http://127.0.0.1:44560/v1' + + // The plugin options drive the fallback's naming. With no configured + // name, `{provider_name}` resolves to the provider id (`litellm`). + const fallbackTemplate = '{name} via {provider_name}' + // Both inventories serve the same model id under different labels. + let inventory = ['company/chat', 'fallback-only-model'] + globalThis.fetch = vi.fn(async (input) => { + const url = String(input) + if (url.endsWith('/v1/model/info')) { + return new Response(JSON.stringify({ data: [] }), { status: 200 }) + } + return new Response( + JSON.stringify({ data: inventory.map((id) => ({ id, object: 'model' })) }), + { status: 200 }, + ) + }) + + // The host registers the configured provider only after setup; the + // post-subscribe reconcile pass must not see it either, so the swap + // happens deterministically on the `provider.updated` event. + let providerListCalls = 0 + const configuredProvider = { + id: 'litellm', + name: 'Team A Gateway', + activation: 'enabled', + package: '@opencode/ai/providers/openai-compatible', + settings: { + baseURL: 'http://127.0.0.1:44561/v1', + modelNameTemplate: fallbackTemplate, + }, + headers: {}, + } + const providerList = vi.fn(async () => { + providerListCalls += 1 + return { data: providerListCalls <= 2 ? [] : [configuredProvider] } + }) + + // The registry holds whatever the last transform published. The first + // transform (setup) publishes the fallback via `add`; after + // `provider.updated` the transform runs again and now sees the fallback's + // published entry as the configured provider's current models — exactly + // the host state at the moment the configured provider takes over. + let registryModels = new Map>() + const record = () => + registryModels.size > 0 ? { provider: configuredProvider, models: registryModels } : undefined + const editor = { + list: () => (registryModels.size > 0 ? [record()] : []), + get: () => record(), + add: vi.fn( + (entry: { info: Record; models: Array> }) => { + registryModels = new Map( + entry.models.map((model) => [String(model.id), model as Record]), + ) + }, + ), + update: vi.fn(), + remove: vi.fn(), + models: { + set: vi.fn((_id: string, models: Array>) => { + registryModels = new Map( + models.map((model) => [String(model.id), model as Record]), + ) + }), + update: vi.fn(), + remove: vi.fn(), + }, + } + let providerTransform: ((editor: unknown) => void) | undefined + const reload = vi.fn(async () => { + providerTransform?.(editor) + }) + let releaseProviderUpdated!: () => void + let releaseSessionCreated!: () => void + const providerUpdatedGate = new Promise((resolve) => { + releaseProviderUpdated = resolve + }) + const sessionCreatedGate = new Promise((resolve) => { + releaseSessionCreated = resolve + }) + const context = { + app: { name: 'OpenCode', version: '2.0.19', channel: 'stable' }, + options: { modelNameTemplate: fallbackTemplate }, + provider: { + list: providerList, + transform: vi.fn(async (transform: (editor: unknown) => void) => { + providerTransform = transform + transform(editor) + return { dispose: vi.fn(async () => {}) } + }), + reload, + }, + event: { + subscribe: () => + (async function* () { + await providerUpdatedGate + yield { type: 'provider.updated' } + await sessionCreatedGate + yield { type: 'session.created' } + })(), + }, + } as unknown as Context + + const cleanup = await plugin.setup(context) + + // The fallback published two models rendered with the fallback's naming: + // no configured provider name, so `{provider_name}` resolved to 'litellm'. + expect([...registryModels.keys()]).toEqual(['company/chat', 'fallback-only-model']) + expect((registryModels.get('company/chat') as Record).name).toBe( + 'Chat via litellm', + ) + + // The configured provider's inventory no longer serves the fallback-only + // model — the replacement must be able to drop it too. + inventory = ['company/chat', 'configured-only-model'] + releaseProviderUpdated() + await vi.waitFor(() => expect(reload).toHaveBeenCalledOnce()) + + // The configured provider must take the shared id over and re-render it + // with its own name ('Team A Gateway'), not keep the fallback's stale + // entry; the dropped id must leave with it; the new id must arrive. + const byId = Object.fromEntries([...registryModels.values()].map((m) => [String(m.id), m])) + expect(Object.keys(byId).sort()).toEqual(['company/chat', 'configured-only-model']) + expect((byId['company/chat'] as Record).name).toBe('Chat via Team A Gateway') + expect((byId['configured-only-model'] as Record).name).toBe( + 'Configured Only Model via Team A Gateway', + ) + + // Ownership follow-through: the takeover must have re-owned the shared + // id, so a later refresh that no longer discovers it can drop it — + // impossible before the fix, which lost ownership on the collision. + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 10 * 60 * 1000) + inventory = ['configured-only-model'] + releaseSessionCreated() + await vi.waitFor(() => + expect([...registryModels.keys()]).toEqual(['configured-only-model']), + ) + dateNow.mockRestore() + await cleanup?.() + }) + + it('preserves curated models when a configured provider replaces the fallback', async () => { + // The other half of the CodeRabbit finding: the user may have curated a + // model under the same id the fallback generated. Simply replacing every + // "owned" match would clobber it, while provenance snapshots keep the + // user's content and release the id so a later refresh cannot remove it. + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-provenance-curated-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + process.env.LITELLM_BASE_URL = 'http://127.0.0.1:44562/v1' + + const fallbackTemplate = '{name} via {provider_name}' + let inventory = ['company/chat', 'fallback-only-model'] + globalThis.fetch = vi.fn(async (input) => { + const url = String(input) + if (url.endsWith('/v1/model/info')) { + return new Response(JSON.stringify({ data: [] }), { status: 200 }) + } + return new Response( + JSON.stringify({ data: inventory.map((id) => ({ id, object: 'model' })) }), + { status: 200 }, + ) + }) + + let providerListCalls = 0 + const configuredProvider = { + id: 'litellm', + name: 'Team A Gateway', + activation: 'enabled', + package: '@opencode/ai/providers/openai-compatible', + settings: { + baseURL: 'http://127.0.0.1:44563/v1', + modelNameTemplate: fallbackTemplate, + }, + headers: {}, + } + const providerList = vi.fn(async () => { + providerListCalls += 1 + return { data: providerListCalls <= 2 ? [] : [configuredProvider] } + }) + + // Between the fallback's publication and the configured provider's + // arrival, the user curates 'company/chat' — the host serves it under the + // same id with hand-written content the fallback never generated. + const curatedEntry = { id: 'company/chat', name: 'Hand-Picked Chat', enabled: true } + let registryModels = new Map>() + const record = () => + registryModels.size > 0 ? { provider: configuredProvider, models: registryModels } : undefined + const editor = { + list: () => (registryModels.size > 0 ? [record()] : []), + get: () => record(), + add: vi.fn( + (entry: { info: Record; models: Array> }) => { + registryModels = new Map( + entry.models.map((model) => [String(model.id), model as Record]), + ) + }, + ), + update: vi.fn(), + remove: vi.fn(), + models: { + set: vi.fn((_id: string, models: Array>) => { + registryModels = new Map( + models.map((model) => [String(model.id), model as Record]), + ) + }), + update: vi.fn(), + remove: vi.fn(), + }, + } + let providerTransform: ((editor: unknown) => void) | undefined + const reload = vi.fn(async () => { + providerTransform?.(editor) + }) + let releaseProviderUpdated!: () => void + let releaseSessionCreated!: () => void + const providerUpdatedGate = new Promise((resolve) => { + releaseProviderUpdated = resolve + }) + const sessionCreatedGate = new Promise((resolve) => { + releaseSessionCreated = resolve + }) + const context = { + app: { name: 'OpenCode', version: '2.0.19', channel: 'stable' }, + options: { modelNameTemplate: fallbackTemplate }, + provider: { + list: providerList, + transform: vi.fn(async (transform: (editor: unknown) => void) => { + providerTransform = transform + transform(editor) + return { dispose: vi.fn(async () => {}) } + }), + reload, + }, + event: { + subscribe: () => + (async function* () { + await providerUpdatedGate + yield { type: 'provider.updated' } + await sessionCreatedGate + yield { type: 'session.created' } + })(), + }, + } as unknown as Context + + const cleanup = await plugin.setup(context) + expect([...registryModels.keys()]).toEqual(['company/chat', 'fallback-only-model']) + + // The user curates the shared id (and keeps the fallback-only model as + // published — only 'company/chat' is touched). + registryModels.set('company/chat', curatedEntry as Record) + + inventory = ['company/chat'] + releaseProviderUpdated() + await vi.waitFor(() => expect(reload).toHaveBeenCalledOnce()) + + // The curated entry survives the takeover unchanged (same object). + expect(registryModels.get('company/chat')).toBe(curatedEntry) + // …and the plugin's own untouched fallback output is dropped (no longer + // served by the configured gateway) rather than lingering forever. + expect([...registryModels.keys()]).toEqual(['company/chat']) + + // The takeover must also have RELEASED the curated id: a later refresh + // whose inventory omits it must never remove the user's entry. + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 10 * 60 * 1000) + inventory = ['another-model'] + releaseSessionCreated() + await vi.waitFor(() => expect([...registryModels.keys()]).toContain('another-model')) + expect(registryModels.get('company/chat')).toBe(curatedEntry) + dateNow.mockRestore() + await cleanup?.() + }) + + it('keeps provenance for entries kept through a changed-content refresh', async () => { + // Lifecycle regression (follow-up to the CodeRabbit finding): a refresh + // whose model info changed keeps the old owned live entry (steady-state + // merge semantics), so the provenance record must snapshot the LAST + // PUBLISHED entry, not compare against the fresh discovery. Comparing + // against fresh discovery dropped provenance for exactly the entries the + // merge kept, and a later replacement of the provider could then neither + // refresh nor drop the plugin's own stale entry. + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-provenance-refresh-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + process.env.LITELLM_BASE_URL = 'http://127.0.0.1:44564/v1' + + const template = '{name} via {provider_name}' + let contextLimit = 8192 + let inventory = ['company/chat'] + globalThis.fetch = vi.fn(async (input) => { + const url = String(input) + if (url.endsWith('/v1/model/info')) { + return new Response( + JSON.stringify({ + data: [ + { + model_name: 'company/chat', + model_info: { + key: 'company/chat', + mode: 'chat', + max_input_tokens: contextLimit, + }, + }, + ], + }), + { status: 200 }, + ) + } + return new Response( + JSON.stringify({ data: inventory.map((id) => ({ id, object: 'model' })) }), + { status: 200 }, + ) + }) + + // The host registers the configured provider only after setup, so the + // initial source is the env/option fallback (id 'litellm'). + let providerRegistered = false + const configuredProvider = { + id: 'litellm', + name: 'Team A Gateway', + activation: 'enabled', + package: '@opencode/ai/providers/openai-compatible', + settings: { + baseURL: 'http://127.0.0.1:44565/v1', + modelNameTemplate: template, + }, + headers: {}, + } + const providerList = vi.fn(async () => ({ + data: providerRegistered ? [configuredProvider] : [], + })) + + let registryModels = new Map>() + const record = () => + registryModels.size > 0 ? { provider: configuredProvider, models: registryModels } : undefined + const editor = { + list: () => (registryModels.size > 0 ? [record()] : []), + get: () => record(), + add: vi.fn( + (entry: { info: Record; models: Array> }) => { + registryModels = new Map( + entry.models.map((model) => [String(model.id), model as Record]), + ) + }, + ), + update: vi.fn(), + remove: vi.fn(), + models: { + set: vi.fn((_id: string, models: Array>) => { + registryModels = new Map( + models.map((model) => [String(model.id), model as Record]), + ) + }), + update: vi.fn(), + remove: vi.fn(), + }, + } + let providerTransform: ((editor: unknown) => void) | undefined + const reload = vi.fn(async () => { + providerTransform?.(editor) + }) + let releaseSessionCreated!: () => void + let releaseProviderUpdated!: () => void + const sessionCreatedGate = new Promise((resolve) => { + releaseSessionCreated = resolve + }) + const providerUpdatedGate = new Promise((resolve) => { + releaseProviderUpdated = resolve + }) + const context = { + app: { name: 'OpenCode', version: '2.0.19', channel: 'stable' }, + options: { modelNameTemplate: template }, + provider: { + list: providerList, + transform: vi.fn(async (transform: (editor: unknown) => void) => { + providerTransform = transform + transform(editor) + return { dispose: vi.fn(async () => {}) } + }), + reload, + }, + event: { + subscribe: () => + (async function* () { + await sessionCreatedGate + yield { type: 'session.created' } + await providerUpdatedGate + yield { type: 'provider.updated' } + })(), + }, + } as unknown as Context + + const cleanup = await plugin.setup(context) + + // Phase 1: the fallback published the model with limit 8192 and the + // fallback's unnamed-provider label ('litellm'). + expect([...registryModels.keys()]).toEqual(['company/chat']) + expect(registryModels.get('company/chat')).toMatchObject({ + name: 'Chat via litellm', + limit: { context: 8192 }, + }) + + // Phase 2: a refresh with CHANGED model info. The merge keeps the old + // live entry (steady-state semantics - a kept owned id retains its live + // content); the assertion is that it stays kept, and that provenance for + // it survives, which phase 3 proves. + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 10 * 60 * 1000) + contextLimit = 65536 + releaseSessionCreated() + // The refresh has run once the cache has been re-saved with the new + // info (discovery always re-persists, kept entries or not). + const refreshedCacheKey = buildCacheKey('litellm', 'http://127.0.0.1:44564', {}, {}, { modelNameTemplate: template, providerName: 'litellm' }) + const savedBefore = readModelCacheSavedAt(refreshedCacheKey) + await vi.waitFor(() => { + const savedAfter = readModelCacheSavedAt(refreshedCacheKey) + expect(savedAfter).not.toBe(savedBefore) + }) + // Kept entry: still the old content. + expect(registryModels.get('company/chat')).toMatchObject({ + name: 'Chat via litellm', + limit: { context: 8192 }, + }) + dateNow.mockRestore() + + // Phase 3: the configured provider replaces the fallback. Provenance + // must have survived phase 2: the takeover re-renders the entry with the + // configured provider's label and fresh info. + providerRegistered = true + releaseProviderUpdated() + await vi.waitFor(() => { + expect(registryModels.get('company/chat')).toMatchObject({ + name: 'Chat via Team A Gateway', + limit: { context: 65536 }, + }) + }) + await cleanup?.() + }) + + + it('never deletes user content for owned ids without provenance evidence', async () => { + // Lifecycle regression (follow-up to the CodeRabbit finding): owned ids + // can outlive their provenance snapshot (a changed-content refresh drops + // the id from a record that compares against fresh discovery, and the + // dialog replacement path carries owned ids along). An undiscovered + // owned id used to be deleted unconditionally, clobbering a user-curated + // entry under it; the deletion must require a snapshot match instead, + // keeping the entry and releasing the id. + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-provenance-evidence-test-')) + process.env.XDG_CACHE_HOME = cacheDirectory + const baseURL = 'http://127.0.0.1:44567' + process.env.LITELLM_BASE_URL = `${baseURL}/v1` + + let contextLimit = 8192 + let inventory = ['shared-id', 'discovered-model'] + globalThis.fetch = vi.fn(async (input) => { + const url = String(input) + if (url.endsWith('/v1/model/info')) { + return new Response( + JSON.stringify({ + data: [ + { + model_name: 'shared-id', + model_info: { key: 'shared-id', mode: 'chat', max_input_tokens: contextLimit }, + }, + ], + }), + { status: 200 }, + ) + } + return new Response( + JSON.stringify({ data: inventory.map((id) => ({ id, object: 'model' })) }), + { status: 200 }, + ) + }) + + // The initial source is the keyless env fallback (id 'litellm'); the + // credential saved later rebuilds it through the dialog path. + const configuredProvider = { + id: 'litellm', + name: 'Configured LiteLLM', + activation: 'enabled', + package: '@opencode/ai/providers/openai-compatible', + settings: {}, + headers: {}, + } + let registryModels = new Map>() + const record = () => + registryModels.size > 0 ? { provider: configuredProvider, models: registryModels } : undefined + const editor = { + list: () => (registryModels.size > 0 ? [record()] : []), + get: () => record(), + add: vi.fn( + (entry: { info: Record; models: Array> }) => { + registryModels = new Map( + entry.models.map((model) => [String(model.id), model as Record]), + ) + }, + ), + update: vi.fn(), + remove: vi.fn(), + models: { + set: vi.fn((_id: string, models: Array>) => { + registryModels = new Map( + models.map((model) => [String(model.id), model as Record]), + ) + }), + update: vi.fn(), + remove: vi.fn(), + }, + } + let providerTransform: ((editor: unknown) => void) | undefined + const reload = vi.fn(async () => { + providerTransform?.(editor) + }) + let releaseSession!: () => void + let releaseSwitch!: () => void + let releaseSecondSession!: () => void + const sessionGate = new Promise((resolve) => { + releaseSession = resolve + }) + const switchGate = new Promise((resolve) => { + releaseSwitch = resolve + }) + const secondSessionGate = new Promise((resolve) => { + releaseSecondSession = resolve + }) + const context = { + app: { name: 'OpenCode', version: '2.0.19', channel: 'stable' }, + options: {}, + provider: { + list: vi.fn(async () => ({ data: [] })), + transform: vi.fn(async (transform: (editor: unknown) => void) => { + providerTransform = transform + transform(editor) + return { dispose: vi.fn(async () => {}) } + }), + reload, + }, + integration: { + connection: { + // No credential at setup; the user saves one later. + active: vi + .fn() + .mockResolvedValueOnce(undefined) + .mockImplementation(async () => ({ key: 'switched-key' })), + resolve: vi.fn(async (connection: { key: string }) => ({ + type: 'key', + key: connection.key, + })), + }, + }, + event: { + subscribe: () => + (async function* () { + await sessionGate + yield { type: 'session.created' } + await switchGate + yield { + type: 'credential.switched', + data: { integrationID: 'litellm' }, + } + await secondSessionGate + yield { type: 'session.created' } + })(), + }, + } as unknown as Context + + const cleanup = await plugin.setup(context) + expect([...registryModels.keys()].sort()).toEqual(['discovered-model', 'shared-id']) + + // Phase 1: a refresh with changed model info. The merge keeps the old + // live entry; a provenance record comparing against fresh discovery + // would drop the id here (the kept entry no longer matches discovery). + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 10 * 60 * 1000) + contextLimit = 65536 + releaseSession() + // The refresh has run once the fallback's cache was re-saved. + const fallbackCacheKey = buildCacheKey('litellm', baseURL, {}, {}) + const savedBefore = readModelCacheSavedAt(fallbackCacheKey) + await vi.waitFor(() => { + expect(readModelCacheSavedAt(fallbackCacheKey)).not.toBe(savedBefore) + }) + dateNow.mockRestore() + + // Phase 2: the user curates 'shared-id' (an id the plugin owns), and the + // gateway's new inventory (served under the saved credential) omits + // both previously discovered ids. + const curatedEntry = { id: 'shared-id', name: 'Hand-Picked', enabled: true } + registryModels.set('shared-id', curatedEntry as Record) + inventory = ['unrelated-model'] + + // Phase 3: the first saved credential rebuilds the source through the + // dialog path. Owned ids carry over; the takeover must keep the curated + // entry (its live content matches no published snapshot) while dropping + // the plugin's own untouched 'discovered-model' output. + releaseSwitch() + const registeredLog = vi.spyOn(console, 'log') + await vi.waitFor(() => + expect(registeredLog.mock.calls.some((call) => String(call[0]).includes('Registered provider "litellm" from the connected LiteLLM credential'))).toBe(true), + ) + // The curated entry survives the rebuild - same object, not re-rendered. + expect(registryModels.get('shared-id')).toBe(curatedEntry) + expect([...registryModels.keys()].sort()).toEqual(['shared-id', 'unrelated-model']) + + // Phase 4: the rebuild must also have RELEASED the curated id: a later + // refresh whose inventory omits it cannot remove the user's entry. Age + // the credential's cache so the refresh actually re-discovers. + const switchedCacheKey = buildCacheKey('litellm', baseURL, {}, {}, {}, 'switched-key') + const dateNow2 = vi.spyOn(Date, 'now').mockReturnValue(Date.now() - 10 * 60 * 1000) + writeModelCache(switchedCacheKey, { 'unrelated-model': { name: 'Unrelated Model' } }) + dateNow2.mockRestore() + inventory = ['unrelated-model', 'another-model'] + // The session refresh re-runs the merge with the released id. + releaseSecondSession() + await vi.waitFor(() => expect([...registryModels.keys()]).toContain('another-model')) + expect(registryModels.get('shared-id')).toBe(curatedEntry) + expect([...registryModels.keys()].sort()).toEqual([ + 'another-model', + 'shared-id', + 'unrelated-model', + ]) + await cleanup?.() + }) + + + + it('retries publishing a newly configured provider after a failed reload', async () => { cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-late-provider-retry-test-')) process.env.XDG_CACHE_HOME = cacheDirectory diff --git a/test/to-config-model.test.ts b/test/to-config-model.test.ts index cf7980e..938ca81 100644 --- a/test/to-config-model.test.ts +++ b/test/to-config-model.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { toConfigModel } from '../src/plugin/index' +import { readModelNameTemplate, toConfigModel } from '../src/plugin/index' import type { LiteLLMModel } from '../src/types' function model(id: string, extra: Partial = {}): LiteLLMModel { @@ -11,18 +11,18 @@ describe('toConfigModel naming (formatModelNames)', () => { it('prettifies the id by default', () => { expect(toConfigModel(chat)?.name).toBe('Claude 3.5 Sonnet') - expect(toConfigModel(chat, undefined, true)?.name).toBe('Claude 3.5 Sonnet') + expect(toConfigModel(chat, undefined, { formatModelNames: true })?.name).toBe('Claude 3.5 Sonnet') }) it('keeps the raw /v1/models id when formatting is disabled', () => { - expect(toConfigModel(chat, undefined, false)?.name).toBe( + expect(toConfigModel(chat, undefined, { formatModelNames: false })?.name).toBe( 'anthropic/claude-3-5-sonnet', ) }) it('preserves an explicit false tool-calling capability', () => { expect( - toConfigModel(chat, undefined, true)?.tool_call, + toConfigModel(chat, undefined, { formatModelNames: true })?.tool_call, ).toBeUndefined() expect( toConfigModel(model('text-only', { supports_function_calling: false }))?.tool_call, @@ -44,7 +44,7 @@ describe('toConfigModel naming (formatModelNames)', () => { it('leaves provider prefixes and version suffixes intact when raw', () => { const versioned = model('claude-opus-4-5@20251101') - expect(toConfigModel(versioned, undefined, false)?.name).toBe( + expect(toConfigModel(versioned, undefined, { formatModelNames: false })?.name).toBe( 'claude-opus-4-5@20251101', ) // ...while the formatted view would collapse them. @@ -53,8 +53,83 @@ describe('toConfigModel naming (formatModelNames)', () => { it('still hides non-chat models regardless of the naming choice', () => { const embedding = model('text-embedding-3-large', { mode: 'embedding' }) - expect(toConfigModel(embedding, undefined, false)).toBeNull() - expect(toConfigModel(embedding, undefined, true)).toBeNull() + expect(toConfigModel(embedding, undefined, { formatModelNames: false })).toBeNull() + expect(toConfigModel(embedding, undefined, { formatModelNames: true })).toBeNull() + }) +}) + +describe('toConfigModel naming (modelNameTemplate)', () => { + const chat = model('anthropic/claude-3-5-sonnet') + const naming = { providerId: 'litellm-team-a', providerName: 'Team A' } + + it('fills every known placeholder', () => { + expect( + toConfigModel(chat, undefined, { + ...naming, + modelNameTemplate: '{provider_name} | {provider_id} | {name} | {id}', + })?.name, + ).toBe('Team A | litellm-team-a | Claude 3.5 Sonnet | anthropic/claude-3-5-sonnet') + }) + + it('applies the template to the raw id when formatting is disabled', () => { + expect( + toConfigModel(chat, undefined, { + ...naming, + formatModelNames: false, + modelNameTemplate: '[{provider_name}] {name}', + })?.name, + ).toBe('[Team A] anthropic/claude-3-5-sonnet') + }) + + it('leaves unknown placeholders as written', () => { + expect( + toConfigModel(chat, undefined, { + ...naming, + modelNameTemplate: '{name} ({team})', + })?.name, + ).toBe('Claude 3.5 Sonnet ({team})') + }) + + it('does not expand placeholders inside a resolved value', () => { + // Single-pass substitution: the `{id}` produced by `{name}` stays literal. + expect( + toConfigModel(model('literal-{id}'), undefined, { + ...naming, + modelNameTemplate: '{name}/{id}', + })?.name, + ).toBe('Literal {id}/literal-{id}') + }) + + it('falls back to the provider id when no display name is configured', () => { + expect( + toConfigModel(chat, undefined, { + providerId: 'litellm-team-b', + modelNameTemplate: '{name} ({provider_name})', + })?.name, + ).toBe('Claude 3.5 Sonnet (litellm-team-b)') + }) + + it('ignores an empty template', () => { + expect(toConfigModel(chat, undefined, { modelNameTemplate: ' ' })?.name).toBe( + 'Claude 3.5 Sonnet', + ) + }) +}) + +describe('readModelNameTemplate', () => { + it('treats the {name} default and empty strings as unset', () => { + // Default configs must keep the cache key they had before this option. + expect(readModelNameTemplate({ modelNameTemplate: '{name}' })).toBeUndefined() + expect(readModelNameTemplate({ modelNameTemplate: ' {name} ' })).toBeUndefined() + expect(readModelNameTemplate({ modelNameTemplate: '' })).toBeUndefined() + }) + + it('ignores non-strings and keeps the template as written', () => { + expect(readModelNameTemplate({ modelNameTemplate: 42 })).toBeUndefined() + expect(readModelNameTemplate({})).toBeUndefined() + expect(readModelNameTemplate({ modelNameTemplate: ' {name} ({provider_name}) ' })).toBe( + ' {name} ({provider_name}) ', + ) }) })