refactor(core): let plugins pin small models via catalog draft

Move copilot utility-model preference out of core selection into the
github-copilot plugin by exposing catalog.model.small set/get/remove on
the transform draft.
This commit is contained in:
Aiden Cline
2026-07-12 21:21:41 -05:00
parent ba89671d90
commit d3bdc98457
6 changed files with 49 additions and 14 deletions
+21 -9
View File
@@ -21,6 +21,7 @@ export const Event = Catalog.Event
type Data = {
providers: Map<ProviderV2.ID, ProviderRecord>
defaultModel?: DefaultModel
smallModels: Map<ProviderV2.ID, ModelV2.ID>
}
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<Data, Draft>({
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] })
+6
View File
@@ -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)),
},
},
})
}),
@@ -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(
+2 -5
View File
@@ -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"))
+6
View File
@@ -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)),
},
},
}),
),
+5
View File
@@ -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
}
}
}