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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
Expand Down Expand Up @@ -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,
}
}

Expand Down
33 changes: 32 additions & 1 deletion src/plugin/v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 } }]
Comment thread
yuseferi marked this conversation as resolved.
})
}

interface ToProviderModelsOptions {
/** The provider's runtime package; models not overridden inherit it. */
providerPackage: string
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 17 additions & 5 deletions src/utils/model-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,31 @@ 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 {
/**
* The deployment accepts Anthropic-style `cache_control` markers on
* 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
}

/**
* Bump when the shape of a cached model entry changes so stale caches
* 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
Expand Down Expand Up @@ -158,8 +166,12 @@ function normalizeHints(value: unknown): Record<string, ModelHints> {
const out: Record<string, ModelHints> = {}
for (const [id, hint] of Object.entries(value as Record<string, unknown>)) {
if (!hint || typeof hint !== 'object' || Array.isArray(hint)) continue
const { acceptsCacheControl } = hint as Record<string, unknown>
out[id] = { acceptsCacheControl: acceptsCacheControl === true }
const { acceptsCacheControl, adaptiveThinking, thinkingAlwaysOn } = hint as Record<string, unknown>
out[id] = {
acceptsCacheControl: acceptsCacheControl === true,
adaptiveThinking: adaptiveThinking === true,
thinkingAlwaysOn: thinkingAlwaysOn === true,
}
}
return out
}
Expand Down
22 changes: 10 additions & 12 deletions test/model-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
13 changes: 10 additions & 3 deletions test/model-compatibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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({})
})
})

Expand Down
12 changes: 8 additions & 4 deletions test/plugin-v1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
})
})

Expand All @@ -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 () => {
Expand Down
Loading
Loading