diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 99c45cbb520..9ff953be82d 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -321,12 +321,12 @@ export const layer = Layer.effect( compactIfNeeded: compaction.compactIfNeeded, compactAfterOverflow: compaction.compactAfterOverflow, compactManual: Effect.fn("SessionCompaction.compactManual")(function* (input) { - const model = yield* models.resolve(input.session).pipe(Effect.catch(() => Effect.succeed(undefined))) - if (!model) return false + const resolved = yield* models.resolve(input.session).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!resolved) return false return yield* compaction.compactManual({ sessionID: input.session.id, messages: input.messages, - model, + model: resolved.model, }) }), }) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 43fb00aa6ce..065b83e7f89 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -14,8 +14,6 @@ import { Config } from "../../config" import { Database } from "../../database/database" import { EventV2 } from "../../event" import { Location } from "../../location" -import { ModelV2 } from "../../model" -import { ProviderV2 } from "../../provider" import { QuestionV2 } from "../../question" import { SystemContext } from "../../system-context/index" import { SystemContextBuiltIns } from "../../system-context/builtins" @@ -217,7 +215,8 @@ const layer = Layer.effect( } if (promoted > 0) currentStep = 1 } - const model = yield* models.resolve(session) + const resolved = yield* models.resolve(session) + const model = resolved.model const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq) const context = entries.map((entry) => entry.message) const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps @@ -244,11 +243,9 @@ const layer = Layer.effect( const publisher = createLLMEventPublisher(events, { sessionID: session.id, agent: agent.id, - model: { - id: ModelV2.ID.make(model.id), - providerID: ProviderV2.ID.make(model.provider), - ...(session.model?.variant === undefined ? {} : { variant: session.model.variant }), - }, + // The selected catalog identity, not model.id: route-level ids are provider API + // model ids (for example gpt-5.5-fast resolves to api id gpt-5.5). + model: resolved.ref, snapshot: startSnapshot, }) const publication = Semaphore.makeUnsafe(1) diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 4583695a319..b21492d18db 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -72,8 +72,15 @@ export type Error = | UnsupportedApiError | Integration.AuthorizationError +export interface Resolved { + /** Route-level model for provider requests; its id is the provider API model id, which may differ from the catalog id. */ + readonly model: Model + /** Selected catalog identity. Durable records and displays must use this, never the API model id. */ + readonly ref: ModelV2.Ref +} + export interface Interface { - readonly resolve: (session: SessionSchema.Info) => Effect.Effect + readonly resolve: (session: SessionSchema.Info) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/SessionRunnerModel") {} @@ -81,6 +88,16 @@ export class Service extends Context.Service()("@opencode/v2 /** Test or embedding seam for supplying a model resolver directly. */ export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve })) +/** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */ +export const resolved = (model: Model, variant?: ModelV2.VariantID): Resolved => ({ + model, + ref: ModelV2.Ref.make({ + id: ModelV2.ID.make(model.id), + providerID: ProviderV2.ID.make(model.provider), + ...(variant === undefined ? {} : { variant }), + }), +}) + const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => { if (credential?.type === "key") return Auth.value(credential.key) if (credential?.type === "oauth") return Auth.value(credential.access) @@ -233,11 +250,19 @@ const layer = Layer.effect( const connection = yield* integrations.connection.active( provider?.integrationID ?? Integration.ID.make(selected.providerID), ) - return yield* resolve( + const model = yield* resolve( session, selected, connection ? yield* integrations.connection.resolve(connection) : undefined, ) + return { + model, + ref: ModelV2.Ref.make({ + id: selected.id, + providerID: selected.providerID, + ...(session.model?.variant === undefined ? {} : { variant: session.model.variant }), + }), + } }), }) }), diff --git a/packages/core/src/session/title.ts b/packages/core/src/session/title.ts index 2467197271b..abed5ce33c8 100644 --- a/packages/core/src/session/title.ts +++ b/packages/core/src/session/title.ts @@ -42,17 +42,17 @@ const make = (dependencies: Dependencies) => { if (!firstUser) return const agent = yield* dependencies.agents.get(AgentV2.ID.make("title")) if (!agent) return - const model = yield* (agent.model + const resolved = yield* (agent.model ? dependencies.models.resolve({ ...session, model: agent.model }) : dependencies.models.resolve(session) ).pipe(Effect.catch(() => Effect.succeed(undefined))) - if (!model) return + if (!resolved) return const chunks: string[] = [] let failed = false const streamed = yield* dependencies.llm .stream( LLM.request({ - model, + model: resolved.model, system: agent.system, messages: [Message.user(firstUser.text)], tools: [], diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 2b5bd55c522..a6a2c18fa81 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -174,6 +174,58 @@ describe("LocationServiceMap", () => { ), ) + it.live("preserves the selected catalog identity when the api model id differs", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) + const resolved = yield* Effect.gen(function* () { + const catalog = yield* Catalog.Service + yield* catalog.transform((editor) => { + editor.provider.update(ProviderV2.ID.make("aliased"), (provider) => { + provider.api = { type: "aisdk", package: "@ai-sdk/openai", settings: {} } + }) + editor.model.update(ProviderV2.ID.make("aliased"), ModelV2.ID.make("fast"), (model) => { + // Catalog id and provider API id intentionally differ, like gpt-5.5-fast -> gpt-5.5. + model.api = { ...model.api, id: ModelV2.ID.make("base") } + model.variants.push({ id: ModelV2.VariantID.make("high"), settings: {}, headers: {}, body: {} }) + }) + }) + const models = yield* SessionRunnerModel.Service + return yield* models.resolve( + SessionV2.Info.make({ + id: SessionV2.ID.make("ses_aliased_model"), + projectID: ProjectV2.ID.global, + title: "test", + model: { + id: ModelV2.ID.make("fast"), + providerID: ProviderV2.ID.make("aliased"), + variant: ModelV2.VariantID.make("high"), + }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + location, + }), + ) + }).pipe(Effect.provide(LocationServiceMap.Service.get(location))) + + expect(resolved.ref).toEqual( + ModelV2.Ref.make({ + id: ModelV2.ID.make("fast"), + providerID: ProviderV2.ID.make("aliased"), + variant: ModelV2.VariantID.make("high"), + }), + ) + expect(String(resolved.model.id)).toBe("base") + }), + ), + ), + ) + it.live("installs public plugins into a location", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts index 41190d470be..dc61a31e2f0 100644 --- a/packages/core/test/session-compact.test.ts +++ b/packages/core/test/session-compact.test.ts @@ -48,7 +48,7 @@ const client = Layer.mock(LLMClient.Service)({ generate: () => Effect.die("unused"), }) const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) }) -const models = SessionRunnerModel.layerWith(() => Effect.succeed(model)) +const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model))) const locations = Layer.effect( LocationServiceMap.Service, LayerMap.make( diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index 4a7bc426b71..dd5e9fb7496 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -38,7 +38,9 @@ const client = Layer.mock(LLMClient.Service)({ generate: () => Effect.die("unused"), }) const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) }) -const models = Layer.mock(SessionRunnerModel.Service)({ resolve: () => Effect.succeed(model) }) +const models = Layer.mock(SessionRunnerModel.Service)({ + resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)), +}) const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionCompaction.node]), diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 57e0ba42f65..0ebe7a6f9e3 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -72,7 +72,7 @@ const model = OpenAIChat.route generation: { maxTokens: 20, temperature: 0 }, }) .model({ id: "gpt-4o-mini" }) -const models = SessionRunnerModel.layerWith(() => Effect.succeed(model)) +const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model))) const systemContext = Layer.mock(SystemContextBuiltIns.Service, { load: () => Effect.succeed(SystemContext.empty) }) const instructionContext = Layer.mock(InstructionContext.Service, { load: () => Effect.succeed(SystemContext.empty) }) const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index d09b9ea34ca..af96096266f 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -162,7 +162,14 @@ const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: ec let modelResolveHook = Effect.void let currentModel = model const models = SessionRunnerModel.layerWith((session) => - modelResolveHook.pipe(Effect.as(session.model?.id === "replacement" ? replacementModel : currentModel)), + modelResolveHook.pipe( + Effect.as( + SessionRunnerModel.resolved( + session.model?.id === "replacement" ? replacementModel : currentModel, + session.model?.variant, + ), + ), + ), ) const systemContextKey = SystemContext.Key.make("test/context") let systemBaseline = "Initial context" diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index 3e945e3eace..ec8a9818b0b 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -36,7 +36,9 @@ const client = Layer.mock(LLMClient.Service)({ }, generate: () => Effect.die("unused"), }) -const models = Layer.mock(SessionRunnerModel.Service)({ resolve: () => Effect.succeed(model) }) +const models = Layer.mock(SessionRunnerModel.Service)({ + resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)), +}) const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, AgentV2.node, SessionTitle.node]),