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 @@ -4,6 +4,7 @@ This repo is a CortexKit-maintained Anthropic auth monorepo for OpenCode and Pi.

## Unreleased

- On OpenCode 2, Claude models the plugin supports but OpenCode's own model list doesn't have yet (such as Fable 5.2 on launch day) now appear in the model picker, as on OpenCode 1.
- Adds Claude Fable 5.2 (`claude-fable-5-2`) on OpenCode 1, OpenCode 2 and Pi, sent the way Claude Code sends it: adaptive thinking, medium default effort, per-message effort and Anthropic's safety fallback. Fast mode stays off for Fable 5.2 until Anthropic enables it for subscription accounts.
- Claude subscription requests no longer carry a `service_tier` field a host adds, keeping them in the shape Claude Code sends; API-key requests keep it.
## 2.7.3
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/tests/opencode2-cachekeep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ async function fixture(
},
},
model: { transform: async () => registration },
provider: { transform: async () => registration },
integration: {
transform: async () => {
logger.debug('startup-test', 'registering integration')
Expand Down
146 changes: 146 additions & 0 deletions packages/opencode/src/tests/opencode2-models.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,152 @@
import { expect, test } from 'bun:test'
import { CLAUDE_MODEL_CAPABILITIES } from '@cortexkit/anthropic-auth-core'
import type { Model, Plugin } from '@opencode/plugin'
import { createNativeClaudeModelRules } from '../v2/models'

function hostModel(modelID = 'claude-fable-5-1'): Model.Info {
// Metadata from models.dev's Fable 5.1 row via OC2's modelInfo conversion.
return {
id: modelID,
modelID,
providerID: 'anthropic',
type: 'chat',
family: 'claude-fable',
name: 'Claude Fable 5.1',
capabilities: {
tools: true,
input: ['text', 'image', 'pdf'],
output: ['text'],
temperature: false,
},
variants: [],
time: { released: Date.parse('2026-09-01') },
cost: [{ input: 10, output: 50, cache: { read: 0.25, write: 12.5 } }],
status: 'active',
enabled: true,
limit: { context: 1_000_000, output: 128_000 },
} as unknown as Model.Info
}

async function providerCatalogue(inventory: Record<string, Model.Info[]>) {
const writes: string[] = []
const models = new Map(
Object.entries(inventory).map(([provider, rows]) => [
provider,
new Map(rows.map((row) => [String(row.id), row])),
]),
)
const rules = createNativeClaudeModelRules()
await rules.installProviderModels({
provider: {
transform: async (
apply: Parameters<Plugin.Context['provider']['transform']>[0],
) => {
apply({
get: (provider: string) => {
const rows = models.get(provider)
return rows ? { models: rows } : undefined
},
models: {
set: (provider: string, rows: readonly Model.Info[]) => {
writes.push(provider)
models.set(
provider,
new Map(rows.map((row) => [String(row.id), row])),
)
},
},
} as never)
return { dispose: async () => {} }
},
},
} as never)
return { models, writes }
}

test('OC2 fills missing Anthropic catalogue rows with table limits, prices and effort variants', async () => {
const host = hostModel()
const { models, writes } = await providerCatalogue({ anthropic: [host] })
expect(writes).toEqual(['anthropic'])
expect(models.get('anthropic')?.get(host.id)).toBe(host)
expect(models.get('anthropic')?.get('claude-fable-5-2')).toMatchObject({
id: 'claude-fable-5-2',
modelID: 'claude-fable-5-2',
providerID: 'anthropic',
type: 'chat',
family: 'claude-fable',
name: 'Claude Fable 5.2',
limit: { context: 1_000_000, output: 128_000 },
cost: [{ input: 8, output: 40, cache: { read: 0.2, write: 10 } }],
time: { released: Date.parse('2026-11-09') },
capabilities: {
tools: true,
input: ['text', 'image', 'pdf'],
output: ['text'],
temperature: false,
},
variants: ['low', 'medium', 'high', 'xhigh', 'max'].map((effort) => ({
id: effort,
settings: {
effort,
thinking: { type: 'adaptive', display: 'summarized' },
},
})),
})
})

test('OC2 preserves an existing exact host row including its different price', async () => {
const host = {
...hostModel('claude-fable-5-2'),
name: 'Host-owned Fable',
cost: [{ input: 123, output: 456, cache: { read: 7, write: 8 } }],
variants: [{ id: 'host', settings: { effort: 'low' } }],
} as unknown as Model.Info
const before = structuredClone(host)
const { models } = await providerCatalogue({ anthropic: [host] })
expect(models.get('anthropic')?.get('claude-fable-5-2')).toBe(host)
expect(host).toEqual(before)
})

test('OC2 leaves other providers untouched and never creates an absent Anthropic provider', async () => {
const other = hostModel('other-model')
const { models, writes } = await providerCatalogue({ other: [other] })
expect(writes).toEqual([])
expect(models.get('other')?.get('other-model')).toBe(other)
expect(models.has('anthropic')).toBe(false)
const both = await providerCatalogue({ anthropic: [], other: [other] })
expect(both.writes).toEqual(['anthropic'])
expect([...both.models.get('other')!.values()]).toEqual([other])
expect(both.models.get('other')!.get('other-model')).toBe(other)
})

test('OC2 adds only rows with catalogues, including limited Mythos rows as on OC1', async () => {
const { models } = await providerCatalogue({ anthropic: [] })
expect([...models.get('anthropic')!.keys()]).toEqual(
CLAUDE_MODEL_CAPABILITIES.filter((row) => row.catalogue).map(
(row) => row.id,
),
)
expect(models.get('anthropic')!.has('claude-mythos-5')).toBe(true)
expect(models.get('anthropic')!.has('claude-mythos-5-1')).toBe(true)
expect(models.get('anthropic')!.has('claude-opus-5')).toBe(false)
})

test('OC2 matches existing models by exact id rather than a dated alias', async () => {
const host = hostModel('claude-fable-5-2-20261109')
const { models } = await providerCatalogue({ anthropic: [host] })
expect(models.get('anthropic')?.get(host.id)).toBe(host)
expect(models.get('anthropic')!.has('claude-fable-5-2')).toBe(true)
})

test('OC2 does not replace an already complete host inventory', async () => {
const rows = CLAUDE_MODEL_CAPABILITIES.filter((row) => row.catalogue).map(
(row) => hostModel(row.id),
)
const { models, writes } = await providerCatalogue({ anthropic: rows })
expect(writes).toEqual([])
expect([...models.get('anthropic')!.values()]).toEqual(rows)
})

test.each(['claude-fable-5-2', 'claude-fable-5-2-20261109'])(
'OC2 adds adaptive effort variants to host catalogue model %s',
(modelID) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ async function fixture() {
hook: async () => registration,
},
model: { transform: async () => registration },
provider: { transform: async () => registration },
integration: { transform: async () => registration },
rpc: {
register: async () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ function openCode2Context() {
hook: async () => registration,
},
model: { transform: async () => registration },
provider: { transform: async () => registration },
integration: { transform: async () => registration },
rpc: {
register: async () => ({
Expand Down
110 changes: 97 additions & 13 deletions packages/opencode/src/v2/models.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,98 @@
import { resolveClaudeModelCapabilities } from '@cortexkit/anthropic-auth-core'
import {
CLAUDE_MODEL_CAPABILITIES,
type ClaudeModelPricing,
resolveClaudeModelCapabilities,
} from '@cortexkit/anthropic-auth-core'
import type { Model, Plugin } from '@opencode/plugin'

/** Each model variant supplies request settings for adaptive thinking and its selected reasoning effort. */
function effortVariants(
efforts: readonly string[] = [],
): Model.Info['variants'] {
return efforts.map((effort) => ({
id: effort as Model.VariantID,
settings: {
effort,
thinking: { type: 'adaptive', display: 'summarized' },
},
}))
}

function modelCost(pricing: ClaudeModelPricing): Model.Info['cost'][number] {
// OC2, like OC1, has one cache-write price; use the five-minute price.
return {
input: pricing.input,
output: pricing.output,
cache: { read: pricing.cacheRead, write: pricing.cacheWrite5m },
} as Model.Info['cost'][number]
}

function nativeCatalogueModels(): Model.Info[] {
return CLAUDE_MODEL_CAPABILITIES.flatMap((row): Model.Info[] => {
const catalogue = row.catalogue
if (!catalogue) return []
return [
{
id: row.id as Model.ID,
modelID: row.id as Model.ID,
providerID: 'anthropic' as Model.Info['providerID'],
type: 'chat',
family: `claude-${row.family}` as Model.Info['family'],
name: row.name,
// OC2 represents attachments as input modalities and reasoning as variants.
capabilities: {
tools: true,
input: ['text', 'image', 'pdf'],
output: ['text'],
temperature: false,
},
variants: effortVariants(row.efforts.variants),
time: { released: Date.parse(catalogue.releaseDate) },
cost: [
modelCost(catalogue.pricing),
...(catalogue.longContext
? [
{
...modelCost(catalogue.longContext.pricing),
tier: {
type: 'context' as const,
size: catalogue.longContext.inputTokensAbove,
},
},
]
: []),
],
status: 'active',
enabled: true,
limit: {
context: catalogue.contextWindow,
output: catalogue.maxOutputTokens,
},
},
]
})
}

/** Supply missing catalogue rows and adaptive-thinking effort variants. */
export function createNativeClaudeModelRules() {
const ids = new Map<string, string>()
const installProviderModels = (ctx: Pick<Plugin.Context, 'provider'>) =>
ctx.provider.transform((editor) => {
const provider = editor.get('anthropic')
if (!provider) return
const additions = nativeCatalogueModels().filter(
(model) => !provider.models.has(String(model.id)),
)
if (!additions.length) return
// Adding a catalogue row does not take over requests: a stock API key or
// OpenCode's own Anthropic login still sends through OpenCode's
// transport. Only the plugin's placeholder login (the non-secret
// connection the plugin's sign-in methods write) routes requests to this
// plugin's accounts.
editor.models.set('anthropic', [
...provider.models.values(),
...additions,
])
})
const install = (ctx: Pick<Plugin.Context, 'model'>) =>
ctx.model.transform((editor) => {
ids.clear()
Expand All @@ -17,18 +106,13 @@ export function createNativeClaudeModelRules() {
(variant) =>
!efforts.some((effort) => effort === String(variant.id)),
)
model.variants = [
...retained,
...efforts.map((effort) => ({
id: effort as Model.VariantID,
settings: {
effort,
thinking: { type: 'adaptive', display: 'summarized' },
},
})),
]
model.variants = [...retained, ...effortVariants(efforts)]
})
}
})
return { install, modelId: (id: string) => ids.get(id) ?? id }
return {
install,
installProviderModels,
modelId: (id: string) => ids.get(id) ?? id,
}
}
1 change: 1 addition & 0 deletions packages/opencode/src/v2/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,7 @@ export async function setupAnthropicAuth(
}
try {
registrations.push(await wire.install(ctx))
registrations.push(await models.installProviderModels(ctx))
registrations.push(await models.install(ctx))
registrations.push(
await registerOpenCode2AuthMethods(
Expand Down
Loading