fix(core): prefer copilot utility models for small selection

Match V1 copilot title behavior by preferring picker-disabled utility
models before family priority, and cover title/small fallback edges.
This commit is contained in:
Aiden Cline 2026-07-12 20:37:48 -05:00
commit ba89671d90
3 changed files with 164 additions and 1 deletions

View file

@ -217,6 +217,15 @@ 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)
}
}
const priority = providerID.startsWith("opencode")
? ["gpt-nano"]
: providerID.startsWith("github-copilot")
@ -246,7 +255,9 @@ const layer = Layer.effect(
}
}
const unprefixed = candidates.find((model) => !crossRegionPrefixes.some((prefix) => model.id.startsWith(prefix)))
const unprefixed = candidates.find(
(model) => !crossRegionPrefixes.some((prefix) => model.id.startsWith(prefix)),
)
if (unprefixed) return projectModel(unprefixed, provider)
continue
}
@ -261,5 +272,6 @@ 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] })

View file

@ -393,6 +393,30 @@ describe("CatalogV2", () => {
}),
)
it.effect("small model prefers picker-disabled copilot utility models", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.githubCopilot
yield* catalog.transform((catalog) => {
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, ModelV2.ID.make("mini"), (model) => {
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
})
})
expect((yield* catalog.model.small(providerID))?.id).toBe(ModelV2.ID.make("gpt-5.4-nano"))
}),
)
it.effect("small model prefers global bedrock deployments before regional ones", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
@ -415,6 +439,54 @@ describe("CatalogV2", () => {
}),
)
it.effect("small model prefers regional bedrock deployments when global is missing", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.amazonBedrock
yield* catalog.transform((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.settings = { region: "us-west-2" }
})
catalog.model.update(providerID, ModelV2.ID.make("claude-haiku"), (model) => {
model.family = ModelV2.Family.make("claude-haiku")
model.time.released = Date.now()
})
catalog.model.update(providerID, ModelV2.ID.make("us.claude-haiku"), (model) => {
model.family = ModelV2.Family.make("claude-haiku")
model.time.released = Date.now() - 1000
})
catalog.model.update(providerID, ModelV2.ID.make("eu.claude-haiku"), (model) => {
model.family = ModelV2.Family.make("claude-haiku")
model.time.released = Date.now() + 1000
})
})
expect((yield* catalog.model.small(providerID))?.id).toBe(ModelV2.ID.make("us.claude-haiku"))
}),
)
it.effect("small model prefers unprefixed bedrock deployments when region prefixes are missing", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.amazonBedrock
yield* catalog.transform((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.settings = { region: "ap-northeast-1" }
})
catalog.model.update(providerID, ModelV2.ID.make("eu.claude-haiku"), (model) => {
model.family = ModelV2.Family.make("claude-haiku")
model.time.released = Date.now()
})
catalog.model.update(providerID, ModelV2.ID.make("claude-haiku"), (model) => {
model.family = ModelV2.Family.make("claude-haiku")
model.time.released = Date.now() - 1000
})
})
expect((yield* catalog.model.small(providerID))?.id).toBe(ModelV2.ID.make("claude-haiku"))
}),
)
it.effect("small model skips inferred models for Azure providers", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service

View file

@ -26,6 +26,7 @@ import { testEffect } from "./lib/effect"
let requests: LLMRequest[] = []
let resolvedModels: Array<{ id: string; provider: string } | undefined> = []
let failSmallResolve = false
const model = Model.make({
id: "title-model",
provider: "test",
@ -42,6 +43,9 @@ const client = Layer.mock(LLMClient.Service)({
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: (session) => {
resolvedModels.push(session.model ? { id: session.model.id, provider: session.model.providerID } : undefined)
if (failSmallResolve && session.model?.id === "mini") {
return Effect.fail(new Error("small unavailable") as never)
}
const selected = session.model
? Model.make({
id: session.model.id,
@ -221,6 +225,81 @@ it.effect("prefers the title agent model over the catalog small model", () =>
}),
)
it.effect("falls back to the session model when no small model exists", () =>
Effect.gen(function* () {
requests = []
resolvedModels = []
failSmallResolve = false
yield* enableTitleAgent()
const sessionID = SessionV2.ID.make("ses_title_session_fallback")
yield* insertSession(sessionID, { id: "main", providerID: "test" })
yield* prompt(sessionID, "Help me debug the failing build")
const store = yield* SessionStore.Service
const session = yield* store
.get(sessionID)
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(session)
expect(resolvedModels).toEqual([{ id: "main", provider: "test" }])
expect(String(requests[0]?.model.id)).toBe("main")
}),
)
it.effect("falls back to the session model when small model resolution fails", () =>
Effect.gen(function* () {
requests = []
resolvedModels = []
failSmallResolve = true
yield* enableTitleAgent()
yield* seedSmallModel()
const sessionID = SessionV2.ID.make("ses_title_small_resolve_fail")
yield* insertSession(sessionID, { id: "main", providerID: "test" })
yield* prompt(sessionID, "Help me debug the failing build")
const store = yield* SessionStore.Service
const session = yield* store
.get(sessionID)
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(session)
expect(resolvedModels).toEqual([
{ id: "mini", provider: "test" },
{ id: "main", provider: "test" },
])
expect(String(requests[0]?.model.id)).toBe("main")
}),
)
it.effect("uses the catalog default provider when the session has no model", () =>
Effect.gen(function* () {
requests = []
resolvedModels = []
failSmallResolve = false
yield* enableTitleAgent()
yield* seedSmallModel()
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
catalog.model.default.set(ProviderV2.ID.make("test"), ModelV2.ID.make("main"))
})
const sessionID = SessionV2.ID.make("ses_title_default_provider")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Help me debug the failing build")
const store = yield* SessionStore.Service
const session = yield* store
.get(sessionID)
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(session)
expect(resolvedModels).toEqual([{ id: "mini", provider: "test" }])
expect(String(requests[0]?.model.id)).toBe("mini")
}),
)
it.effect("does not generate once a second user message exists", () =>
Effect.gen(function* () {
requests = []