diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 90439c625c..62bb90716f 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -21,6 +21,7 @@ export const Event = Catalog.Event type Data = { providers: Map defaultModel?: DefaultModel + smallModels: Map } export type Draft = { @@ -38,6 +39,11 @@ export type Draft = { get: () => DefaultModel | undefined set: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void } + small: { + get: (providerID: ProviderV2.ID) => ModelV2.ID | undefined + set: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void + remove: (providerID: ProviderV2.ID) => void + } } } @@ -83,7 +89,7 @@ const layer = Layer.effect( const state = State.create({ name: "catalog", - initial: () => ({ providers: new Map() }), + initial: () => ({ providers: new Map(), smallModels: new Map() }), draft: (draft) => { const result: Draft = { provider: { @@ -131,6 +137,15 @@ const layer = Layer.effect( draft.defaultModel = { providerID, modelID } }, }, + small: { + get: (providerID) => draft.smallModels.get(providerID), + set: (providerID, modelID) => { + draft.smallModels.set(providerID, modelID) + }, + remove: (providerID) => { + draft.smallModels.delete(providerID) + }, + }, }, } return result @@ -217,13 +232,11 @@ const layer = Layer.effect( return } - // GitHub exposes utility models for title generation without including them in the picker. - // They remain in the catalog with enabled=false, so prefer them before family selection. - if (providerID.startsWith("github-copilot")) { - for (const id of COPILOT_UTILITY_MODELS) { - const model = record.models.get(ModelV2.ID.make(id)) - if (model?.status === "active") return projectModel(model, provider) - } + // Plugin transforms may pin a small model (for example picker-disabled utility models). + const configured = state.get().smallModels.get(providerID) + if (configured) { + const model = record.models.get(configured) + if (model?.status === "active") return projectModel(model, provider) } const priority = providerID.startsWith("opencode") @@ -272,6 +285,5 @@ const layer = Layer.effect( ) const SMALL_MODEL_FAMILY_PRIORITY = ["gemini-flash", "gpt-nano", "claude-haiku"] -const COPILOT_UTILITY_MODELS = ["gpt-5.4-nano", "gpt-4.1", "gpt-4o", "gpt-4o-mini"] export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Integration.node] }) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 007a53ed70..eeb6a87b0d 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -148,6 +148,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int set: (providerID, modelID) => draft.model.default.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), }, + small: { + get: (providerID) => draft.model.small.get(ProviderV2.ID.make(providerID)), + set: (providerID, modelID) => + draft.model.small.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), + remove: (providerID) => draft.model.small.remove(ProviderV2.ID.make(providerID)), + }, }, }) }), diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index 324a937a5c..a402d31fe5 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -119,6 +119,8 @@ function shouldUseResponses(modelID: string) { return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini") } +const UTILITY_MODELS = ["gpt-5.4-nano", "gpt-4.1", "gpt-4o", "gpt-4o-mini"] + export const GithubCopilotPlugin = define({ id: "opencode.provider.github-copilot", effect: Effect.fn(function* (ctx) { @@ -192,6 +194,13 @@ export const GithubCopilotPlugin = define({ model.enabled = false }) } + // GitHub exposes utility models for title generation without including them in the picker. + const utility = UTILITY_MODELS.map((id) => ModelV2.ID.make(id)).find((id) => { + const model = evt.model.get(item.provider.id, id) + return model?.status === "active" + }) + if (utility) evt.model.small.set(item.provider.id, utility) + else evt.model.small.remove(item.provider.id) }) const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload()))) yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe( diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 2f76685533..786f6a92c2 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -393,7 +393,7 @@ describe("CatalogV2", () => { }), ) - it.effect("small model prefers picker-disabled copilot utility models", () => + it.effect("small model prefers a plugin-configured small model override", () => Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.githubCopilot @@ -403,14 +403,11 @@ describe("CatalogV2", () => { model.family = ModelV2.Family.make("gpt-mini") model.time.released = Date.now() }) - catalog.model.update(providerID, ModelV2.ID.make("gpt-4o-mini"), (model) => { - model.enabled = false - model.time.released = Date.now() - 1000 - }) catalog.model.update(providerID, ModelV2.ID.make("gpt-5.4-nano"), (model) => { model.enabled = false model.time.released = Date.now() - 2000 }) + catalog.model.small.set(providerID, ModelV2.ID.make("gpt-5.4-nano")) }) expect((yield* catalog.model.small(providerID))?.id).toBe(ModelV2.ID.make("gpt-5.4-nano")) diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 679624ee57..33f0cf0ef1 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -178,6 +178,12 @@ export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog" set: (providerID, modelID) => draft.model.default.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), }, + small: { + get: (providerID) => draft.model.small.get(ProviderV2.ID.make(providerID)), + set: (providerID, modelID) => + draft.model.small.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), + remove: (providerID) => draft.model.small.remove(ProviderV2.ID.make(providerID)), + }, }, }), ), diff --git a/packages/plugin/src/v2/effect/catalog.ts b/packages/plugin/src/v2/effect/catalog.ts index b96ceec4a8..ea2cc7c931 100644 --- a/packages/plugin/src/v2/effect/catalog.ts +++ b/packages/plugin/src/v2/effect/catalog.ts @@ -23,6 +23,11 @@ export interface CatalogDraft { get(): { providerID: string; modelID: string } | undefined set(providerID: string, modelID: string): void } + readonly small: { + get(providerID: string): string | undefined + set(providerID: string, modelID: string): void + remove(providerID: string): void + } } }