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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ also shows how to configure a provider directly in `opencode.json`.
| 📡 **Dynamic discovery** | Queries `/v1/models` so your OpenCode model picker always reflects your live `model_list`. |
| ⚡ **Stale-while-revalidate** | A warm disk cache supplies models without a network request. New sessions trigger a background refresh, and cache entries expire after 7 days. |
| 🏷️ **Smart formatting** | Turns `anthropic/claude-3-5-sonnet` into `Claude 3.5 Sonnet` in the picker — handles versions, sizes, quantizations, and brand-cased names like `gpt-4o`. Set `formatModelNames: false` to keep the raw LiteLLM ids instead. |
| 🧠 **Modality-aware** | Enriches `/v1/models` entries with `/v1/model/info` (`mode`, token limits, capability flags) and hides embedding / image / audio models from the picker. |
| 🧠 **Modality-aware** | Enriches `/v1/models` entries with `/v1/model/info` (`mode`, token limits, capability flags) and hides embedding / image / audio models from the picker. Declares `temperature` support only for models whose `supported_openai_params` lists it, so OpenCode applies (and the user can set) a sampling temperature on exactly those models. |
| 💵 **Real pricing** | Maps `input_cost_per_token` / `output_cost_per_token` (and cache read/write costs) from `/v1/model/info` into OpenCode's `cost` field, so the picker and `/cost` show what the proxy actually bills instead of `$0.00`. Models LiteLLM has no price for are left unpriced, not falsely marked free. |
| 🧩 **Reasoning-effort variants** | When LiteLLM reports per-model effort support (`supports_low_reasoning_effort`, …), the plugin surfaces each level as a picker variant automatically. |
| 🧭 **Responses-API routing** | Models LiteLLM reports with `mode: "responses"` are invoked through `/v1/responses` instead of `/v1/chat/completions`, with no extra configuration. OpenCode 2 overrides the runtime package per model; OpenCode 1 gets a `<provider>-responses` sibling. |
Expand Down
11 changes: 11 additions & 0 deletions src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,17 @@ export function toConfigModel(
if (model.supports_vision) {
entry.attachment = true
}
// Declare `temperature` only when `/v1/model/info` lists it among the model's
// `supported_openai_params`. OpenCode sends a sampling temperature (its own
// per-model default, or one set on the agent) only for models flagged as
// supporting it, so a model that accepts the parameter would otherwise
// silently ignore the user's setting. When the parameter is not listed, or
// `/v1/model/info` is unavailable, the field stays unset and OpenCode keeps
// its default behavior (some deployments, such as reasoning-only endpoints or
// guardrail classifiers, reject the parameter outright).
if (info?.supported_openai_params?.includes('temperature')) {
entry.temperature = true
}
// Only emit `cost` when LiteLLM actually reported a price. Omitting
// it (rather than defaulting to 0) lets OpenCode/models.dev fall back
// to their own default instead of us asserting "this model is free"
Expand Down
2 changes: 2 additions & 0 deletions src/plugin/v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,8 @@ function toProviderModels(
tools: entry.tool_call === undefined ? defaults.capabilities.tools : entry.tool_call === true,
input,
output,
// Left unset (unknown) unless the model was declared to accept it.
...(entry.temperature === true ? { temperature: true } : {}),
},
cost,
variants,
Expand Down
2 changes: 1 addition & 1 deletion src/utils/model-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export interface ModelHints {
* from older plugin versions are ignored rather than deserialized into
* an incompatible structure.
*/
const CACHE_VERSION = 5
const CACHE_VERSION = 6

/**
* Maximum age of a cache entry before it's treated as a miss. Without a
Expand Down
71 changes: 71 additions & 0 deletions test/plugin-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,77 @@ describe('OpenCode 2 plugin entrypoint', () => {
expect(disposeTransform).toHaveBeenCalledOnce()
})

it('declares temperature support only for models that list it', async () => {
cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-temperature-test-'))
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: 'accepts-temperature',
model_info: { mode: 'chat', supported_openai_params: ['max_tokens', 'temperature'] },
},
{
model_name: 'rejects-temperature',
model_info: { mode: 'chat', supported_openai_params: ['max_tokens'] },
},
],
}),
{ status: 200 },
)
}
return new Response(
JSON.stringify({
data: [
{ id: 'accepts-temperature', object: 'model' },
{ id: 'rejects-temperature', object: 'model' },
],
}),
{ status: 200 },
)
})

const registered: Array<{ models: Array<Record<string, unknown>> }> = []
const editor = {
list: () => [],
get: () => undefined,
add: (entry: { models: Array<Record<string, unknown>> }) => {
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' },
provider: {
list: vi.fn(async () => ({ data: [] })),
transform: vi.fn(async (transform: (editor: unknown) => void) => {
transform(editor)
return { dispose: vi.fn(async () => {}) }
}),
reload: vi.fn(async () => {}),
},
event: { subscribe: () => (async function* () {})() },
} as unknown as Context

const cleanup = await plugin.setup(context)

const byId = Object.fromEntries(registered[0].models.map((model) => [model.id, model]))
expect((byId['accepts-temperature'].capabilities as Record<string, unknown>).temperature).toBe(
true,
)
// Not listed: the capability stays unknown rather than being asserted false.
expect(
(byId['rejects-temperature'].capabilities as Record<string, unknown>).temperature,
).toBeUndefined()
await cleanup?.()
})

it('applies modelCompatibility overrides on top of inferred compatibility', async () => {
cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-test-'))
process.env.XDG_CACHE_HOME = cacheDirectory
Expand Down
22 changes: 22 additions & 0 deletions test/to-config-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,25 @@ describe('toConfigModel naming (formatModelNames)', () => {
expect(toConfigModel(embedding, undefined, true)).toBeNull()
})
})

describe('toConfigModel temperature', () => {
const chat = model('company/chat')

it('declares temperature when supported_openai_params lists it', () => {
expect(
toConfigModel(chat, { supported_openai_params: ['max_tokens', 'temperature'] })?.temperature,
).toBe(true)
})

it('leaves temperature unset when the parameter is not listed', () => {
expect(
toConfigModel(chat, { supported_openai_params: ['max_tokens', 'reasoning_effort'] })
?.temperature,
).toBeUndefined()
expect(toConfigModel(chat, { supported_openai_params: null })?.temperature).toBeUndefined()
})

it('leaves temperature unset when /v1/model/info is unavailable', () => {
expect(toConfigModel(chat)?.temperature).toBeUndefined()
})
})
Loading