From 4617f7285b96e639990ae5f0f3e7535d62130394 Mon Sep 17 00:00:00 2001 From: Cleverson Sacramento Date: Tue, 20 Oct 2026 11:43:37 -0300 Subject: [PATCH] feat: declare temperature support for models that list it OpenCode sends a sampling temperature (its own per-model default, or one set on the agent) only for models flagged as supporting it. The plugin never set the flag, so every discovered model ended up unflagged and the user's temperature was silently ignored, even for models whose /v1/model/info lists `temperature` in `supported_openai_params`. Declare `temperature` when `supported_openai_params` lists it, on both the OpenCode 1 config entry and the OpenCode 2 `capabilities`. When the parameter is not listed, or /v1/model/info is unavailable, the field is left unset so behavior is unchanged (some deployments, such as reasoning-only endpoints or guardrail classifiers, reject it). The cached entry shape gains a field, so CACHE_VERSION moves to 6 and older caches are rediscovered. --- README.md | 2 +- src/plugin/index.ts | 11 ++++++ src/plugin/v2.ts | 2 + src/utils/model-cache.ts | 2 +- test/plugin-v2.test.ts | 71 ++++++++++++++++++++++++++++++++++++ test/to-config-model.test.ts | 22 +++++++++++ 6 files changed, 108 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2abd7ce..919da91 100644 --- a/README.md +++ b/README.md @@ -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 `-responses` sibling. | diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 0f020cc..4bb4a73 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -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" diff --git a/src/plugin/v2.ts b/src/plugin/v2.ts index cfdac55..4452be3 100644 --- a/src/plugin/v2.ts +++ b/src/plugin/v2.ts @@ -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, diff --git a/src/utils/model-cache.ts b/src/utils/model-cache.ts index 2be3e44..c6c48ae 100644 --- a/src/utils/model-cache.ts +++ b/src/utils/model-cache.ts @@ -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 diff --git a/test/plugin-v2.test.ts b/test/plugin-v2.test.ts index 704ecde..9f56fce 100644 --- a/test/plugin-v2.test.ts +++ b/test/plugin-v2.test.ts @@ -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> }> = [] + const editor = { + list: () => [], + get: () => undefined, + add: (entry: { 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' }, + 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).temperature).toBe( + true, + ) + // Not listed: the capability stays unknown rather than being asserted false. + expect( + (byId['rejects-temperature'].capabilities as Record).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 diff --git a/test/to-config-model.test.ts b/test/to-config-model.test.ts index 7c5c459..cf7980e 100644 --- a/test/to-config-model.test.ts +++ b/test/to-config-model.test.ts @@ -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() + }) +})