Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion src/plugin/build-model.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
}
}
29 changes: 22 additions & 7 deletions src/plugin/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, LiteLLMModelInfo> | 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(
Expand Down Expand Up @@ -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)
Comment thread
JCHacking marked this conversation as resolved.
}
}

Expand Down
18 changes: 16 additions & 2 deletions src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | null {
function toConfigModel(
model: LiteLLMModel,
info?: LiteLLMModelInfo,
): Record<string, unknown> | null {
const type = categorizeModel(model)
if (type === 'embedding' || type === 'image' || type === 'audio') {
return null
Expand Down Expand Up @@ -117,6 +120,14 @@ function toConfigModel(model: LiteLLMModel): Record<string, unknown> | 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
}

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
19 changes: 19 additions & 0 deletions src/utils/litellm-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
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).
Expand Down
Loading