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?.() })