From 53c3c69830561b9fdc5bcaf52fa07acd66c2afb8 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 19 Aug 2026 23:15:42 -0500 Subject: [PATCH 1/5] feat(core): add configured model variant fallbacks --- packages/core/src/config/plugin/provider.ts | 20 +++ packages/core/src/plugin/variant.ts | 154 ++++++++++++++++- packages/core/test/config/plugin.test.ts | 29 ++++ packages/core/test/config/provider.test.ts | 173 +++++++++++++++++++- packages/core/test/model-resolver.test.ts | 42 +++-- packages/core/test/plugin/variant.test.ts | 130 ++++++++++++++- 6 files changed, 534 insertions(+), 14 deletions(-) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 6d89cb6b7a76..6e1585af411a 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -6,6 +6,7 @@ import { Money } from "@opencode-ai/schema/money" import { Effect, Stream } from "effect" import { Config } from "../../config.js" import { Provider } from "../../provider.js" +import { VariantPlugin } from "../../plugin/variant.js" export const Plugin = define({ id: "opencode.config.provider", @@ -34,6 +35,8 @@ export const Plugin = define({ }) yield* ctx.catalog.transform((catalog) => { + const fallback = new Map() + const explicit = new Map() const configuredDefault = Config.latest(loaded.entries, "model") if (configuredDefault !== undefined) catalog.model.default.set(configuredDefault.providerID, configuredDefault.model) @@ -48,6 +51,13 @@ export const Plugin = define({ if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body) }) for (const [id, config] of Object.entries(item.models ?? {})) { + const key = `${providerID}\0${id}` + if (!catalog.model.get(providerID, id) && config.variants === undefined) + fallback.set(key, { providerID, modelID: id }) + if (config.variants !== undefined) { + fallback.delete(key) + explicit.set(key, { providerID, modelID: id }) + } catalog.model.update(providerID, id, (model) => { if (config.family !== undefined) model.family = config.family if (config.name !== undefined) model.name = config.name @@ -67,6 +77,7 @@ export const Plugin = define({ } if (config.variants !== undefined) { model.variants ??= [] + if (config.variants.length === 0) model.variants = [] for (const variant of config.variants) { let existing = model.variants.find((item) => item.id === variant.id) if (!existing) { @@ -96,6 +107,15 @@ export const Plugin = define({ }) } } + for (const item of fallback.values()) { + const model = catalog.model.get(item.providerID, item.modelID) + if (!model || model.variants.length > 0) continue + VariantPlugin.markFallback(model) + } + for (const item of explicit.values()) { + const model = catalog.model.get(item.providerID, item.modelID) + if (model) VariantPlugin.suppressFallback(model) + } }) yield* ctx.event.subscribe().pipe( Stream.filter((event) => event.type === "config.updated"), diff --git a/packages/core/src/plugin/variant.ts b/packages/core/src/plugin/variant.ts index 7d2fe6740144..5a9af1a67c2e 100644 --- a/packages/core/src/plugin/variant.ts +++ b/packages/core/src/plugin/variant.ts @@ -12,7 +12,8 @@ export const Plugin = define({ for (const record of catalog.provider.list()) { for (const model of record.models.values()) { catalog.model.update(model.providerID, model.id, (draft) => { - const generated = generate(draft, record.provider) + if (suppressed.has(draft)) return + const generated = fallbacks.has(draft) ? fallback(draft, record.provider) : generate(draft, record.provider) if (generated.length === 0) return const variants = draft.variants ?? [] @@ -42,3 +43,154 @@ export function generate( settings: { reasoningEffort: id }, })) } + +const OPENAI_EFFORTS = ["none", "low", "medium", "high", "xhigh", "max"] +const COMMON_EFFORTS = ["low", "medium", "high"] +const ENCRYPTED_REASONING = ["reasoning.encrypted_content"] +// Config runs immediately before this plugin over the same materialized model objects. +// Weak markers retain omitted versus explicit empty variants without exposing provenance publicly. +const fallbacks = new WeakSet() +const suppressed = new WeakSet() + +export function markFallback(model: object) { + suppressed.delete(model) + fallbacks.add(model) +} + +export function suppressFallback(model: object) { + fallbacks.delete(model) + suppressed.add(model) +} + +export function fallback( + model: { + readonly modelID: string + readonly package?: string + readonly settings?: Readonly> + readonly limit: { readonly output: number } + }, + provider?: { readonly package: string }, +): NonNullable { + const packageName = model.package ?? provider?.package + if (openAIResponses(packageName, model.settings)) + return OPENAI_EFFORTS.map((id) => ({ + id: Model.VariantID.make(id), + settings: settings(packageName, { + reasoningEffort: id, + reasoningSummary: "auto", + include: ENCRYPTED_REASONING, + }), + })) + if (openAIChat(packageName, model.settings)) return efforts(packageName, COMMON_EFFORTS) + if (google(packageName)) return googleVariants(packageName, model.modelID, model.limit.output) + if (anthropic(packageName)) return anthropicVariants(packageName, model.modelID, model.limit.output) + return [] +} + +function openAIResponses(packageName: string | undefined, settings: Readonly> | undefined) { + if (Provider.isAISDK(packageName)) + return ( + Provider.packageName(packageName) === "@ai-sdk/openai" || + (Provider.packageName(packageName) === "@ai-sdk/azure" && settings?.useCompletionUrls !== true) + ) + return [ + "@opencode-ai/ai/providers/openai", + "@opencode-ai/ai/providers/openai/responses", + "@opencode-ai/ai/providers/azure", + "@opencode-ai/ai/providers/azure/responses", + "@opencode-ai/ai/providers/google-vertex/responses", + ].includes(packageName ?? "") +} + +function openAIChat(packageName: string | undefined, settings: Readonly> | undefined) { + if (Provider.isAISDK(packageName)) + return ( + Provider.packageName(packageName) === "@ai-sdk/openai-compatible" || + (Provider.packageName(packageName) === "@ai-sdk/azure" && settings?.useCompletionUrls === true) + ) + return [ + "@opencode-ai/ai/providers/openai/chat", + "@opencode-ai/ai/providers/openai-compatible", + "@opencode-ai/ai/providers/azure/chat", + "@opencode-ai/ai/providers/google-vertex/chat", + ].includes(packageName ?? "") +} + +function google(packageName: string | undefined) { + if (Provider.isAISDK(packageName)) + return ["@ai-sdk/google", "@ai-sdk/google-vertex"].includes(Provider.packageName(packageName)) + return [ + "@opencode-ai/ai/providers/google", + "@opencode-ai/ai/providers/google-vertex", + "@opencode-ai/ai/providers/google-vertex/gemini", + ].includes(packageName ?? "") +} + +function anthropic(packageName: string | undefined) { + if (Provider.isAISDK(packageName)) + return ["@ai-sdk/anthropic", "@ai-sdk/google-vertex/anthropic"].includes(Provider.packageName(packageName)) + return [ + "@opencode-ai/ai/providers/anthropic", + "@opencode-ai/ai/providers/anthropic-compatible", + "@opencode-ai/ai/providers/google-vertex/messages", + ].includes(packageName ?? "") +} + +function settings(packageName: string | undefined, value: Readonly>) { + return Provider.isAISDK(packageName) ? value : { providerOptions: value } +} + +function efforts(packageName: string | undefined, ids: readonly string[]) { + return ids.map((id) => ({ id: Model.VariantID.make(id), settings: settings(packageName, { reasoningEffort: id }) })) +} + +function googleVariants( + packageName: string | undefined, + modelID: string, + output: number, +): NonNullable { + const id = modelID.toLowerCase() + if (!id.includes("2.5")) + return COMMON_EFFORTS.map((effort) => ({ + id: Model.VariantID.make(effort), + settings: settings(packageName, { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }), + })) + const variants = [ + { id: "high", budget: 16_000 }, + { id: "max", budget: id.includes("pro") && !id.includes("flash") ? 32_768 : 24_576 }, + ] + const maximum = output - 1 + if (maximum <= 0) return [] + return variants.map((item) => ({ + id: Model.VariantID.make(item.id), + settings: settings(packageName, { + thinkingConfig: { includeThoughts: true, thinkingBudget: Math.min(item.budget, maximum) }, + }), + })) +} + +function anthropicVariants( + packageName: string | undefined, + modelID: string, + output: number, +): NonNullable { + const version = /claude-(?:[a-z]+-)?(\d+)(?:[.-](\d{1,2}))?(?:[.@-]|$)/i.exec(modelID) + const major = Number(version?.[1] ?? 5) + const minor = Number(version?.[2] ?? 0) + if (major > 4 || (major === 4 && minor >= 6)) { + const ids = major > 4 || minor >= 7 ? [...COMMON_EFFORTS, "xhigh", "max"] : [...COMMON_EFFORTS, "max"] + return ids.map((id) => ({ + id: Model.VariantID.make(id), + settings: settings(packageName, { thinking: { type: "adaptive", display: "summarized" }, effort: id }), + })) + } + const maximum = Math.min(31_999, output - 1) + if (maximum <= 0) return [] + return [ + { id: "high", budget: Math.min(16_000, maximum) }, + { id: "max", budget: maximum }, + ].map((item) => ({ + id: Model.VariantID.make(item.id), + settings: settings(packageName, { thinking: { type: "enabled", budgetTokens: item.budget } }), + })) +} diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index 34184bccd791..169f971af7fe 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -399,10 +399,38 @@ describe("PluginSupervisor config", () => { }), ) + it.live("lets an explicit empty config array clear generated variants", () => + withLocation( + { + plugins: [path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts")], + providers: { + configured: { + models: { + "glm-5.2": { variants: [] }, + }, + }, + }, + }, + Effect.gen(function* () { + yield* ready() + const catalog = yield* Catalog.Service + expect((yield* catalog.model.get(Provider.ID.make("configured"), Model.ID.make("glm-5.2")))?.variants).toEqual( + [], + ) + }), + ), + ) + it.live("allows variant generation to be disabled", () => withLocation( { plugins: [path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts"), "-opencode.variant"], + providers: { + custom: { + package: "aisdk:@ai-sdk/openai", + models: { reasoner: {} }, + }, + }, }, Effect.gen(function* () { yield* ready() @@ -413,6 +441,7 @@ describe("PluginSupervisor config", () => { expect((yield* catalog.model.get(Provider.ID.make("configured"), Model.ID.make("glm-5.2")))?.variants).toEqual([ expect.objectContaining({ id: "high", headers: { custom: "true" } }), ]) + expect((yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("reasoner")))?.variants).toEqual([]) }), ), ) diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index b06dccc75b7b..6925486a56c9 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -9,16 +9,18 @@ import { Integration } from "@opencode-ai/core/integration" import { Model } from "@opencode-ai/core/model" import { Plugin } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" +import { VariantPlugin } from "@opencode-ai/core/plugin/variant" import { Provider } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "../plugin/fixture" const it = testEffect(PluginTestLayer) -const addPlugin = Effect.fn(function* (entries: Entry[]) { +const addPlugin = Effect.fn(function* (entries: Entry[], variants = false) { const plugin = yield* Plugin.Service const host = yield* PluginHost.make(plugin) yield* ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provide(Config.testLayer(entries))) + if (variants) yield* VariantPlugin.Plugin.effect(host) }) function required(value: T | undefined): T { @@ -104,6 +106,175 @@ describe("ConfigProviderPlugin.Plugin", () => { }), ) + it.effect("adds fallback variants to new configured models when variants are omitted", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = Provider.ID.make("custom") + const modelID = Model.ID.make("gpt-next") + const entries = [ + new Document({ + type: "document", + info: decode({ + providers: { + custom: { + package: "aisdk:@ai-sdk/openai", + models: { "gpt-next": {} }, + }, + }, + }), + }), + ] + + yield* addPlugin(entries, true) + + const variants = required(yield* catalog.model.get(providerID, modelID)).variants + expect(variants.map((variant) => String(variant.id))).toEqual(["none", "low", "medium", "high", "xhigh", "max"]) + expect(variants[3]?.settings).toEqual({ + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }) + }), + ) + + it.effect("keeps explicit empty and custom configured variants", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = Provider.ID.make("custom") + const entries = [ + new Document({ + type: "document", + info: decode({ + providers: { + custom: { + package: "aisdk:@ai-sdk/openai", + models: { + disabled: { variants: [] }, + explicit: { variants: [{ id: "deep", settings: { reasoningEffort: "max" } }] }, + }, + }, + }, + }), + }), + ] + + yield* addPlugin(entries, true) + + expect((yield* catalog.model.get(providerID, Model.ID.make("disabled")))?.variants).toEqual([]) + expect( + (yield* catalog.model.get(providerID, Model.ID.make("explicit")))?.variants.map((variant) => ({ + ...variant, + id: String(variant.id), + })), + ).toEqual([{ id: "deep", settings: { reasoningEffort: "max" } }]) + }), + ) + + it.effect("does not add config fallbacks to existing catalog models", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = Provider.ID.make("custom") + const modelID = Model.ID.make("known") + yield* catalog.transform((draft) => { + draft.model.update(providerID, modelID, () => {}) + }) + const entries = [ + new Document({ + type: "document", + info: decode({ + providers: { + custom: { + package: "aisdk:@ai-sdk/openai", + models: { known: { name: "Known" } }, + }, + }, + }), + }), + ] + + yield* addPlugin(entries, true) + + expect((yield* catalog.model.get(providerID, modelID))?.variants).toEqual([]) + }), + ) + + it.effect("lets an explicit empty array clear inherited variants", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = Provider.ID.make("custom") + const modelID = Model.ID.make("known") + yield* catalog.transform((draft) => { + draft.model.update(providerID, modelID, (model) => { + model.variants = [{ id: Model.VariantID.make("high"), settings: { reasoningEffort: "high" } }] + }) + }) + const entries = [ + new Document({ + type: "document", + info: decode({ + providers: { + custom: { + package: "aisdk:@ai-sdk/openai", + models: { known: { variants: [] } }, + }, + }, + }), + }), + ] + + yield* addPlugin(entries, true) + + expect((yield* catalog.model.get(providerID, modelID))?.variants).toEqual([]) + }), + ) + + it.effect("respects layered variant intent and the final package flavor", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = Provider.ID.make("custom") + const entries = [ + new Document({ + type: "document", + info: decode({ + providers: { + custom: { + package: "aisdk:@ai-sdk/openai-compatible", + models: { + cleared: {}, + disabled: { variants: [] }, + flavor: {}, + }, + }, + }, + }), + }), + new Document({ + type: "document", + info: decode({ + providers: { + custom: { + package: "aisdk:@ai-sdk/openai", + models: { + cleared: { variants: [] }, + disabled: { name: "Disabled" }, + flavor: { name: "OpenAI" }, + }, + }, + }, + }), + }), + ] + + yield* addPlugin(entries, true) + + expect((yield* catalog.model.get(providerID, Model.ID.make("cleared")))?.variants).toEqual([]) + expect((yield* catalog.model.get(providerID, Model.ID.make("disabled")))?.variants).toEqual([]) + expect( + (yield* catalog.model.get(providerID, Model.ID.make("flavor")))?.variants.map((variant) => String(variant.id)), + ).toEqual(["none", "low", "medium", "high", "xhigh", "max"]) + }), + ) + it.effect("preserves catalog capabilities unless config overrides them", () => Effect.gen(function* () { const catalog = yield* Catalog.Service diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index 7d7ee3bafeaa..634724a17edc 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -9,6 +9,7 @@ import { Integration } from "@opencode-ai/core/integration" import { Compatibility, ID, Info, VariantID } from "@opencode-ai/core/model" import { Provider } from "@opencode-ai/core/provider" import { ModelResolver } from "@opencode-ai/core/model-resolver" +import { VariantPlugin } from "@opencode-ai/core/plugin/variant" import { Catalog } from "@opencode-ai/core/catalog" import { AISDK } from "@opencode-ai/core/aisdk" import { Npm } from "@opencode-ai/util/npm" @@ -509,6 +510,34 @@ describe("ModelResolver", () => { }), ) + it.effect("applies native OpenAI fallback settings to Responses requests", () => + Effect.gen(function* () { + const packageName = "@opencode-ai/ai/providers/openai" + const base = model(packageName, { modelID: "gpt-next", limit: { context: 100, output: 32_000 } }) + const catalog = model(packageName, { + modelID: "gpt-next", + limit: { context: 100, output: 32_000 }, + variants: VariantPlugin.fallback(base), + }) + const resolved = yield* ModelResolver.resolveModel( + catalog, + VariantID.make("high"), + Credential.Key.make({ type: "key", key: "secret" }), + ) + + expect(resolved.route.defaults.providerOptions).toMatchObject({ + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }) + const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" })) + expect(prepared.body).toMatchObject({ + include: ["reasoning.encrypted_content"], + reasoning: { effort: "high", summary: "auto" }, + }) + }), + ) + it.effect("overlays selected OpenAI-compatible variant bodies", () => Effect.gen(function* () { const catalog = model(Provider.aisdk("@ai-sdk/openai-compatible"), { @@ -885,12 +914,7 @@ describe("ModelResolver", () => { { reasoning: { effort: "high" } }, { reasoning: { effort: "high" } }, ], - [ - "@ai-sdk/xai", - "@opencode-ai/ai/providers/xai", - { reasoningEffort: "high" }, - { reasoningEffort: "high" }, - ], + ["@ai-sdk/xai", "@opencode-ai/ai/providers/xai", { reasoningEffort: "high" }, { reasoningEffort: "high" }], ] as const yield* Effect.forEach(packages, ([catalogPackage, nativePackage, sourceOptions, providerOptions]) => @@ -940,11 +964,7 @@ describe("ModelResolver", () => { ["@ai-sdk/azure", "@opencode-ai/ai/providers/azure/responses", "api-model"], ["@ai-sdk/google", "@opencode-ai/ai/providers/google", "api-model"], ["@ai-sdk/google-vertex", "@opencode-ai/ai/providers/google-vertex", "api-model"], - [ - "@ai-sdk/google-vertex/anthropic", - "@opencode-ai/ai/providers/google-vertex/messages", - "claude-sonnet-4-6", - ], + ["@ai-sdk/google-vertex/anthropic", "@opencode-ai/ai/providers/google-vertex/messages", "claude-sonnet-4-6"], ["@ai-sdk/openai", "@opencode-ai/ai/providers/openai", "api-model"], ["@ai-sdk/openai-compatible", "@opencode-ai/ai/providers/openai-compatible", "api-model"], ["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "api-model"], diff --git a/packages/core/test/plugin/variant.test.ts b/packages/core/test/plugin/variant.test.ts index 1df1082fb663..e739102aa025 100644 --- a/packages/core/test/plugin/variant.test.ts +++ b/packages/core/test/plugin/variant.test.ts @@ -1,4 +1,4 @@ -import { describe, expect } from "bun:test" +import { describe, expect, test } from "bun:test" import { Catalog } from "@opencode-ai/core/catalog" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" @@ -59,3 +59,131 @@ describe("VariantPlugin", () => { }), ) }) + +describe("VariantPlugin.fallback", () => { + const model = ( + modelID: string, + packageName: string, + output = 32_000, + settings?: Readonly>, + ) => ({ + modelID, + package: packageName, + settings, + limit: { output }, + }) + const plain = (variants: Model.Info["variants"]) => + variants.map((variant) => ({ ...variant, id: String(variant.id) })) + const settings = (packageName: string, value: Readonly>) => + Provider.isAISDK(packageName) ? value : { providerOptions: value } + + test.each([ + Provider.aisdk("@ai-sdk/openai"), + Provider.aisdk("@ai-sdk/azure"), + "@opencode-ai/ai/providers/openai", + "@opencode-ai/ai/providers/openai/responses", + "@opencode-ai/ai/providers/azure", + "@opencode-ai/ai/providers/azure/responses", + "@opencode-ai/ai/providers/google-vertex/responses", + ])("adds OpenAI Responses variants for %s", (packageName) => { + const variants = VariantPlugin.fallback(model("gpt-next", packageName)) + + expect(variants.map((variant) => String(variant.id))).toEqual(["none", "low", "medium", "high", "xhigh", "max"]) + expect(variants[0]?.settings).toEqual( + settings(packageName, { + reasoningEffort: "none", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }), + ) + }) + + test.each([ + Provider.aisdk("@ai-sdk/openai-compatible"), + "@opencode-ai/ai/providers/openai/chat", + "@opencode-ai/ai/providers/openai-compatible", + "@opencode-ai/ai/providers/azure/chat", + "@opencode-ai/ai/providers/google-vertex/chat", + ])("adds conservative chat variants for %s", (packageName) => { + expect(plain(VariantPlugin.fallback(model("reasoner", packageName)))).toEqual([ + { id: "low", settings: settings(packageName, { reasoningEffort: "low" }) }, + { id: "medium", settings: settings(packageName, { reasoningEffort: "medium" }) }, + { id: "high", settings: settings(packageName, { reasoningEffort: "high" }) }, + ]) + }) + + test("uses chat fallbacks for AI SDK Azure completion URLs", () => { + const variants = VariantPlugin.fallback( + model("deployment", Provider.aisdk("@ai-sdk/azure"), 32_000, { useCompletionUrls: true }), + ) + + expect(plain(variants)).toEqual([ + { id: "low", settings: { reasoningEffort: "low" } }, + { id: "medium", settings: { reasoningEffort: "medium" } }, + { id: "high", settings: { reasoningEffort: "high" } }, + ]) + }) + + test.each([ + Provider.aisdk("@ai-sdk/google"), + Provider.aisdk("@ai-sdk/google-vertex"), + "@opencode-ai/ai/providers/google", + "@opencode-ai/ai/providers/google-vertex", + "@opencode-ai/ai/providers/google-vertex/gemini", + ])("adds Google level and legacy budget variants for %s", (packageName) => { + expect(plain(VariantPlugin.fallback(model("gemini-next", packageName)))).toEqual([ + { + id: "low", + settings: settings(packageName, { thinkingConfig: { includeThoughts: true, thinkingLevel: "low" } }), + }, + { + id: "medium", + settings: settings(packageName, { thinkingConfig: { includeThoughts: true, thinkingLevel: "medium" } }), + }, + { + id: "high", + settings: settings(packageName, { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }), + }, + ]) + expect(plain(VariantPlugin.fallback(model("gemini-2.5-pro", packageName, 64_000)))).toEqual([ + { + id: "high", + settings: settings(packageName, { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }), + }, + { + id: "max", + settings: settings(packageName, { thinkingConfig: { includeThoughts: true, thinkingBudget: 32_768 } }), + }, + ]) + }) + + test.each([ + Provider.aisdk("@ai-sdk/anthropic"), + Provider.aisdk("@ai-sdk/google-vertex/anthropic"), + "@opencode-ai/ai/providers/anthropic", + "@opencode-ai/ai/providers/anthropic-compatible", + "@opencode-ai/ai/providers/google-vertex/messages", + ])("adds Anthropic adaptive and legacy budget variants for %s", (packageName) => { + expect(VariantPlugin.fallback(model("claude-opus-4-7", packageName)).map((variant) => String(variant.id))).toEqual([ + "low", + "medium", + "high", + "xhigh", + "max", + ]) + expect(VariantPlugin.fallback(model("claude-opus-4-7", packageName))[0]?.settings).toEqual( + settings(packageName, { + thinking: { type: "adaptive", display: "summarized" }, + effort: "low", + }), + ) + expect(plain(VariantPlugin.fallback(model("claude-haiku-4-5", packageName, 20_000)))).toEqual([ + { id: "high", settings: settings(packageName, { thinking: { type: "enabled", budgetTokens: 16_000 } }) }, + { id: "max", settings: settings(packageName, { thinking: { type: "enabled", budgetTokens: 19_999 } }) }, + ]) + }) + + test("does not add fallbacks for unknown packages", () => { + expect(VariantPlugin.fallback(model("reasoner", "custom"))).toEqual([]) + }) +}) From bb86683dab8a990b6a2d62b07b8820e7cd380801 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 20 Aug 2026 01:24:12 -0500 Subject: [PATCH 2/5] fix(core): constrain legacy thinking fallbacks --- packages/core/src/plugin/variant.ts | 29 ++++++++++++++++------- packages/core/test/plugin/variant.test.ts | 22 +++++++++++++++++ 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/packages/core/src/plugin/variant.ts b/packages/core/src/plugin/variant.ts index 5a9af1a67c2e..c4524c4f4161 100644 --- a/packages/core/src/plugin/variant.ts +++ b/packages/core/src/plugin/variant.ts @@ -47,6 +47,7 @@ export function generate( const OPENAI_EFFORTS = ["none", "low", "medium", "high", "xhigh", "max"] const COMMON_EFFORTS = ["low", "medium", "high"] const ENCRYPTED_REASONING = ["reasoning.encrypted_content"] +const CLAUDE_MANUAL_THINKING_MAX = { haiku: [4, 5], sonnet: [4, 5], opus: [4, 5] } as const // Config runs immediately before this plugin over the same materialized model objects. // Weak markers retain omitted versus explicit empty variants without exposing provenance publicly. const fallbacks = new WeakSet() @@ -149,15 +150,14 @@ function googleVariants( modelID: string, output: number, ): NonNullable { - const id = modelID.toLowerCase() - if (!id.includes("2.5")) + if (!/(?:^|[/.:_-])gemini-2[.-]5(?:[/.:_-]|$)/i.test(modelID)) return COMMON_EFFORTS.map((effort) => ({ id: Model.VariantID.make(effort), settings: settings(packageName, { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }), })) const variants = [ { id: "high", budget: 16_000 }, - { id: "max", budget: id.includes("pro") && !id.includes("flash") ? 32_768 : 24_576 }, + { id: "max", budget: /(?:^|[/.:_-])pro(?:[/.:_-]|$)/i.test(modelID) ? 32_768 : 24_576 }, ] const maximum = output - 1 if (maximum <= 0) return [] @@ -174,11 +174,12 @@ function anthropicVariants( modelID: string, output: number, ): NonNullable { - const version = /claude-(?:[a-z]+-)?(\d+)(?:[.-](\d{1,2}))?(?:[.@-]|$)/i.exec(modelID) - const major = Number(version?.[1] ?? 5) - const minor = Number(version?.[2] ?? 0) - if (major > 4 || (major === 4 && minor >= 6)) { - const ids = major > 4 || minor >= 7 ? [...COMMON_EFFORTS, "xhigh", "max"] : [...COMMON_EFFORTS, "max"] + const model = claudeModel(modelID) + const version = model && CLAUDE_MANUAL_THINKING_MAX[model.family] + const manual = version && (model.major < version[0] || (model.major === version[0] && model.minor <= version[1])) + if (!manual) { + const ids = + !model || model.major > 4 || model.minor >= 7 ? [...COMMON_EFFORTS, "xhigh", "max"] : [...COMMON_EFFORTS, "max"] return ids.map((id) => ({ id: Model.VariantID.make(id), settings: settings(packageName, { thinking: { type: "adaptive", display: "summarized" }, effort: id }), @@ -194,3 +195,15 @@ function anthropicVariants( settings: settings(packageName, { thinking: { type: "enabled", budgetTokens: item.budget } }), })) } + +function claudeModel(modelID: string) { + const familyFirst = /(?:^|[/.:_-])(opus|sonnet|haiku)-([1-9]\d*)(?:[.-](\d{1,2}))?(?:[/.:_-]|$)/i.exec(modelID) + const versionFirst = /(?:^|[/.:_-])claude-([1-9]\d*)(?:[.-](\d{1,2}))?-(opus|sonnet|haiku)(?:[/.:_-]|$)/i.exec( + modelID, + ) + const family = (["haiku", "sonnet", "opus"] as const).find((item) => item === (familyFirst?.[1] ?? versionFirst?.[3])) + const major = Number(familyFirst?.[2] ?? versionFirst?.[1]) + const minor = Number(familyFirst?.[3] ?? versionFirst?.[2] ?? 0) + if (!family || !Number.isFinite(major) || !Number.isFinite(minor)) return + return { family, major, minor } +} diff --git a/packages/core/test/plugin/variant.test.ts b/packages/core/test/plugin/variant.test.ts index e739102aa025..68b166100f89 100644 --- a/packages/core/test/plugin/variant.test.ts +++ b/packages/core/test/plugin/variant.test.ts @@ -155,6 +155,11 @@ describe("VariantPlugin.fallback", () => { settings: settings(packageName, { thinkingConfig: { includeThoughts: true, thinkingBudget: 32_768 } }), }, ]) + expect(VariantPlugin.fallback(model("gemini-12.5-pro", packageName)).map((variant) => String(variant.id))).toEqual([ + "low", + "medium", + "high", + ]) }) test.each([ @@ -181,6 +186,23 @@ describe("VariantPlugin.fallback", () => { { id: "high", settings: settings(packageName, { thinking: { type: "enabled", budgetTokens: 16_000 } }) }, { id: "max", settings: settings(packageName, { thinking: { type: "enabled", budgetTokens: 19_999 } }) }, ]) + for (const family of ["haiku", "sonnet", "opus"]) { + expect(VariantPlugin.fallback(model(`claude-${family}-4-5`, packageName))[0]?.settings).toEqual( + settings(packageName, { thinking: { type: "enabled", budgetTokens: 16_000 } }), + ) + expect(VariantPlugin.fallback(model(`claude-${family}-4-6`, packageName))[0]?.settings).toEqual( + settings(packageName, { + thinking: { type: "adaptive", display: "summarized" }, + effort: "low", + }), + ) + } + expect(VariantPlugin.fallback(model("claude-mythos-4-5", packageName))[0]?.settings).toEqual( + settings(packageName, { + thinking: { type: "adaptive", display: "summarized" }, + effort: "low", + }), + ) }) test("does not add fallbacks for unknown packages", () => { From 9426706dfa1dd057922319584356c080937d80b5 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 20 Aug 2026 12:58:52 -0500 Subject: [PATCH 3/5] refactor(core): default to modern thinking variants --- packages/core/src/plugin/variant.ts | 58 ++++++++++++++--------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/packages/core/src/plugin/variant.ts b/packages/core/src/plugin/variant.ts index c4524c4f4161..f20f8d19b272 100644 --- a/packages/core/src/plugin/variant.ts +++ b/packages/core/src/plugin/variant.ts @@ -150,22 +150,22 @@ function googleVariants( modelID: string, output: number, ): NonNullable { - if (!/(?:^|[/.:_-])gemini-2[.-]5(?:[/.:_-]|$)/i.test(modelID)) - return COMMON_EFFORTS.map((effort) => ({ - id: Model.VariantID.make(effort), - settings: settings(packageName, { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }), + if (/(?:^|[/.:_-])gemini-2[.-]5(?:[/.:_-]|$)/i.test(modelID)) { + const maximum = output - 1 + if (maximum <= 0) return [] + return [ + { id: "high", budget: 16_000 }, + { id: "max", budget: /(?:^|[/.:_-])pro(?:[/.:_-]|$)/i.test(modelID) ? 32_768 : 24_576 }, + ].map((item) => ({ + id: Model.VariantID.make(item.id), + settings: settings(packageName, { + thinkingConfig: { includeThoughts: true, thinkingBudget: Math.min(item.budget, maximum) }, + }), })) - const variants = [ - { id: "high", budget: 16_000 }, - { id: "max", budget: /(?:^|[/.:_-])pro(?:[/.:_-]|$)/i.test(modelID) ? 32_768 : 24_576 }, - ] - const maximum = output - 1 - if (maximum <= 0) return [] - return variants.map((item) => ({ - id: Model.VariantID.make(item.id), - settings: settings(packageName, { - thinkingConfig: { includeThoughts: true, thinkingBudget: Math.min(item.budget, maximum) }, - }), + } + return COMMON_EFFORTS.map((effort) => ({ + id: Model.VariantID.make(effort), + settings: settings(packageName, { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }), })) } @@ -177,22 +177,22 @@ function anthropicVariants( const model = claudeModel(modelID) const version = model && CLAUDE_MANUAL_THINKING_MAX[model.family] const manual = version && (model.major < version[0] || (model.major === version[0] && model.minor <= version[1])) - if (!manual) { - const ids = - !model || model.major > 4 || model.minor >= 7 ? [...COMMON_EFFORTS, "xhigh", "max"] : [...COMMON_EFFORTS, "max"] - return ids.map((id) => ({ - id: Model.VariantID.make(id), - settings: settings(packageName, { thinking: { type: "adaptive", display: "summarized" }, effort: id }), + if (manual) { + const maximum = Math.min(31_999, output - 1) + if (maximum <= 0) return [] + return [ + { id: "high", budget: Math.min(16_000, maximum) }, + { id: "max", budget: maximum }, + ].map((item) => ({ + id: Model.VariantID.make(item.id), + settings: settings(packageName, { thinking: { type: "enabled", budgetTokens: item.budget } }), })) } - const maximum = Math.min(31_999, output - 1) - if (maximum <= 0) return [] - return [ - { id: "high", budget: Math.min(16_000, maximum) }, - { id: "max", budget: maximum }, - ].map((item) => ({ - id: Model.VariantID.make(item.id), - settings: settings(packageName, { thinking: { type: "enabled", budgetTokens: item.budget } }), + const ids = + !model || model.major > 4 || model.minor >= 7 ? [...COMMON_EFFORTS, "xhigh", "max"] : [...COMMON_EFFORTS, "max"] + return ids.map((id) => ({ + id: Model.VariantID.make(id), + settings: settings(packageName, { thinking: { type: "adaptive", display: "summarized" }, effort: id }), })) } From 90dde9b3f1934857c2753fa88b7b39073289740d Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 20 Aug 2026 13:31:17 -0500 Subject: [PATCH 4/5] refactor(core): store variant intent in catalog --- packages/core/src/catalog.ts | 13 ++++++++++- packages/core/src/config/plugin/provider.ts | 16 +++++++++---- packages/core/src/plugin/variant.ts | 26 +++++++-------------- 3 files changed, 32 insertions(+), 23 deletions(-) diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index fd7485ecc898..74ca9b6d7199 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -20,6 +20,7 @@ export { Event } from "@opencode-ai/schema/catalog" type Data = { providers: Map + variantGeneration: Map defaultModel?: DefaultModel } @@ -34,6 +35,10 @@ export type Draft = { get: (providerID: Provider.ID, modelID: Model.ID) => Model.Info | undefined update: (providerID: Provider.ID, modelID: Model.ID, fn: (model: Model.MutableInfo) => void) => void remove: (providerID: Provider.ID, modelID: Model.ID) => void + variantGeneration: { + get: (providerID: Provider.ID, modelID: Model.ID) => "fallback" | "suppress" | undefined + set: (providerID: Provider.ID, modelID: Model.ID, value: "fallback" | "suppress") => void + } default: { get: () => DefaultModel | undefined set: (providerID: Provider.ID, modelID: Model.ID) => void @@ -83,7 +88,7 @@ const layer = Layer.effect( const state = State.create({ name: "catalog", - initial: () => ({ providers: new Map() }), + initial: () => ({ providers: new Map(), variantGeneration: new Map() }), draft: (draft) => { const result: Draft = { provider: { @@ -124,6 +129,12 @@ const layer = Layer.effect( remove: (providerID, modelID) => { draft.providers.get(providerID)?.models.delete(modelID) }, + variantGeneration: { + get: (providerID, modelID) => draft.variantGeneration.get(`${providerID}\0${modelID}`), + set: (providerID, modelID, value) => { + draft.variantGeneration.set(`${providerID}\0${modelID}`, value) + }, + }, default: { get: () => draft.defaultModel, set: (providerID, modelID) => { diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 6e1585af411a..e9eb6615439f 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -4,15 +4,18 @@ import { define } from "@opencode-ai/plugin/effect/plugin" import { Document, type Entry } from "@opencode-ai/schema/config" import { Money } from "@opencode-ai/schema/money" import { Effect, Stream } from "effect" +import { Catalog } from "../../catalog.js" import { Config } from "../../config.js" +import { Model } from "../../model.js" import { Provider } from "../../provider.js" -import { VariantPlugin } from "../../plugin/variant.js" export const Plugin = define({ id: "opencode.config.provider", effect: Effect.fn(function* (ctx) { + const catalog = yield* Catalog.Service const config = yield* Config.Service const loaded = { entries: yield* config.entries() } + const generation = new Map() yield* ctx.integration.transform((integrations) => { for (const [id, provider] of configuredProviders(loaded.entries)) { const integrationID = id @@ -35,6 +38,7 @@ export const Plugin = define({ }) yield* ctx.catalog.transform((catalog) => { + generation.clear() const fallback = new Map() const explicit = new Map() const configuredDefault = Config.latest(loaded.entries, "model") @@ -110,11 +114,15 @@ export const Plugin = define({ for (const item of fallback.values()) { const model = catalog.model.get(item.providerID, item.modelID) if (!model || model.variants.length > 0) continue - VariantPlugin.markFallback(model) + generation.set(`${item.providerID}\0${item.modelID}`, { ...item, value: "fallback" }) } for (const item of explicit.values()) { - const model = catalog.model.get(item.providerID, item.modelID) - if (model) VariantPlugin.suppressFallback(model) + generation.set(`${item.providerID}\0${item.modelID}`, { ...item, value: "suppress" }) + } + }) + yield* catalog.transform((draft) => { + for (const item of generation.values()) { + draft.model.variantGeneration.set(Provider.ID.make(item.providerID), Model.ID.make(item.modelID), item.value) } }) yield* ctx.event.subscribe().pipe( diff --git a/packages/core/src/plugin/variant.ts b/packages/core/src/plugin/variant.ts index f20f8d19b272..578ef847c312 100644 --- a/packages/core/src/plugin/variant.ts +++ b/packages/core/src/plugin/variant.ts @@ -2,18 +2,22 @@ export * as VariantPlugin from "./variant.js" import { Effect } from "effect" import { define } from "@opencode-ai/plugin/effect/plugin" +import { Catalog } from "../catalog.js" import { Model } from "../model.js" import { Provider } from "../provider.js" export const Plugin = define({ id: "opencode.variant", - effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform((catalog) => { + effect: Effect.fn(function* () { + const catalog = yield* Catalog.Service + yield* catalog.transform((catalog) => { for (const record of catalog.provider.list()) { for (const model of record.models.values()) { catalog.model.update(model.providerID, model.id, (draft) => { - if (suppressed.has(draft)) return - const generated = fallbacks.has(draft) ? fallback(draft, record.provider) : generate(draft, record.provider) + const generation = catalog.model.variantGeneration.get(model.providerID, model.id) + if (generation === "suppress") return + const generated = + generation === "fallback" ? fallback(draft, record.provider) : generate(draft, record.provider) if (generated.length === 0) return const variants = draft.variants ?? [] @@ -48,20 +52,6 @@ const OPENAI_EFFORTS = ["none", "low", "medium", "high", "xhigh", "max"] const COMMON_EFFORTS = ["low", "medium", "high"] const ENCRYPTED_REASONING = ["reasoning.encrypted_content"] const CLAUDE_MANUAL_THINKING_MAX = { haiku: [4, 5], sonnet: [4, 5], opus: [4, 5] } as const -// Config runs immediately before this plugin over the same materialized model objects. -// Weak markers retain omitted versus explicit empty variants without exposing provenance publicly. -const fallbacks = new WeakSet() -const suppressed = new WeakSet() - -export function markFallback(model: object) { - suppressed.delete(model) - fallbacks.add(model) -} - -export function suppressFallback(model: object) { - fallbacks.delete(model) - suppressed.add(model) -} export function fallback( model: { From 11de3e2e8793c4f62635874f5b247a0b35ffc2af Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 20 Aug 2026 14:05:38 -0500 Subject: [PATCH 5/5] refactor(core): keep variant policy in plugin --- packages/core/src/catalog.ts | 13 +--- packages/core/src/config/plugin/provider.ts | 28 -------- packages/core/src/plugin/variant.ts | 74 ++++++++++++++++----- packages/core/test/config/provider.test.ts | 12 ++-- packages/core/test/plugin/variant.test.ts | 9 ++- 5 files changed, 72 insertions(+), 64 deletions(-) diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 74ca9b6d7199..fd7485ecc898 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -20,7 +20,6 @@ export { Event } from "@opencode-ai/schema/catalog" type Data = { providers: Map - variantGeneration: Map defaultModel?: DefaultModel } @@ -35,10 +34,6 @@ export type Draft = { get: (providerID: Provider.ID, modelID: Model.ID) => Model.Info | undefined update: (providerID: Provider.ID, modelID: Model.ID, fn: (model: Model.MutableInfo) => void) => void remove: (providerID: Provider.ID, modelID: Model.ID) => void - variantGeneration: { - get: (providerID: Provider.ID, modelID: Model.ID) => "fallback" | "suppress" | undefined - set: (providerID: Provider.ID, modelID: Model.ID, value: "fallback" | "suppress") => void - } default: { get: () => DefaultModel | undefined set: (providerID: Provider.ID, modelID: Model.ID) => void @@ -88,7 +83,7 @@ const layer = Layer.effect( const state = State.create({ name: "catalog", - initial: () => ({ providers: new Map(), variantGeneration: new Map() }), + initial: () => ({ providers: new Map() }), draft: (draft) => { const result: Draft = { provider: { @@ -129,12 +124,6 @@ const layer = Layer.effect( remove: (providerID, modelID) => { draft.providers.get(providerID)?.models.delete(modelID) }, - variantGeneration: { - get: (providerID, modelID) => draft.variantGeneration.get(`${providerID}\0${modelID}`), - set: (providerID, modelID, value) => { - draft.variantGeneration.set(`${providerID}\0${modelID}`, value) - }, - }, default: { get: () => draft.defaultModel, set: (providerID, modelID) => { diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index e9eb6615439f..6d89cb6b7a76 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -4,18 +4,14 @@ import { define } from "@opencode-ai/plugin/effect/plugin" import { Document, type Entry } from "@opencode-ai/schema/config" import { Money } from "@opencode-ai/schema/money" import { Effect, Stream } from "effect" -import { Catalog } from "../../catalog.js" import { Config } from "../../config.js" -import { Model } from "../../model.js" import { Provider } from "../../provider.js" export const Plugin = define({ id: "opencode.config.provider", effect: Effect.fn(function* (ctx) { - const catalog = yield* Catalog.Service const config = yield* Config.Service const loaded = { entries: yield* config.entries() } - const generation = new Map() yield* ctx.integration.transform((integrations) => { for (const [id, provider] of configuredProviders(loaded.entries)) { const integrationID = id @@ -38,9 +34,6 @@ export const Plugin = define({ }) yield* ctx.catalog.transform((catalog) => { - generation.clear() - const fallback = new Map() - const explicit = new Map() const configuredDefault = Config.latest(loaded.entries, "model") if (configuredDefault !== undefined) catalog.model.default.set(configuredDefault.providerID, configuredDefault.model) @@ -55,13 +48,6 @@ export const Plugin = define({ if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body) }) for (const [id, config] of Object.entries(item.models ?? {})) { - const key = `${providerID}\0${id}` - if (!catalog.model.get(providerID, id) && config.variants === undefined) - fallback.set(key, { providerID, modelID: id }) - if (config.variants !== undefined) { - fallback.delete(key) - explicit.set(key, { providerID, modelID: id }) - } catalog.model.update(providerID, id, (model) => { if (config.family !== undefined) model.family = config.family if (config.name !== undefined) model.name = config.name @@ -81,7 +67,6 @@ export const Plugin = define({ } if (config.variants !== undefined) { model.variants ??= [] - if (config.variants.length === 0) model.variants = [] for (const variant of config.variants) { let existing = model.variants.find((item) => item.id === variant.id) if (!existing) { @@ -111,19 +96,6 @@ export const Plugin = define({ }) } } - for (const item of fallback.values()) { - const model = catalog.model.get(item.providerID, item.modelID) - if (!model || model.variants.length > 0) continue - generation.set(`${item.providerID}\0${item.modelID}`, { ...item, value: "fallback" }) - } - for (const item of explicit.values()) { - generation.set(`${item.providerID}\0${item.modelID}`, { ...item, value: "suppress" }) - } - }) - yield* catalog.transform((draft) => { - for (const item of generation.values()) { - draft.model.variantGeneration.set(Provider.ID.make(item.providerID), Model.ID.make(item.modelID), item.value) - } }) yield* ctx.event.subscribe().pipe( Stream.filter((event) => event.type === "config.updated"), diff --git a/packages/core/src/plugin/variant.ts b/packages/core/src/plugin/variant.ts index 578ef847c312..6a8178b93a8b 100644 --- a/packages/core/src/plugin/variant.ts +++ b/packages/core/src/plugin/variant.ts @@ -1,39 +1,77 @@ export * as VariantPlugin from "./variant.js" -import { Effect } from "effect" +import { type Entry } from "@opencode-ai/schema/config" +import { Effect, Stream } from "effect" import { define } from "@opencode-ai/plugin/effect/plugin" -import { Catalog } from "../catalog.js" +import { Config } from "../config.js" import { Model } from "../model.js" import { Provider } from "../provider.js" export const Plugin = define({ id: "opencode.variant", - effect: Effect.fn(function* () { - const catalog = yield* Catalog.Service - yield* catalog.transform((catalog) => { + effect: Effect.fn(function* (ctx) { + const config = yield* Config.Service + const loaded = { entries: yield* config.entries() } + yield* ctx.catalog.transform((catalog) => { + const configured = configuredModels(loaded.entries) for (const record of catalog.provider.list()) { for (const model of record.models.values()) { catalog.model.update(model.providerID, model.id, (draft) => { - const generation = catalog.model.variantGeneration.get(model.providerID, model.id) - if (generation === "suppress") return - const generated = - generation === "fallback" ? fallback(draft, record.provider) : generate(draft, record.provider) - if (generated.length === 0) return - - const variants = draft.variants ?? [] - const explicit = new Map(variants.map((variant) => [variant.id, variant])) - const generatedIDs = new Set(generated.map((variant) => variant.id)) - draft.variants = [ - ...generated.map((variant) => explicit.get(variant.id) ?? variant), - ...variants.filter((variant) => !generatedIDs.has(variant.id)), - ] + const intent = configured.get(`${model.providerID}\0${model.id}`) + if (intent === "clear") { + draft.variants = [] + return + } + if (intent === "suppress") return + if (intent === "fallback") { + if (draft.variants.length > 0) return + apply(draft, fallback(draft, record.provider)) + return + } + apply(draft, generate(draft, record.provider)) }) } } }) + yield* ctx.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => + config.entries().pipe( + Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))), + Effect.andThen(ctx.catalog.reload()), + ), + ), + Effect.forkScoped({ startImmediately: true }), + ) }), }) +function apply(draft: Model.MutableInfo, generated: NonNullable) { + if (generated.length === 0) return + const variants = draft.variants ?? [] + const explicit = new Map(variants.map((variant) => [variant.id, variant])) + const generatedIDs = new Set(generated.map((variant) => variant.id)) + draft.variants = [ + ...generated.map((variant) => explicit.get(variant.id) ?? variant), + ...variants.filter((variant) => !generatedIDs.has(variant.id)), + ] +} + +function configuredModels(entries: readonly Entry[]) { + const result = new Map() + for (const entry of entries) { + if (entry.type !== "document") continue + for (const [providerID, provider] of Object.entries(entry.info.providers ?? {})) { + for (const [modelID, model] of Object.entries(provider.models ?? {})) { + const key = `${providerID}\0${modelID}` + if (!result.has(key)) result.set(key, "fallback") + if (model.variants !== undefined) result.set(key, model.variants.length === 0 ? "clear" : "suppress") + } + } + } + return result +} + export function generate( model: { readonly id: string; readonly modelID?: string; readonly package?: string }, provider?: { readonly package: string }, diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 6925486a56c9..ef6f69b148c8 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -20,7 +20,7 @@ const addPlugin = Effect.fn(function* (entries: Entry[], variants = false) { const plugin = yield* Plugin.Service const host = yield* PluginHost.make(plugin) yield* ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provide(Config.testLayer(entries))) - if (variants) yield* VariantPlugin.Plugin.effect(host) + if (variants) yield* VariantPlugin.Plugin.effect(host).pipe(Effect.provide(Config.testLayer(entries))) }) function required(value: T | undefined): T { @@ -170,13 +170,15 @@ describe("ConfigProviderPlugin.Plugin", () => { }), ) - it.effect("does not add config fallbacks to existing catalog models", () => + it.effect("preserves authoritative catalog variants", () => Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = Provider.ID.make("custom") const modelID = Model.ID.make("known") yield* catalog.transform((draft) => { - draft.model.update(providerID, modelID, () => {}) + draft.model.update(providerID, modelID, (model) => { + model.variants = [{ id: Model.VariantID.make("custom"), settings: { reasoningEffort: "high" } }] + }) }) const entries = [ new Document({ @@ -194,7 +196,9 @@ describe("ConfigProviderPlugin.Plugin", () => { yield* addPlugin(entries, true) - expect((yield* catalog.model.get(providerID, modelID))?.variants).toEqual([]) + expect((yield* catalog.model.get(providerID, modelID))?.variants).toEqual([ + { id: Model.VariantID.make("custom"), settings: { reasoningEffort: "high" } }, + ]) }), ) diff --git a/packages/core/test/plugin/variant.test.ts b/packages/core/test/plugin/variant.test.ts index 68b166100f89..6926a41e7f76 100644 --- a/packages/core/test/plugin/variant.test.ts +++ b/packages/core/test/plugin/variant.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { Catalog } from "@opencode-ai/core/catalog" +import { Config } from "@opencode-ai/core/config" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Location } from "@opencode-ai/core/location" @@ -31,7 +32,9 @@ describe("VariantPlugin", () => { model.package = Provider.aisdk("@ai-sdk/openai-compatible") }) }) - yield* VariantPlugin.Plugin.effect(host({ catalog: catalogHost(service) })) + yield* VariantPlugin.Plugin.effect(host({ catalog: catalogHost(service) })).pipe( + Effect.provide(Config.testLayer([])), + ) expect((yield* service.model.get(Provider.ID.opencode, Model.ID.make("glm-5.2")))?.variants).toEqual([ expect.objectContaining({ id: "high", settings: { reasoningEffort: "high" } }), @@ -50,7 +53,9 @@ describe("VariantPlugin", () => { model.variants = [{ id: Model.VariantID.make("high"), settings: {}, headers: { custom: "true" }, body: {} }] }) }) - yield* VariantPlugin.Plugin.effect(host({ catalog: catalogHost(service) })) + yield* VariantPlugin.Plugin.effect(host({ catalog: catalogHost(service) })).pipe( + Effect.provide(Config.testLayer([])), + ) expect((yield* service.model.get(Provider.ID.opencode, Model.ID.make("glm-5.2")))?.variants).toEqual([ expect.objectContaining({ id: "high", headers: { custom: "true" } }),