diff --git a/CHANGELOG.md b/CHANGELOG.md index 26a48228..9d49b9eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/opencode/src/tests/opencode2-cachekeep.test.ts b/packages/opencode/src/tests/opencode2-cachekeep.test.ts index 69ca7cb7..f73b7b4b 100644 --- a/packages/opencode/src/tests/opencode2-cachekeep.test.ts +++ b/packages/opencode/src/tests/opencode2-cachekeep.test.ts @@ -325,6 +325,7 @@ async function fixture( }, }, model: { transform: async () => registration }, + provider: { transform: async () => registration }, integration: { transform: async () => { logger.debug('startup-test', 'registering integration') diff --git a/packages/opencode/src/tests/opencode2-models.test.ts b/packages/opencode/src/tests/opencode2-models.test.ts index 9bc47105..f4a35e7d 100644 --- a/packages/opencode/src/tests/opencode2-models.test.ts +++ b/packages/opencode/src/tests/opencode2-models.test.ts @@ -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) { + 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[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) => { diff --git a/packages/opencode/src/tests/opencode2-quota-shutdown.test.ts b/packages/opencode/src/tests/opencode2-quota-shutdown.test.ts index 0422ac41..38aa6848 100644 --- a/packages/opencode/src/tests/opencode2-quota-shutdown.test.ts +++ b/packages/opencode/src/tests/opencode2-quota-shutdown.test.ts @@ -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 () => ({ diff --git a/packages/opencode/src/tests/state-layout-host-wiring.test.ts b/packages/opencode/src/tests/state-layout-host-wiring.test.ts index 6e4ae5fd..ceb1457f 100644 --- a/packages/opencode/src/tests/state-layout-host-wiring.test.ts +++ b/packages/opencode/src/tests/state-layout-host-wiring.test.ts @@ -136,6 +136,7 @@ function openCode2Context() { hook: async () => registration, }, model: { transform: async () => registration }, + provider: { transform: async () => registration }, integration: { transform: async () => registration }, rpc: { register: async () => ({ diff --git a/packages/opencode/src/v2/models.ts b/packages/opencode/src/v2/models.ts index d37b4aa4..3c2cc68f 100644 --- a/packages/opencode/src/v2/models.ts +++ b/packages/opencode/src/v2/models.ts @@ -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() + const installProviderModels = (ctx: Pick) => + 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) => ctx.model.transform((editor) => { ids.clear() @@ -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, + } } diff --git a/packages/opencode/src/v2/setup.ts b/packages/opencode/src/v2/setup.ts index dda2840c..21cf3144 100644 --- a/packages/opencode/src/v2/setup.ts +++ b/packages/opencode/src/v2/setup.ts @@ -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(