From d4a32917e7104aacab8feddd37f00987e8692c2f Mon Sep 17 00:00:00 2001 From: Mateusz Kowalski Date: Sun, 18 Oct 2026 21:57:00 +0200 Subject: [PATCH] fix(v2): spell effort variants for Anthropic runtime packages Discovered reasoning-effort variants set `reasoningEffort`, which only the OpenAI runtimes read. On OpenCode 2 a provider can use `@opencode/ai/providers/anthropic` or `anthropic-compatible` to reach Claude through the proxy's `/v1/messages`; there every variant was a silent no-op. Record whether `/v1/model/info` reports `supports_adaptive_thinking` and `thinking_always_on`, and on Anthropic packages emit each level as `{ thinking: { type: "adaptive", display: "summarized" }, effort }`, the shape OpenCode's own catalog uses for adaptive-thinking Claude models. `none` becomes `thinking: { type: "disabled" }` and is dropped when thinking is always on. Models without adaptive thinking get no variants on those packages rather than inert ones. OpenAI packages are unchanged. Cache entries written before this change carry neither hint, so `CACHE_VERSION` moves to 5 and such caches are rediscovered. Signed-off-by: Mateusz Kowalski Assisted-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + README.md | 25 ++++ src/plugin/index.ts | 5 +- src/plugin/v2.ts | 33 ++++- src/types/index.ts | 2 + src/utils/model-cache.ts | 22 +++- test/model-cache.test.ts | 22 ++-- test/model-compatibility.test.ts | 13 +- test/plugin-v1.test.ts | 12 +- test/plugin-v2.test.ts | 209 ++++++++++++++++++++++++++++--- 10 files changed, 302 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c495ad0..b2ae434 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ ### Bug Fixes * **v2:** register Chat Completions models whose deployment accepts Anthropic `cache_control` with `compatibility.cacheFormat: "anthropic"` so prompt caching works for Claude behind LiteLLM +* **v2:** spell reasoning-effort variants as `thinking` + `effort` for providers on an Anthropic runtime package, where `reasoningEffort` is ignored; models without adaptive thinking get none there * restore baseline reasoning-effort variants from sparse LiteLLM metadata (including GPT-6 Astra), respect explicit effort lists and disabled levels * avoid inferring reasoning-effort variants from negative-only flags or unsupported request parameters * constrain inferred GPT-5 Pro effort variants to provider-supported levels, including aliased and dated deployments diff --git a/README.md b/README.md index 668413f..6f44b05 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,31 @@ between effort levels from the model picker without hand-curating every entry. > `mode: "responses"` the plugin routes it to `/v1/responses` automatically; > otherwise reach it without `reasoningEffort`. See the FAQ entry below. +On OpenCode 2, models whose effective package is `@opencode/ai/providers/anthropic` +or `@opencode/ai/providers/anthropic-compatible` get the same levels in the +shape that runtime reads, `{ thinking: { type: "adaptive", display: "summarized" }, effort }`, +for models LiteLLM reports with `supports_adaptive_thinking`, with `none` +becoming `thinking: { type: "disabled" }` (dropped when `thinking_always_on`). +Models without adaptive thinking get no variants on those packages, since +`reasoningEffort` would be ignored there. To reach Claude over the proxy's +`/v1/messages` while the rest of the catalog keeps its usual transport, split it +with [`includeModels`/`excludeModels`](#splitting-one-proxy-into-multiple-providers-includemodels--excludemodels): + +```jsonc +{ + "providers": { + "litellm": { + "package": "@opencode/ai/providers/openai-compatible", + "settings": { "baseURL": "http://localhost:4000/v1", "excludeModels": ["*claude*"] } + }, + "litellm-claude": { + "package": "@opencode/ai/providers/anthropic-compatible", + "settings": { "baseURL": "http://localhost:4000/v1", "includeModels": ["*claude*"] } + } + } +} +``` + ### Responses API models LiteLLM reports `mode` per model on `/v1/model/info` (the plugin fetches it diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 4d0f62e..197519f 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -333,7 +333,8 @@ export function toConfigModel( * config (or translated into `Model.Info` on OpenCode 2). `responsesIds` * lists the subset that must be invoked through the OpenAI Responses API; * callers route those accordingly without inspecting each entry. `hints` - * carries per-model upstream facts OpenCode 2 maps to `compatibility`. + * carries per-model upstream facts OpenCode 2 maps to `compatibility` and + * `variants`. */ export interface DiscoveredModels { models: Record @@ -447,6 +448,8 @@ export async function discoverModels( const params = info?.supported_openai_params hints[model.id] = { acceptsCacheControl: Array.isArray(params) && params.includes('cache_control'), + adaptiveThinking: info?.supports_adaptive_thinking === true, + thinkingAlwaysOn: info?.thinking_always_on === true, } } diff --git a/src/plugin/v2.ts b/src/plugin/v2.ts index 1223491..e19f400 100644 --- a/src/plugin/v2.ts +++ b/src/plugin/v2.ts @@ -39,6 +39,11 @@ const CHAT_COMPLETIONS_PACKAGES = new Set([ OPENAI_COMPATIBLE_PACKAGE, '@opencode/ai/providers/openai/chat', ]) +const ANTHROPIC_PACKAGES = new Set([ + '@opencode/ai/providers/anthropic', + '@opencode/ai/providers/anthropic-compatible', +]) +const ADAPTIVE_THINKING = { type: 'adaptive', display: 'summarized' } // Runtime package that talks to `/v1/responses`. OpenCode 2 lets one model // override the provider's package, so Responses models stay under their // original provider id and no sibling provider is needed here. @@ -126,6 +131,29 @@ function numberOrZero(value: unknown): number { return typeof value === 'number' && Number.isFinite(value) ? value : 0 } +/** + * Discovery spells every effort level as `{ reasoningEffort }`, which the + * OpenAI runtimes read. The Anthropic runtimes read `thinking` and `effort` + * instead, so on those packages the levels are respelled the way OpenCode's + * own catalog does for adaptive-thinking Claude models. A model that does + * not report adaptive thinking gets no variants rather than inert ones. + */ +function anthropicVariants( + variants: Model.Info['variants'], + hints: ModelHints | undefined, +): Model.Info['variants'] { + if (!hints?.adaptiveThinking) return [] + return variants.flatMap((variant) => { + const effort = variant.settings?.reasoningEffort + if (typeof effort !== 'string') return [] + if (effort === 'none') { + if (hints.thinkingAlwaysOn) return [] + return [{ ...variant, settings: { thinking: { type: 'disabled' } } }] + } + return [{ ...variant, settings: { thinking: ADAPTIVE_THINKING, effort } }] + }) +} + interface ToProviderModelsOptions { /** The provider's runtime package; models not overridden inherit it. */ providerPackage: string @@ -172,11 +200,14 @@ function toProviderModels( : [] const rawVariants = asRecord(entry.variants) - const variants = Object.entries(rawVariants).map(([id, settings]) => ({ + const openAIVariants = Object.entries(rawVariants).map(([id, settings]) => ({ id: id as Model.VariantID, settings: asRecord(settings), })) as Model.Info['variants'] const pkg = responsesIds.has(modelID) ? RESPONSES_PACKAGE : options.providerPackage + const variants = ANTHROPIC_PACKAGES.has(pkg) + ? anthropicVariants(openAIVariants, hints[modelID]) + : openAIVariants const compatibility = resolveCompatibility( inferCompatibility(hints[modelID], CHAT_COMPLETIONS_PACKAGES.has(pkg)), Object.hasOwn(overrides, modelID) ? overrides[modelID] : undefined, diff --git a/src/types/index.ts b/src/types/index.ts index d35b41e..766ceae 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -71,6 +71,8 @@ export interface LiteLLMModelInfo { supported_openai_params?: string[] | null supports_pdf_input?: boolean supports_audio_input?: boolean + supports_adaptive_thinking?: boolean + thinking_always_on?: boolean input_cost_per_token?: number output_cost_per_token?: number cache_read_input_token_cost?: number diff --git a/src/utils/model-cache.ts b/src/utils/model-cache.ts index 8d80441..355844e 100644 --- a/src/utils/model-cache.ts +++ b/src/utils/model-cache.ts @@ -5,8 +5,9 @@ import { join } from 'node:path' /** * Facts about a discovered model's upstream that OpenCode 2 turns into - * `Model.Info.compatibility`. They ride alongside the config-shaped model - * entries (which have no field for them) through discovery and the cache. + * `Model.Info.compatibility` and `variants`. They ride alongside the + * config-shaped model entries (which have no field for them) through + * discovery and the cache. */ export interface ModelHints { /** @@ -14,6 +15,13 @@ export interface ModelHints { * Chat Completions (`supported_openai_params` lists `cache_control`). */ acceptsCacheControl: boolean + /** + * The model takes `thinking: { type: "adaptive" }` plus an `effort` level + * (`supports_adaptive_thinking`). + */ + adaptiveThinking: boolean + /** Thinking cannot be turned off (`thinking_always_on`). */ + thinkingAlwaysOn: boolean } /** @@ -21,7 +29,7 @@ export interface ModelHints { * from older plugin versions are ignored rather than deserialized into * an incompatible structure. */ -const CACHE_VERSION = 4 +const CACHE_VERSION = 5 /** * Maximum age of a cache entry before it's treated as a miss. Without a @@ -158,8 +166,12 @@ function normalizeHints(value: unknown): Record { const out: Record = {} for (const [id, hint] of Object.entries(value as Record)) { if (!hint || typeof hint !== 'object' || Array.isArray(hint)) continue - const { acceptsCacheControl } = hint as Record - out[id] = { acceptsCacheControl: acceptsCacheControl === true } + const { acceptsCacheControl, adaptiveThinking, thinkingAlwaysOn } = hint as Record + out[id] = { + acceptsCacheControl: acceptsCacheControl === true, + adaptiveThinking: adaptiveThinking === true, + thinkingAlwaysOn: thinkingAlwaysOn === true, + } } return out } diff --git a/test/model-cache.test.ts b/test/model-cache.test.ts index ef598d0..cead715 100644 --- a/test/model-cache.test.ts +++ b/test/model-cache.test.ts @@ -80,25 +80,23 @@ describe('model cache', () => { }) it('round-trips model hints and drops malformed ones', () => { - writeModelCache('hints@http://x', { a: {}, b: {} }, [], { - a: { acceptsCacheControl: true }, - b: { acceptsCacheControl: false }, + const a = { acceptsCacheControl: true, adaptiveThinking: true, thinkingAlwaysOn: false } + const b = { acceptsCacheControl: false, adaptiveThinking: false, thinkingAlwaysOn: true } + writeModelCache('hints@http://x', { a: {}, b: {} }, [], { a, b }) + expect(readModelCacheEntry('hints@http://x')?.hints).toEqual({ a, b }) + rewriteCacheFile('hints@http://x', (parsed) => { + parsed.hints = { a: { acceptsCacheControl: 'yes', adaptiveThinking: 1 }, b: null, c: [] } }) expect(readModelCacheEntry('hints@http://x')?.hints).toEqual({ - a: { acceptsCacheControl: true }, - b: { acceptsCacheControl: false }, - }) - rewriteCacheFile('hints@http://x', (parsed) => { - parsed.hints = { a: { acceptsCacheControl: 'yes' }, b: null, c: [] } + a: { acceptsCacheControl: false, adaptiveThinking: false, thinkingAlwaysOn: false }, }) - expect(readModelCacheEntry('hints@http://x')?.hints).toEqual({ a: { acceptsCacheControl: false } }) }) - it('treats caches that predate the model hints as a miss', () => { + it('treats caches that predate the thinking hints as a miss', () => { writeModelCache(KEY, { m: {} }, ['m']) rewriteCacheFile(KEY, (parsed) => { - parsed.version = 3 - delete parsed.hints + parsed.version = 4 + parsed.hints = { m: { acceptsCacheControl: true } } }) expect(readModelCacheEntry(KEY)).toBeNull() expect(readModelCache(KEY)).toBeNull() diff --git a/test/model-compatibility.test.ts b/test/model-compatibility.test.ts index 294b374..6619ecf 100644 --- a/test/model-compatibility.test.ts +++ b/test/model-compatibility.test.ts @@ -4,6 +4,13 @@ import { parseModelCompatibility, resolveCompatibility, } from '../src/utils/model-compatibility' +import type { ModelHints } from '../src/utils/model-cache' + +const hints = (acceptsCacheControl: boolean): ModelHints => ({ + acceptsCacheControl, + adaptiveThinking: false, + thinkingAlwaysOn: false, +}) describe('parseModelCompatibility', () => { it('returns an empty map for non-object input', () => { @@ -42,13 +49,13 @@ describe('parseModelCompatibility', () => { describe('inferCompatibility', () => { it('asks Chat Completions for Anthropic cache markers when the proxy accepts them', () => { - expect(inferCompatibility({ acceptsCacheControl: true }, true)).toEqual({ cacheFormat: 'anthropic' }) + expect(inferCompatibility(hints(true), true)).toEqual({ cacheFormat: 'anthropic' }) }) it('infers nothing without the hint or outside Chat Completions', () => { - expect(inferCompatibility({ acceptsCacheControl: false }, true)).toEqual({}) + expect(inferCompatibility(hints(false), true)).toEqual({}) expect(inferCompatibility(undefined, true)).toEqual({}) - expect(inferCompatibility({ acceptsCacheControl: true }, false)).toEqual({}) + expect(inferCompatibility(hints(true), false)).toEqual({}) }) }) diff --git a/test/plugin-v1.test.ts b/test/plugin-v1.test.ts index e569741..040238c 100644 --- a/test/plugin-v1.test.ts +++ b/test/plugin-v1.test.ts @@ -143,8 +143,8 @@ describe('OpenCode 1 config hook', () => { const cacheKey = buildCacheKey('litellm', 'http://127.0.0.1:45557', {}, {}) expect(readModelCacheEntry(cacheKey)?.hints).toEqual({ - claude: { acceptsCacheControl: true }, - gpt: { acceptsCacheControl: false }, + claude: { acceptsCacheControl: true, adaptiveThinking: false, thinkingAlwaysOn: false }, + gpt: { acceptsCacheControl: false, adaptiveThinking: false, thinkingAlwaysOn: false }, }) }) @@ -170,11 +170,15 @@ describe('OpenCode 1 config hook', () => { await plugin.config!(config as never) dateNow.mockRestore() const cacheKey = buildCacheKey('litellm', 'http://127.0.0.1:45558', {}, {}) - expect(readModelCacheEntry(cacheKey)?.hints).toEqual({ claude: { acceptsCacheControl: true } }) + expect(readModelCacheEntry(cacheKey)?.hints).toEqual({ + claude: { acceptsCacheControl: true, adaptiveThinking: false, thinkingAlwaysOn: false }, + }) await plugin.event!({ event: { type: 'session.created' } } as never) await vi.waitFor(() => expect(readModelCacheSavedAt(cacheKey)).toBeGreaterThanOrEqual(started)) - expect(readModelCacheEntry(cacheKey)?.hints).toEqual({ claude: { acceptsCacheControl: true } }) + expect(readModelCacheEntry(cacheKey)?.hints).toEqual({ + claude: { acceptsCacheControl: true, adaptiveThinking: false, thinkingAlwaysOn: false }, + }) }) it('creates no sibling provider when no model needs the Responses API', async () => { diff --git a/test/plugin-v2.test.ts b/test/plugin-v2.test.ts index 2199eeb..7ac6dc8 100644 --- a/test/plugin-v2.test.ts +++ b/test/plugin-v2.test.ts @@ -101,6 +101,7 @@ describe('OpenCode 2 plugin entrypoint', () => { supports_vision: true, supports_reasoning: true, supports_reasoning_efforts: ['low', 'high'], + supports_adaptive_thinking: true, supported_openai_params: ['max_tokens', 'cache_control', 'reasoning_effort'], input_cost_per_token: 0.000003, output_cost_per_token: 0.000015, @@ -275,10 +276,14 @@ describe('OpenCode 2 plugin entrypoint', () => { expect(registered[0].models[3].compatibility).toBeUndefined() const cacheKey = buildCacheKey('litellm', 'http://127.0.0.1:44444', {}, {}) expect(readModelCacheEntry(cacheKey)?.hints).toEqual({ - 'anthropic/claude-3-5-sonnet': { acceptsCacheControl: true }, - 'gpt-6-astra': { acceptsCacheControl: false }, - 'gpt-5-search-api': { acceptsCacheControl: false }, - 'company-pro': { acceptsCacheControl: true }, + 'anthropic/claude-3-5-sonnet': { + acceptsCacheControl: true, + adaptiveThinking: true, + thinkingAlwaysOn: false, + }, + 'gpt-6-astra': { acceptsCacheControl: false, adaptiveThinking: false, thinkingAlwaysOn: false }, + 'gpt-5-search-api': { acceptsCacheControl: false, adaptiveThinking: false, thinkingAlwaysOn: false }, + 'company-pro': { acceptsCacheControl: true, adaptiveThinking: false, thinkingAlwaysOn: false }, }) await cleanup?.() @@ -379,9 +384,9 @@ describe('OpenCode 2 plugin entrypoint', () => { { claude: { name: 'Claude' }, qwen: { name: 'Qwen' }, constructor: { name: 'Constructor' } }, [], { - claude: { acceptsCacheControl: true }, - qwen: { acceptsCacheControl: true }, - constructor: { acceptsCacheControl: true }, + claude: { acceptsCacheControl: true, adaptiveThinking: false, thinkingAlwaysOn: false }, + qwen: { acceptsCacheControl: true, adaptiveThinking: false, thinkingAlwaysOn: false }, + constructor: { acceptsCacheControl: true, adaptiveThinking: false, thinkingAlwaysOn: false }, }, ) globalThis.fetch = vi.fn(async () => { @@ -498,6 +503,150 @@ describe('OpenCode 2 plugin entrypoint', () => { await cleanup?.() }) + it('respells effort variants for providers on an Anthropic runtime package', async () => { + cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-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: [ + { + // Adaptive thinking, can be switched off: `none` becomes `disabled`. + model_name: 'claude-adaptive', + model_info: { + key: 'claude-adaptive', + mode: 'chat', + supports_reasoning: true, + supports_adaptive_thinking: true, + reasoning_effort_levels: ['none', 'low', 'max'], + supported_openai_params: ['cache_control', 'reasoning_effort'], + }, + }, + { + // Adaptive thinking that is always on: `none` is dropped. + model_name: 'claude-always-on', + model_info: { + key: 'claude-always-on', + mode: 'chat', + supports_reasoning: true, + supports_adaptive_thinking: true, + thinking_always_on: true, + reasoning_effort_levels: ['none', 'high'], + }, + }, + { + // No adaptive thinking reported: `reasoningEffort` would be inert, so no variants. + model_name: 'claude-manual', + model_info: { + key: 'claude-manual', + mode: 'chat', + supports_reasoning: true, + reasoning_effort_levels: ['low', 'high'], + }, + }, + { + // Responses override on the same provider: the OpenAI runtime keeps `reasoningEffort`. + model_name: 'gpt-responses', + model_info: { + key: 'gpt-responses', + mode: 'responses', + supports_reasoning: true, + supports_adaptive_thinking: true, + reasoning_effort_levels: ['low'], + }, + }, + ], + }), + { status: 200 }, + ) + } + return new Response( + JSON.stringify({ data: [ + { id: 'claude-adaptive', object: 'model' }, + { id: 'claude-always-on', object: 'model' }, + { id: 'claude-manual', object: 'model' }, + { id: 'gpt-responses', object: 'model' }, + ] }), + { status: 200 }, + ) + }) + + const registered: Array<{ info: Record; models: Array> }> = [] + const editor = { + list: () => [], + get: () => undefined, + add: (entry: { info: Record; 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:44444/v1' }, + provider: { + list: vi.fn(async () => ({ + data: [ + { + id: 'litellm-claude', + name: 'LiteLLM Claude', + activation: 'enabled', + package: '@opencode/ai/providers/anthropic-compatible', + settings: { baseURL: 'http://127.0.0.1:44444/v1' }, + }, + { + id: 'litellm-anthropic', + name: 'LiteLLM Anthropic', + activation: 'enabled', + package: '@opencode/ai/providers/anthropic', + settings: { baseURL: 'http://127.0.0.1:44444/v1' }, + }, + ], + })), + transform: vi.fn(async (transform: (editor: unknown) => void) => { + transform(editor as never) + return { dispose: vi.fn(async () => {}) } + }), + reload: vi.fn(async () => {}), + }, + event: { subscribe: () => (async function* () {})() }, + } as unknown as Context + + const cleanup = await plugin.setup(context) + + expect(registered.map((entry) => entry.info.id)).toEqual(['litellm-claude', 'litellm-anthropic']) + expect(registered[0].info).toMatchObject({ + id: 'litellm-claude', + package: '@opencode/ai/providers/anthropic-compatible', + }) + const adaptive = { type: 'adaptive', display: 'summarized' } + expect(registered[0].models.map((model) => [model.id, model.variants])).toEqual([ + [ + 'claude-adaptive', + [ + { id: 'none', settings: { thinking: { type: 'disabled' } } }, + { id: 'low', settings: { thinking: adaptive, effort: 'low' } }, + { id: 'max', settings: { thinking: adaptive, effort: 'max' } }, + ], + ], + ['claude-always-on', [{ id: 'high', settings: { thinking: adaptive, effort: 'high' } }]], + ['claude-manual', []], + ['gpt-responses', [{ id: 'low', settings: { reasoningEffort: 'low' } }]], + ]) + // Both Anthropic packages get the same spelling. + expect(registered[1].models.map((model) => model.variants)).toEqual( + registered[0].models.map((model) => model.variants), + ) + // The Anthropic runtime places cache breakpoints itself; nothing to declare, + // even though `claude-adaptive` advertises `cache_control`. + const all = registered.flatMap((entry) => entry.models) + expect(all.every((model) => model.compatibility === undefined)).toBe(true) + await cleanup?.() + }) + it('uses service environment variables to create the default provider', async () => { cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-env-test-')) process.env.XDG_CACHE_HOME = cacheDirectory @@ -761,22 +910,33 @@ describe('OpenCode 2 plugin entrypoint', () => { ['vertex/claude-sonnet', { cacheFormat: 'anthropic' }], ]) expect(readModelCacheEntry(cacheKey)?.hints).toEqual({ - 'anthropic/claude-3-5-sonnet': { acceptsCacheControl: true }, - 'vertex/claude-sonnet': { acceptsCacheControl: true }, + 'anthropic/claude-3-5-sonnet': { + acceptsCacheControl: true, + adaptiveThinking: false, + thinkingAlwaysOn: false, + }, + 'vertex/claude-sonnet': { + acceptsCacheControl: true, + adaptiveThinking: false, + thinkingAlwaysOn: false, + }, }) await cleanup?.() }) - it('infers no cacheFormat for an Anthropic package, including after a refresh', async () => { + it('keeps Anthropic variants and no cacheFormat across a background refresh', async () => { cacheDirectory = mkdtempSync(join(tmpdir(), 'opencode-litellm-refresh-test-')) process.env.XDG_CACHE_HOME = cacheDirectory const baseURL = 'http://127.0.0.1:44444' const cacheKey = buildCacheKey('litellm-claude', baseURL, {}, {}) const now = Date.now() const dateNow = vi.spyOn(Date, 'now').mockReturnValue(now - 10 * 60 * 1000) - writeModelCache(cacheKey, { 'cached-model': { name: 'Cached Model' } }, [], { - 'cached-model': { acceptsCacheControl: true }, - }) + writeModelCache( + cacheKey, + { 'cached-model': { name: 'Cached Model', variants: { high: { reasoningEffort: 'high' } } } }, + [], + { 'cached-model': { acceptsCacheControl: true, adaptiveThinking: true, thinkingAlwaysOn: false } }, + ) dateNow.mockRestore() globalThis.fetch = vi.fn(async (input) => { @@ -787,7 +947,14 @@ describe('OpenCode 2 plugin entrypoint', () => { data: [ { model_name: 'claude', - model_info: { key: 'claude', mode: 'chat', supported_openai_params: ['cache_control'] }, + model_info: { + key: 'claude', + mode: 'chat', + supports_reasoning: true, + supports_adaptive_thinking: true, + reasoning_effort_levels: ['high'], + supported_openai_params: ['cache_control'], + }, }, ], }), @@ -843,9 +1010,15 @@ describe('OpenCode 2 plugin entrypoint', () => { const cleanup = await plugin.setup(context) await vi.waitFor(() => expect(reload).toHaveBeenCalledOnce()) + const high = [ + { id: 'high', settings: { thinking: { type: 'adaptive', display: 'summarized' }, effort: 'high' } }, + ] + // Cached hints drive the warm start; discovered hints drive the refresh. expect(registeredModels[0].map((model) => model.compatibility)).toEqual([undefined]) + expect(registeredModels[0].map((model) => model.variants)).toEqual([high]) expect(registeredModels[1].map((model) => model.id)).toEqual(['claude']) expect(registeredModels[1][0].compatibility).toBeUndefined() + expect(registeredModels[1][0].variants).toEqual(high) await cleanup?.() }) @@ -906,12 +1079,16 @@ describe('OpenCode 2 plugin entrypoint', () => { const first = await plugin.setup(makeContext([])) dateNow.mockRestore() await first?.() - expect(readModelCacheEntry(cacheKey)?.hints).toEqual({ claude: { acceptsCacheControl: true } }) + expect(readModelCacheEntry(cacheKey)?.hints).toEqual({ + claude: { acceptsCacheControl: true, adaptiveThinking: false, thinkingAlwaysOn: false }, + }) const second = await plugin.setup(makeContext([{ type: 'session.created' }])) await vi.waitFor(() => expect(readModelCacheSavedAt(cacheKey)).toBeGreaterThanOrEqual(started)) expect(reload).not.toHaveBeenCalled() - expect(readModelCacheEntry(cacheKey)?.hints).toEqual({ claude: { acceptsCacheControl: true } }) + expect(readModelCacheEntry(cacheKey)?.hints).toEqual({ + claude: { acceptsCacheControl: true, adaptiveThinking: false, thinkingAlwaysOn: false }, + }) await second?.() })