diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index defd95f9c1..35a8d7929b 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 f3314ef44b..cbdfe255db 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 786f6a92c2..fcfc4f0d43 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 b850d9ac73..908e213369 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 aeccef95a9..84be326c3c 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")) }) })