From 3c71e4b4c00d45f946a7f97626af76a46f0d0a0e Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 12 Jul 2026 15:55:06 -0500 Subject: [PATCH 1/6] fix(core): use catalog small model for session titles Prefer Catalog.model.small over the session model when the title agent has no explicit model, falling back to the session model if small selection is unavailable. --- packages/core/src/session/title.ts | 29 +++-- packages/core/test/session-title.test.ts | 153 +++++++++++++++++++---- 2 files changed, 154 insertions(+), 28 deletions(-) diff --git a/packages/core/src/session/title.ts b/packages/core/src/session/title.ts index 8e7b5857b7f1..41ec8afea08d 100644 --- a/packages/core/src/session/title.ts +++ b/packages/core/src/session/title.ts @@ -3,10 +3,12 @@ export * as SessionTitle from "./title" import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/llm" import { Context, DateTime, Effect, Layer, Stream } from "effect" import { AgentV2 } from "../agent" +import { Catalog } from "../catalog" import { Database } from "../database/database" import { EventV2 } from "../event" import { makeLocationNode } from "../effect/app-node" import { llmClient } from "../effect/app-node-platform" +import { ModelV2 } from "../model" import { SessionEvent } from "./event" import { SessionHistory } from "./history" import { SessionRunnerModel } from "./runner/model" @@ -21,6 +23,7 @@ type Dependencies = { } readonly agents: AgentV2.Interface readonly models: SessionRunnerModel.Interface + readonly catalog: Catalog.Interface } export interface Interface { @@ -33,6 +36,21 @@ export class Service extends Context.Service()("@opencode/v2 const truncate = (value: string) => (value.length <= MAX_LENGTH ? value : `${value.slice(0, MAX_LENGTH - 3)}...`) const make = (dependencies: Dependencies) => { + const resolveModel = (session: SessionSchema.Info, agent: AgentV2.Info) => { + if (agent.model) return dependencies.models.resolve({ ...session, model: agent.model }) + return Effect.gen(function* () { + const providerID = session.model?.providerID ?? (yield* dependencies.catalog.model.default())?.providerID + const small = providerID ? yield* dependencies.catalog.model.small(providerID) : undefined + if (!small) return yield* dependencies.models.resolve(session) + return yield* dependencies.models + .resolve({ + ...session, + model: ModelV2.Ref.make({ id: small.id, providerID: small.providerID }), + }) + .pipe(Effect.catch(() => dependencies.models.resolve(session))) + }) + } + const generateForFirstPrompt = Effect.fn("SessionTitle.generateForFirstPrompt")(function* ( db: Database.Interface["db"], session: SessionSchema.Info, @@ -42,11 +60,7 @@ const make = (dependencies: Dependencies) => { if (!firstUser) return const agent = yield* dependencies.agents.get(AgentV2.ID.make("title")) if (!agent) return - const resolved = yield* ( - agent.model - ? dependencies.models.resolve({ ...session, model: agent.model }) - : dependencies.models.resolve(session) - ).pipe(Effect.catch(() => Effect.succeed(undefined))) + const resolved = yield* resolveModel(session, agent).pipe(Effect.catch(() => Effect.succeed(undefined))) if (!resolved) return const chunks: string[] = [] let failed = false @@ -90,8 +104,9 @@ export const layer = Layer.effect( const llm = yield* LLMClient.Service const agents = yield* AgentV2.Service const models = yield* SessionRunnerModel.Service + const catalog = yield* Catalog.Service const database = yield* Database.Service - const title = make({ events, llm, agents, models }) + const title = make({ events, llm, agents, models, catalog }) return Service.of({ generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session), }) @@ -101,5 +116,5 @@ export const layer = Layer.effect( export const node = makeLocationNode({ service: Service, layer, - deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node], + deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Catalog.node, Database.node], }) diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index aeb096366065..76f52e60eae8 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -2,11 +2,14 @@ import { expect } from "bun:test" import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/llm" import { OpenAIChat } from "@opencode-ai/llm/protocols" import { AgentV2 } from "@opencode-ai/core/agent" +import { Catalog } from "@opencode-ai/core/catalog" import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { llmClient } from "@opencode-ai/core/effect/app-node-platform" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionProjector } from "@opencode-ai/core/session/projector" @@ -18,10 +21,12 @@ import { SessionV2 } from "@opencode-ai/core/session" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" -import { DateTime, Effect, Layer, Stream } from "effect" +import { Money } from "@opencode-ai/schema/money" +import { Effect, Layer, Stream } from "effect" import { testEffect } from "./lib/effect" let requests: LLMRequest[] = [] +let resolvedModels: Array<{ id: string; provider: string } | undefined> = [] const model = Model.make({ id: "title-model", provider: "test", @@ -36,7 +41,17 @@ const client = Layer.mock(LLMClient.Service)({ generate: () => Effect.die("unused"), }) const models = Layer.mock(SessionRunnerModel.Service)({ - resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)), + resolve: (session) => { + resolvedModels.push(session.model ? { id: session.model.id, provider: session.model.providerID } : undefined) + const selected = session.model + ? Model.make({ + id: session.model.id, + provider: session.model.providerID, + route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }), + }) + : model + return Effect.succeed(SessionRunnerModel.resolved(selected)) + }, }) const it = testEffect( AppNodeBuilder.build( @@ -46,6 +61,7 @@ const it = testEffect( SessionProjector.node, SessionStore.node, AgentV2.node, + Catalog.node, SessionTitle.node, ]), [ @@ -55,7 +71,10 @@ const it = testEffect( ), ) -const insertSession = (id: SessionV2.ID) => +const insertSession = ( + id: SessionV2.ID, + model?: { id: string; providerID: string }, +) => Effect.gen(function* () { const { db } = yield* Database.Service yield* db @@ -73,6 +92,14 @@ const insertSession = (id: SessionV2.ID) => directory: "/project", title: "New session - fake", version: "test", + ...(model + ? { + model: { + id: ModelV2.ID.make(model.id), + providerID: ProviderV2.ID.make(model.providerID), + }, + } + : {}), }) .onConflictDoNothing() .run() @@ -94,17 +121,68 @@ const prompt = (sessionID: SessionV2.ID, text: string) => }) }) -it.effect("generates a title from the sole user message and renames the session", () => +const enableTitleAgent = (model?: { id: string; providerID: string }) => Effect.gen(function* () { - requests = [] const agentService = yield* AgentV2.Service yield* agentService.transform((editor) => { editor.update(AgentV2.ID.make("title"), (agent) => { agent.mode = "primary" agent.hidden = true agent.system = "You are a title generator." + if (model) { + agent.model = { + id: ModelV2.ID.make(model.id), + providerID: ProviderV2.ID.make(model.providerID), + } + } }) }) + }) + +const seedSmallModel = () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("test") + yield* catalog.transform((catalog) => { + catalog.provider.update(providerID, () => {}) + catalog.model.update(providerID, ModelV2.ID.make("main"), (model) => { + model.capabilities.input = ["text"] + model.capabilities.output = ["text"] + model.cost = [ + { + input: Money.USDPerMillionTokens.make(50), + output: Money.USDPerMillionTokens.make(50), + cache: { + read: Money.USDPerMillionTokens.zero, + write: Money.USDPerMillionTokens.zero, + }, + }, + ] + model.time.released = Date.now() + }) + catalog.model.update(providerID, ModelV2.ID.make("mini"), (model) => { + model.capabilities.input = ["text"] + model.capabilities.output = ["text"] + model.cost = [ + { + input: Money.USDPerMillionTokens.make(1), + output: Money.USDPerMillionTokens.make(1), + cache: { + read: Money.USDPerMillionTokens.zero, + write: Money.USDPerMillionTokens.zero, + }, + }, + ] + model.time.released = Date.now() + }) + }) + }) + +it.effect("generates a title from the sole user message and renames the session", () => + Effect.gen(function* () { + requests = [] + resolvedModels = [] + yield* enableTitleAgent() const sessionID = SessionV2.ID.make("ses_title_generate") yield* insertSession(sessionID) yield* prompt(sessionID, "Help me debug the failing build") @@ -123,17 +201,55 @@ it.effect("generates a title from the sole user message and renames the session" }), ) +it.effect("prefers the catalog small model over the session model", () => + Effect.gen(function* () { + requests = [] + resolvedModels = [] + yield* enableTitleAgent() + yield* seedSmallModel() + const sessionID = SessionV2.ID.make("ses_title_small_model") + 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" }]) + expect(String(requests[0]?.model.id)).toBe("mini") + }), +) + +it.effect("prefers the title agent model over the catalog small model", () => + Effect.gen(function* () { + requests = [] + resolvedModels = [] + yield* enableTitleAgent({ id: "agent-title", providerID: "test" }) + yield* seedSmallModel() + const sessionID = SessionV2.ID.make("ses_title_agent_model") + 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: "agent-title", provider: "test" }]) + expect(String(requests[0]?.model.id)).toBe("agent-title") + }), +) + it.effect("does not generate once a second user message exists", () => Effect.gen(function* () { requests = [] - const agentService = yield* AgentV2.Service - yield* agentService.transform((editor) => { - editor.update(AgentV2.ID.make("title"), (agent) => { - agent.mode = "primary" - agent.hidden = true - agent.system = "You are a title generator." - }) - }) + resolvedModels = [] + yield* enableTitleAgent() const sessionID = SessionV2.ID.make("ses_title_second_message") yield* insertSession(sessionID) yield* prompt(sessionID, "First message") @@ -155,14 +271,8 @@ it.effect("does not generate once a second user message exists", () => it.effect("does not generate for a child session", () => Effect.gen(function* () { requests = [] - const agentService = yield* AgentV2.Service - yield* agentService.transform((editor) => { - editor.update(AgentV2.ID.make("title"), (agent) => { - agent.mode = "primary" - agent.hidden = true - agent.system = "You are a title generator." - }) - }) + resolvedModels = [] + yield* enableTitleAgent() const sessionID = SessionV2.ID.make("ses_title_child") const { db } = yield* Database.Service yield* db @@ -201,6 +311,7 @@ it.effect("does not generate for a child session", () => it.effect("does not generate when the title agent is removed", () => Effect.gen(function* () { requests = [] + resolvedModels = [] const sessionID = SessionV2.ID.make("ses_title_no_agent") yield* insertSession(sessionID) yield* prompt(sessionID, "Help me debug the failing build") From 83c6e0f8eaaf560df714ea4928719974f9d904da Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 12 Jul 2026 16:11:05 -0500 Subject: [PATCH 2/6] refactor(core): inline title model selection Keep single-use model selection at the title generation call site. --- packages/core/src/session/title.ts | 31 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/core/src/session/title.ts b/packages/core/src/session/title.ts index 41ec8afea08d..f3314ef44b75 100644 --- a/packages/core/src/session/title.ts +++ b/packages/core/src/session/title.ts @@ -36,21 +36,6 @@ export class Service extends Context.Service()("@opencode/v2 const truncate = (value: string) => (value.length <= MAX_LENGTH ? value : `${value.slice(0, MAX_LENGTH - 3)}...`) const make = (dependencies: Dependencies) => { - const resolveModel = (session: SessionSchema.Info, agent: AgentV2.Info) => { - if (agent.model) return dependencies.models.resolve({ ...session, model: agent.model }) - return Effect.gen(function* () { - const providerID = session.model?.providerID ?? (yield* dependencies.catalog.model.default())?.providerID - const small = providerID ? yield* dependencies.catalog.model.small(providerID) : undefined - if (!small) return yield* dependencies.models.resolve(session) - return yield* dependencies.models - .resolve({ - ...session, - model: ModelV2.Ref.make({ id: small.id, providerID: small.providerID }), - }) - .pipe(Effect.catch(() => dependencies.models.resolve(session))) - }) - } - const generateForFirstPrompt = Effect.fn("SessionTitle.generateForFirstPrompt")(function* ( db: Database.Interface["db"], session: SessionSchema.Info, @@ -60,7 +45,21 @@ const make = (dependencies: Dependencies) => { if (!firstUser) return const agent = yield* dependencies.agents.get(AgentV2.ID.make("title")) if (!agent) return - const resolved = yield* resolveModel(session, agent).pipe(Effect.catch(() => Effect.succeed(undefined))) + const resolved = yield* ( + agent.model + ? dependencies.models.resolve({ ...session, model: agent.model }) + : Effect.gen(function* () { + const providerID = session.model?.providerID ?? (yield* dependencies.catalog.model.default())?.providerID + const small = providerID ? yield* dependencies.catalog.model.small(providerID) : undefined + if (!small) return yield* dependencies.models.resolve(session) + return yield* dependencies.models + .resolve({ + ...session, + model: ModelV2.Ref.make({ id: small.id, providerID: small.providerID }), + }) + .pipe(Effect.catch(() => dependencies.models.resolve(session))) + }) + ).pipe(Effect.catch(() => Effect.succeed(undefined))) if (!resolved) return const chunks: string[] = [] let failed = false From c5dde0cabb79e9661f321e613a9e7db316536472 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 12 Jul 2026 20:07:06 -0500 Subject: [PATCH 3/6] fix(core): align small model selection with v1 families Replace the opencode gpt-5-nano hardcode and cost/name heuristic with V1-style family priority: newest model in gpt-nano / gemini-flash / claude-haiku (with opencode and copilot family order, Bedrock region prefix handling, and Azure skip). No small_model config. --- packages/core/src/catalog.ts | 71 ++++---- packages/core/test/catalog.test.ts | 155 ++++++++++++++---- .../test/plugin/provider-opencode.test.ts | 20 ++- packages/core/test/session-title.test.ts | 26 +-- 4 files changed, 174 insertions(+), 98 deletions(-) diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index c12793b3589c..a898fd618f8b 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -1,7 +1,7 @@ export * as Catalog from "./catalog" import { makeLocationNode } from "./effect/app-node" -import { Array, Context, Effect, Layer, Option, Order, pipe } from "effect" +import { Array, Context, Effect, Layer, Order, pipe } from "effect" import { Catalog } from "@opencode-ai/schema/catalog" import { ModelV2 } from "./model" import { ProviderV2 } from "./provider" @@ -217,48 +217,41 @@ const layer = Layer.effect( return } - if (providerID === ProviderV2.ID.opencode) { - const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano")) - if (gpt5Nano?.enabled && gpt5Nano.status === "active") return projectModel(gpt5Nano, provider) - } + const priority = providerID.startsWith("opencode") + ? ["gpt-nano"] + : providerID.startsWith("github-copilot") + ? ["gpt-mini", ...SMALL_MODEL_FAMILY_PRIORITY] + : SMALL_MODEL_FAMILY_PRIORITY - const candidates = pipe( + const models = pipe( Array.fromIterable(record.models.values()), - Array.filter( - (model) => - model.providerID === providerID && - model.enabled && - model.status === "active" && - model.capabilities.input.some((item) => item.startsWith("text")) && - model.capabilities.output.some((item) => item.startsWith("text")), - ), - Array.map((model) => ({ - model, - cost: model.cost[0] ? model.cost[0].input + model.cost[0].output : 999, - age: (Date.now() - model.time.released) / (1000 * 60 * 60 * 24 * 30), - small: SMALL_MODEL_RE.test(`${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()), - })), - Array.filter((item) => item.cost > 0 && item.age <= 18), + Array.filter((model) => model.enabled && model.status === "active"), + Array.sortWith((model) => model.id, Order.flip(Order.String)), + Array.sortWith((model) => model.time.released, Order.flip(Order.Number)), ) - const pick = (items: typeof candidates) => { - const maxCost = Math.max(...items.map((item) => item.cost), 0.01) - const maxAge = Math.max(...items.map((item) => item.age), 0.01) - return pipe( - items, - Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number), - Array.map((item) => projectModel(item.model, provider)), - Array.head, - ) - } + for (const family of priority) { + const candidates = models.filter((model) => model.family === family) + if (providerID === ProviderV2.ID.amazonBedrock) { + const crossRegionPrefixes = ["global.", "us.", "eu."] + const globalMatch = candidates.find((model) => model.id.startsWith("global.")) + if (globalMatch) return projectModel(globalMatch, provider) - return Option.getOrUndefined( - pipe( - candidates, - Array.filter((item) => item.small), - (items) => (items.length > 0 ? pick(items) : pick(candidates)), - ), - ) + const region = typeof provider.settings?.region === "string" ? provider.settings.region : undefined + if (region) { + const regionPrefix = region.split("-")[0] + if (regionPrefix === "us" || regionPrefix === "eu") { + const regionalMatch = candidates.find((model) => model.id.startsWith(`${regionPrefix}.`)) + if (regionalMatch) return projectModel(regionalMatch, provider) + } + } + + const unprefixed = candidates.find((model) => !crossRegionPrefixes.some((prefix) => model.id.startsWith(prefix))) + if (unprefixed) return projectModel(unprefixed, provider) + continue + } + if (candidates[0]) return projectModel(candidates[0], provider) + } }), }, } @@ -267,6 +260,6 @@ const layer = Layer.effect( }), ) -const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/ +const SMALL_MODEL_FAMILY_PRIORITY = ["gemini-flash", "gpt-nano", "claude-haiku"] 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 ad667f288c29..1e65ba6463c3 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -290,45 +290,144 @@ describe("CatalogV2", () => { }), ) - it.effect("small model prefers small keyword candidates before cost scoring", () => + it.effect("small model selects the latest model in the preferred family", () => Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") yield* catalog.transform((catalog) => { catalog.provider.update(providerID, () => {}) - catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => { - model.capabilities.input = ["text"] - model.capabilities.output = ["text"] - model.cost = [ - { - input: Money.USDPerMillionTokens.make(1), - output: Money.USDPerMillionTokens.make(1), - cache: { - read: Money.USDPerMillionTokens.zero, - write: Money.USDPerMillionTokens.zero, - }, - }, - ] + catalog.model.update(providerID, ModelV2.ID.make("old-flash"), (model) => { + model.family = ModelV2.Family.make("gemini-flash") + model.time.released = Date.now() - 1000 + }) + catalog.model.update(providerID, ModelV2.ID.make("new-flash"), (model) => { + model.family = ModelV2.Family.make("gemini-flash") + model.time.released = Date.now() + }) + catalog.model.update(providerID, ModelV2.ID.make("newer-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("new-flash")) + }), + ) + + it.effect("small model matches exact model families", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("test") + yield* catalog.transform((catalog) => { + catalog.provider.update(providerID, () => {}) + catalog.model.update(providerID, ModelV2.ID.make("glm-flash"), (model) => { + model.family = ModelV2.Family.make("glm-flash") + model.time.released = Date.now() + 1000 + }) + catalog.model.update(providerID, ModelV2.ID.make("claude-haiku"), (model) => { + model.family = ModelV2.Family.make("claude-haiku") + model.time.released = Date.now() + }) + }) + + expect((yield* catalog.model.small(providerID))?.id).toBe(ModelV2.ID.make("claude-haiku")) + }), + ) + + it.effect("small model ignores model IDs without family metadata", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("test") + yield* catalog.transform((catalog) => { + catalog.provider.update(providerID, () => {}) + catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => { + model.time.released = Date.now() + }) + }) + + expect(yield* catalog.model.small(providerID)).toBeUndefined() + }), + ) + + it.effect("small model prefers gpt-nano family for opencode providers", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.opencode + yield* catalog.transform((catalog) => { + catalog.provider.update(providerID, () => {}) + catalog.model.update(providerID, ModelV2.ID.make("old-nano"), (model) => { + model.family = ModelV2.Family.make("gpt-nano") + model.time.released = Date.now() - 1000 + }) + catalog.model.update(providerID, ModelV2.ID.make("new-nano"), (model) => { + model.family = ModelV2.Family.make("gpt-nano") model.time.released = Date.now() }) - catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => { - model.capabilities.input = ["text"] - model.capabilities.output = ["text"] - model.cost = [ - { - input: Money.USDPerMillionTokens.make(10), - output: Money.USDPerMillionTokens.make(10), - cache: { - read: Money.USDPerMillionTokens.zero, - write: Money.USDPerMillionTokens.zero, - }, - }, - ] + catalog.model.update(providerID, ModelV2.ID.make("flash"), (model) => { + model.family = ModelV2.Family.make("gemini-flash") + model.time.released = Date.now() + 1000 + }) + }) + + expect((yield* catalog.model.small(providerID))?.id).toBe(ModelV2.ID.make("new-nano")) + }), + ) + + it.effect("small model prefers gpt-mini family for github-copilot providers", () => + 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("flash"), (model) => { + model.family = ModelV2.Family.make("gemini-flash") model.time.released = Date.now() }) + catalog.model.update(providerID, ModelV2.ID.make("mini"), (model) => { + model.family = ModelV2.Family.make("gpt-mini") + model.time.released = Date.now() - 1000 + }) }) - expect((yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini") + expect((yield* catalog.model.small(providerID))?.id).toBe(ModelV2.ID.make("mini")) + }), + ) + + it.effect("small model prefers global bedrock deployments before regional ones", () => + 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("us.claude-haiku"), (model) => { + model.family = ModelV2.Family.make("claude-haiku") + model.time.released = Date.now() + }) + catalog.model.update(providerID, ModelV2.ID.make("global.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("global.claude-haiku")) + }), + ) + + it.effect("small model skips inferred models for Azure providers", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + for (const providerID of [ProviderV2.ID.azure, ProviderV2.ID.make("azure-cognitive-services")]) { + yield* catalog.transform((catalog) => { + catalog.provider.update(providerID, () => {}) + catalog.model.update(providerID, ModelV2.ID.make("small"), (model) => { + model.family = ModelV2.Family.make("gemini-flash") + model.time.released = Date.now() + }) + }) + expect(yield* catalog.model.small(providerID)).toBeUndefined() + } }), ) }) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index dfb0e82d0a03..29a38261b048 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -481,30 +481,38 @@ describe("OpencodePlugin", () => { ), ) - it.effect("prefers gpt-5-nano as the opencode small model", () => + it.effect("prefers the newest gpt-nano family model for opencode", () => Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.opencode yield* catalog.transform((catalog) => { catalog.provider.update(providerID, () => {}) - catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => { + catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => { + model.family = ModelV2.Family.make("gpt-nano") + model.capabilities.input = ["text"] + model.capabilities.output = ["text"] + model.cost = [...cost(10, 10)] + model.time.released = Date.now() - 1000 + }) + catalog.model.update(providerID, ModelV2.ID.make("gpt-5.4-nano"), (model) => { + model.family = ModelV2.Family.make("gpt-nano") model.capabilities.input = ["text"] model.capabilities.output = ["text"] model.cost = [...cost(1, 1)] model.time.released = Date.now() }) - catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => { + catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => { model.capabilities.input = ["text"] model.capabilities.output = ["text"] - model.cost = [...cost(10, 10)] - model.time.released = Date.now() + model.cost = [...cost(1, 1)] + model.time.released = Date.now() + 1000 }) }) const selected = yield* catalog.model.small(providerID) - expect(selected?.id).toBe(ModelV2.ID.make("gpt-5-nano")) + expect(selected?.id).toBe(ModelV2.ID.make("gpt-5.4-nano")) }), ) }) diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index 76f52e60eae8..d1906bf20c46 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -21,7 +21,6 @@ import { SessionV2 } from "@opencode-ai/core/session" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" -import { Money } from "@opencode-ai/schema/money" import { Effect, Layer, Stream } from "effect" import { testEffect } from "./lib/effect" @@ -146,33 +145,10 @@ const seedSmallModel = () => yield* catalog.transform((catalog) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("main"), (model) => { - model.capabilities.input = ["text"] - model.capabilities.output = ["text"] - model.cost = [ - { - input: Money.USDPerMillionTokens.make(50), - output: Money.USDPerMillionTokens.make(50), - cache: { - read: Money.USDPerMillionTokens.zero, - write: Money.USDPerMillionTokens.zero, - }, - }, - ] model.time.released = Date.now() }) catalog.model.update(providerID, ModelV2.ID.make("mini"), (model) => { - model.capabilities.input = ["text"] - model.capabilities.output = ["text"] - model.cost = [ - { - input: Money.USDPerMillionTokens.make(1), - output: Money.USDPerMillionTokens.make(1), - cache: { - read: Money.USDPerMillionTokens.zero, - write: Money.USDPerMillionTokens.zero, - }, - }, - ] + model.family = ModelV2.Family.make("gpt-nano") model.time.released = Date.now() }) }) From ba89671d901e03ca339104ba4cfaf899dc14c66c Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 12 Jul 2026 20:37:48 -0500 Subject: [PATCH 4/6] 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. --- packages/core/src/catalog.ts | 14 ++++- packages/core/test/catalog.test.ts | 72 +++++++++++++++++++++ packages/core/test/session-title.test.ts | 79 ++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 1 deletion(-) diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index a898fd618f8b..90439c625c14 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 1e65ba6463c3..2f766855330f 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 d1906bf20c46..aeccef95a96a 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 = [] From d3bdc9845760b7dcec1addfb6d2f0eac5faad688 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 12 Jul 2026 21:21:41 -0500 Subject: [PATCH 5/6] 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. --- packages/core/src/catalog.ts | 30 +++++++++++++------ packages/core/src/plugin/host.ts | 6 ++++ .../src/plugin/provider/github-copilot.ts | 9 ++++++ packages/core/test/catalog.test.ts | 7 ++--- packages/core/test/plugin/host.ts | 6 ++++ packages/plugin/src/v2/effect/catalog.ts | 5 ++++ 6 files changed, 49 insertions(+), 14 deletions(-) diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 90439c625c14..62bb90716fb1 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 007a53ed70ec..eeb6a87b0ddb 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 324a937a5c10..a402d31fe56f 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 2f766855330f..786f6a92c282 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 679624ee5791..33f0cf0ef13b 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 b96ceec4a839..ea2cc7c931b8 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 + } } } From fb0f7636037d0b4cae1020b045e8bf19651951da Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 13 Jul 2026 13:44:03 -0500 Subject: [PATCH 6/6] fix(core): resolve hidden title models internally --- packages/core/src/session/runner/model.ts | 72 +++++++++++++------ packages/core/src/session/title.ts | 6 +- packages/core/test/catalog.test.ts | 3 + .../plugin/provider-github-copilot.test.ts | 22 ++++++ packages/core/test/session-title.test.ts | 17 +++++ 5 files changed, 92 insertions(+), 28 deletions(-) diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index defd95f9c117..35a8d7929b12 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -88,12 +88,23 @@ export interface Resolved { export interface Interface { readonly resolve: (session: SessionSchema.Info) => Effect.Effect + readonly resolveCatalogModel: ( + session: SessionSchema.Info, + model: ModelV2.Info, + ) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/SessionRunnerModel") {} /** Test or embedding seam for supplying a model resolver directly. */ -export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve })) +export const layerWith = ( + resolve: Interface["resolve"], + resolveCatalogModel: Interface["resolveCatalogModel"] = (session, model) => + resolve({ + ...session, + model: ModelV2.Ref.make({ id: model.id, providerID: model.providerID }), + }), +) => Layer.succeed(Service, Service.of({ resolve, resolveCatalogModel })) /** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */ export const resolved = (model: Model, variant?: ModelV2.VariantID, cost: ModelV2.Info["cost"] = []): Resolved => ({ @@ -307,7 +318,43 @@ const layer = Layer.effect( const integrations = yield* Integration.Service const npm = yield* Npm.Service const aisdk = yield* AISDK.Service + const resolveCatalogModel = Effect.fn("SessionRunnerModel.resolveCatalogModel")(function* ( + session: SessionSchema.Info, + selected: ModelV2.Info, + variant?: ModelV2.VariantID, + ) { + const provider = yield* catalog.provider.get(selected.providerID) + const connection = yield* integrations.connection.active( + provider?.integrationID ?? Integration.ID.make(selected.providerID), + ) + const model = yield* resolve( + { + ...session, + model: ModelV2.Ref.make({ + id: selected.id, + providerID: selected.providerID, + ...(variant === undefined ? {} : { variant }), + }), + }, + selected, + connection ? yield* integrations.connection.resolve(connection) : undefined, + { + loadPackage: (specifier) => ProviderV2.loadPackage(specifier, npm), + loadAISDK: (model) => aisdk.model(model), + }, + ) + return { + model, + ref: ModelV2.Ref.make({ + id: selected.id, + providerID: selected.providerID, + ...(variant === undefined ? {} : { variant }), + }), + cost: selected.cost, + } + }) return Service.of({ + resolveCatalogModel, resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) { // Location plugins populate and filter the catalog asynchronously during layer startup. const defaultModel = session.model ? undefined : yield* catalog.model.default() @@ -324,28 +371,7 @@ const layer = Layer.effect( modelID: session.model.id, }) if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id }) - const provider = yield* catalog.provider.get(selected.providerID) - const connection = yield* integrations.connection.active( - provider?.integrationID ?? Integration.ID.make(selected.providerID), - ) - const model = yield* resolve( - session, - selected, - connection ? yield* integrations.connection.resolve(connection) : undefined, - { - loadPackage: (specifier) => ProviderV2.loadPackage(specifier, npm), - loadAISDK: (model) => aisdk.model(model), - }, - ) - return { - model, - ref: ModelV2.Ref.make({ - id: selected.id, - providerID: selected.providerID, - ...(session.model?.variant === undefined ? {} : { variant: session.model.variant }), - }), - cost: selected.cost, - } + return yield* resolveCatalogModel(session, selected, session.model?.variant) }), }) }), diff --git a/packages/core/src/session/title.ts b/packages/core/src/session/title.ts index f3314ef44b75..cbdfe255dbe8 100644 --- a/packages/core/src/session/title.ts +++ b/packages/core/src/session/title.ts @@ -8,7 +8,6 @@ import { Database } from "../database/database" import { EventV2 } from "../event" import { makeLocationNode } from "../effect/app-node" import { llmClient } from "../effect/app-node-platform" -import { ModelV2 } from "../model" import { SessionEvent } from "./event" import { SessionHistory } from "./history" import { SessionRunnerModel } from "./runner/model" @@ -53,10 +52,7 @@ const make = (dependencies: Dependencies) => { const small = providerID ? yield* dependencies.catalog.model.small(providerID) : undefined if (!small) return yield* dependencies.models.resolve(session) return yield* dependencies.models - .resolve({ - ...session, - model: ModelV2.Ref.make({ id: small.id, providerID: small.providerID }), - }) + .resolveCatalogModel(session, small) .pipe(Effect.catch(() => dependencies.models.resolve(session))) }) ).pipe(Effect.catch(() => Effect.succeed(undefined))) diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 786f6a92c282..fcfc4f0d4357 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -411,6 +411,9 @@ describe("CatalogV2", () => { }) expect((yield* catalog.model.small(providerID))?.id).toBe(ModelV2.ID.make("gpt-5.4-nano")) + expect((yield* catalog.model.available()).some((model) => model.id === ModelV2.ID.make("gpt-5.4-nano"))).toBe( + false, + ) }), ) diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index b850d9ac7353..908e213369fc 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -289,6 +289,28 @@ describe("GithubCopilotPlugin", () => { }), ) + it.effect("pins picker-hidden utility models for internal small-model work", () => + 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("gpt-4.1"), (model) => { + model.enabled = false + }) + catalog.model.update(providerID, ModelV2.ID.make("gpt-5.4-nano"), (model) => { + model.enabled = false + }) + }) + yield* addPlugin() + + expect((yield* catalog.model.small(providerID))?.id).toBe(ModelV2.ID.make("gpt-5.4-nano")) + expect((yield* catalog.model.available()).some((model) => model.id === ModelV2.ID.make("gpt-5.4-nano"))).toBe( + false, + ) + }), + ) + it.effect("does not disable gpt-5-chat-latest for non-Copilot 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 aeccef95a96a..84be326c3c61 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -55,6 +55,21 @@ const models = Layer.mock(SessionRunnerModel.Service)({ : model return Effect.succeed(SessionRunnerModel.resolved(selected)) }, + resolveCatalogModel: (session, selected) => { + resolvedModels.push({ id: selected.id, provider: selected.providerID }) + if (failSmallResolve && selected.id === ModelV2.ID.make("mini")) { + return Effect.fail(new Error("small unavailable") as never) + } + return Effect.succeed( + SessionRunnerModel.resolved( + Model.make({ + id: selected.id, + provider: selected.providerID, + route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }), + }), + ), + ) + }, }) const it = testEffect( AppNodeBuilder.build( @@ -152,9 +167,11 @@ const seedSmallModel = () => model.time.released = Date.now() }) catalog.model.update(providerID, ModelV2.ID.make("mini"), (model) => { + model.enabled = false model.family = ModelV2.Family.make("gpt-nano") model.time.released = Date.now() }) + catalog.model.small.set(providerID, ModelV2.ID.make("mini")) }) })