diff --git a/CHANGELOG.md b/CHANGELOG.md index 91da1eb..91f4389 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Features + +* **plugin:** surface reasoning-effort variants from `/v1/model/info` reported by LiteLLM + ### Fixed - **Embedding / image / audio models no longer appear in the OpenCode model picker.** The non-chat filter in `toConfigModel()` was a dead diff --git a/README.md b/README.md index 378b3fe..d052e9c 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,13 @@ detects reasoning-tier models from their id (`gpt-5*`, `o1*`, `o3*`, `o4*`) and from LiteLLM's `mode === 'responses'` field, and creates the sibling provider lazily. +If LiteLLM reports per-model reasoning-effort support (e.g. +`supports_low_reasoning_effort`, `supports_medium_reasoning_effort`, +`supports_high_reasoning_effort` in `model_info`), the plugin automatically +surfaces those as OpenCode variants under the discovered model. Each variant +sets `reasoningEffort` to the reported level, so you can switch between +effort levels from the model picker without hand-curating every entry. + To override the routing per model: ```jsonc diff --git a/src/plugin/build-model.ts b/src/plugin/build-model.ts index e8a270f..f0c8fd1 100644 --- a/src/plugin/build-model.ts +++ b/src/plugin/build-model.ts @@ -1,5 +1,5 @@ import type { Model as ModelV2 } from '@opencode-ai/sdk/v2' -import type { LiteLLMModel } from '../types' +import type { LiteLLMModel, LiteLLMModelInfo } from '../types' import { categorizeModel, formatModelName, @@ -20,6 +20,7 @@ export function buildModelV2( providerID: string, api: { id: string; url: string; npm: string }, model: LiteLLMModel, + info?: LiteLLMModelInfo, ): ModelV2 { const type = categorizeModel(model) const isVision = !!model.supports_vision @@ -66,5 +67,13 @@ export function buildModelV2( options: {}, headers: {}, release_date: '', + variants: info?.supports_reasoning_efforts?.length + ? Object.fromEntries( + info.supports_reasoning_efforts.map((effort) => [ + effort, + { reasoningEffort: effort }, + ]), + ) + : undefined, } } diff --git a/src/plugin/discover.ts b/src/plugin/discover.ts index b67cc5f..4978902 100644 --- a/src/plugin/discover.ts +++ b/src/plugin/discover.ts @@ -2,11 +2,12 @@ import type { Model as ModelV2, Provider as ProviderV2 } from '@opencode-ai/sdk/ import { autoDetectLiteLLM, checkLiteLLMHealth, + discoverLiteLLMModelInfo, discoverLiteLLMModels, normalizeBaseURL, } from '../utils/litellm-api' import { requiresResponsesAPI } from '../utils/format-model-name' -import type { LiteLLMModel, Transport, TransportPolicy } from '../types' +import type { LiteLLMModel, LiteLLMModelInfo, Transport, TransportPolicy } from '../types' import { buildModelV2 } from './build-model' const DISCOVERY_TIMEOUT_MS = 5000 @@ -138,16 +139,29 @@ export async function discoverBucket( return } - let models: LiteLLMModel[] - try { - models = await discoverLiteLLMModels(baseURL, apiKey, customHeaders) - } catch (error) { + const [modelsResult, infoResult] = await Promise.allSettled([ + discoverLiteLLMModels(baseURL, apiKey, customHeaders), + discoverLiteLLMModelInfo(baseURL, apiKey, customHeaders), + ]) + + if (modelsResult.status === 'rejected') { console.warn( '[opencode-litellm] Model discovery failed:', - error instanceof Error ? error.message : String(error), + modelsResult.reason instanceof Error ? modelsResult.reason.message : String(modelsResult.reason), ) return } + const models = modelsResult.value + + let infoByName: Map | null = null + if (infoResult.status === 'fulfilled') { + infoByName = infoResult.value + } else { + console.warn( + '[opencode-litellm] /v1/model/info unavailable; model costs will be zero:', + infoResult.reason instanceof Error ? infoResult.reason.message : String(infoResult.reason), + ) + } if (models.length === 0) { console.warn( @@ -176,7 +190,8 @@ export async function discoverBucket( // it with "team not allowed"). Set `api.id` per-model so each // entry carries the correct upstream model name. const perModelApi = { ...resolvedApi, id: model.id } - out[model.id] = buildModelV2(resolvedApi.id, perModelApi, model) + const info = infoByName?.get(model.id) ?? {} + out[model.id] = buildModelV2(resolvedApi.id, perModelApi, model, info) } } diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 1f94960..4464936 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -87,7 +87,10 @@ function enrichModel(model: LiteLLMModel, info: LiteLLMModelInfo): LiteLLMModel * image, audio) — they can't be used as primary chat models and would * clutter the picker. */ -function toConfigModel(model: LiteLLMModel): Record | null { +function toConfigModel( + model: LiteLLMModel, + info?: LiteLLMModelInfo, +): Record | null { const type = categorizeModel(model) if (type === 'embedding' || type === 'image' || type === 'audio') { return null @@ -117,6 +120,14 @@ function toConfigModel(model: LiteLLMModel): Record | null { if (input.length > 1) { entry.modalities = { input, output: ['text'] } } + entry.variants = info?.supports_reasoning_efforts?.length + ? Object.fromEntries( + info.supports_reasoning_efforts.map((effort) => [ + effort, + { reasoningEffort: effort }, + ]), + ) + : undefined return entry } @@ -303,7 +314,10 @@ export const LiteLLMPlugin: Plugin = async (_input: PluginInput) => { if (models[model.id]) continue const info = infoByName?.get(model.id) if (infoByName && !info) unmatched.push(model.id) - const entry = toConfigModel(info ? enrichModel(model, info) : model) + const entry = toConfigModel( + info ? enrichModel(model, info) : model, + info, + ) if (!entry) { skipped++ continue diff --git a/src/types/index.ts b/src/types/index.ts index 26ec7c7..bed4115 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -56,6 +56,7 @@ export interface LiteLLMModelInfo { supports_function_calling?: boolean supports_vision?: boolean supports_reasoning?: boolean + supports_reasoning_efforts?: string[] supports_pdf_input?: boolean supports_audio_input?: boolean } diff --git a/src/utils/litellm-api.ts b/src/utils/litellm-api.ts index 3b46b0c..8857145 100644 --- a/src/utils/litellm-api.ts +++ b/src/utils/litellm-api.ts @@ -125,6 +125,25 @@ export async function discoverLiteLLMModelInfo( info[flag] = paramsValue } } + + // Add supports reasoning efforts if present in litellm_params + const reasoningEffortPattern = /^supports_([a-z]+)_reasoning_effort$/ + const efforts = new Set() + for (const source of [info, entry.litellm_params]) { + if (!source || typeof source !== 'object') continue + for (const [key, value] of Object.entries(source)) { + const match = key.match(reasoningEffortPattern) + if (match && value === true) { + efforts.add(match[1]) + } + } + } + if (efforts.size > 0) { + info.supports_reasoning_efforts = [ + ...new Set([...(info.supports_reasoning_efforts ?? []), ...efforts]), + ] + } + // Index under every alias LiteLLM may use for this model — the // `/v1/models` id can match any of them depending on how the // deployment names its entries (alias vs upstream model string).