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() + }) +})