diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index c12793b3589c..62bb90716fb1 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" @@ -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,48 +232,50 @@ 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) + // 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 candidates = pipe( + const priority = providerID.startsWith("opencode") + ? ["gpt-nano"] + : providerID.startsWith("github-copilot") + ? ["gpt-mini", ...SMALL_MODEL_FAMILY_PRIORITY] + : SMALL_MODEL_FAMILY_PRIORITY + + 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 +284,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/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/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 8e7b5857b7f1..cbdfe255dbe8 100644 --- a/packages/core/src/session/title.ts +++ b/packages/core/src/session/title.ts @@ -3,6 +3,7 @@ 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" @@ -21,6 +22,7 @@ type Dependencies = { } readonly agents: AgentV2.Interface readonly models: SessionRunnerModel.Interface + readonly catalog: Catalog.Interface } export interface Interface { @@ -45,7 +47,14 @@ const make = (dependencies: Dependencies) => { const resolved = yield* ( agent.model ? dependencies.models.resolve({ ...session, model: agent.model }) - : dependencies.models.resolve(session) + : 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 + .resolveCatalogModel(session, small) + .pipe(Effect.catch(() => dependencies.models.resolve(session))) + }) ).pipe(Effect.catch(() => Effect.succeed(undefined))) if (!resolved) return const chunks: string[] = [] @@ -90,8 +99,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 +111,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/catalog.test.ts b/packages/core/test/catalog.test.ts index ad667f288c29..fcfc4f0d4357 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -290,45 +290,216 @@ 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("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).toBe(ModelV2.ID.make("mini")) + }), + ) + + it.effect("small model prefers a plugin-configured small model override", () => + 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("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("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")) + expect((yield* catalog.model.available()).some((model) => model.id === ModelV2.ID.make("gpt-5.4-nano"))).toBe( + false, + ) + }), + ) + + 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).toMatch("expensive-mini") + expect((yield* catalog.model.small(providerID))?.id).toBe(ModelV2.ID.make("global.claude-haiku")) + }), + ) + + 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 + 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/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/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/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 aeb096366065..84be326c3c61 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 { Effect, Layer, Stream } from "effect" 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", @@ -36,7 +41,35 @@ 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) + 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, + provider: session.model.providerID, + route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }), + }) + : 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( @@ -46,6 +79,7 @@ const it = testEffect( SessionProjector.node, SessionStore.node, AgentV2.node, + Catalog.node, SessionTitle.node, ]), [ @@ -55,7 +89,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 +110,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 +139,47 @@ 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.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")) + }) + }) + +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 +198,130 @@ it.effect("generates a title from the sole user message and renames the session" }), ) -it.effect("does not generate once a second user message exists", () => +it.effect("prefers the catalog small model over the session model", () => 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() + 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("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 = [] + resolvedModels = [] + yield* enableTitleAgent() const sessionID = SessionV2.ID.make("ses_title_second_message") yield* insertSession(sessionID) yield* prompt(sessionID, "First message") @@ -155,14 +343,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 +383,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") 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 + } } }