diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index a898fd618f..90439c625c 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -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] }) diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 1e65ba6463..2f76685533 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -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 diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index d1906bf20c..aeccef95a9 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -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 = []