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:
parent
c5dde0cabb
commit
ba89671d90
3 changed files with 164 additions and 1 deletions
|
|
@ -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] })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue