From f9f22804526c88a89e28af846133c3c91e1d3803 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 22 Jun 2026 17:07:27 +0200 Subject: [PATCH] fix(core): defer session model validation (#33377) --- packages/core/src/public/opencode.ts | 75 ++-------- packages/core/src/public/session.ts | 24 +--- packages/core/src/session/runner/model.ts | 70 +++++++-- packages/core/test/location-layer.test.ts | 56 +++++++- packages/core/test/public-opencode.test.ts | 133 +++--------------- .../core/test/session-runner-model.test.ts | 37 +++-- 6 files changed, 171 insertions(+), 224 deletions(-) diff --git a/packages/core/src/public/opencode.ts b/packages/core/src/public/opencode.ts index 7388705d8c..7d32556340 100644 --- a/packages/core/src/public/opencode.ts +++ b/packages/core/src/public/opencode.ts @@ -1,11 +1,9 @@ export * as OpenCode from "./opencode" import { Context, Effect, Layer } from "effect" -import { Catalog } from "../catalog" import { Database } from "../database/database" import { EventV2 } from "../event" import { LocationServiceMap } from "../location-layer" -import { PluginBoot } from "../plugin/boot" import { ProjectV2 } from "../project" import { SessionV2 } from "../session" import * as SessionExecutionLocal from "../session/execution/local" @@ -23,69 +21,22 @@ export interface Interface { /** Intentional public native API for Effect applications embedding OpenCode. */ export class Service extends Context.Service()("@opencode/public/OpenCode") {} -class SessionModelValidation extends Context.Service< - SessionModelValidation, - { - readonly validate: ( - input: Session.SwitchModelInput & { readonly location: Session.Info["location"] }, - ) => Effect.Effect - } ->()("@opencode/public/OpenCode/SessionModelValidation") {} - -const ApplicationToolsLayer = ApplicationTools.layer -const LocationServicesLayer = LocationServiceMap.layer.pipe(Layer.provide(ApplicationToolsLayer)) -const SessionModelValidationLayer = Layer.effect( - SessionModelValidation, - Effect.gen(function* () { - const locations = yield* LocationServiceMap - return SessionModelValidation.of({ - validate: Effect.fn("OpenCode.sessions.validateModel")(function* (input) { - yield* Effect.gen(function* () { - yield* (yield* PluginBoot.Service).wait() - const catalog = yield* Catalog.Service - const model = (yield* catalog.model.available()).find( - (model) => model.providerID === input.model.providerID && model.id === input.model.id, - ) - if (!model) - return yield* new Session.ModelUnavailableError({ - providerID: input.model.providerID, - modelID: input.model.id, - }) - if ( - input.model.variant !== undefined && - input.model.variant !== "default" && - !model.variants.some((variant) => variant.id === input.model.variant) - ) - return yield* new Session.VariantUnavailableError({ - providerID: input.model.providerID, - modelID: input.model.id, - variant: input.model.variant, - }) - }).pipe(Effect.provide(locations.get(input.location))) - }), - }) - }), +const SessionsLayer = SessionV2.layer.pipe( + Layer.provide(SessionProjector.layer), + Layer.provide(SessionExecutionLocal.layer), + Layer.provide(SessionStore.layer), + Layer.provide(EventV2.layer), + Layer.provide(Database.defaultLayer), + Layer.provide(ProjectV2.defaultLayer), + Layer.provide(LocationServiceMap.layer.pipe(Layer.provide(ApplicationTools.layer))), + Layer.orDie, ) - -const SessionsLayer = Layer.merge( - SessionV2.layer.pipe( - Layer.provide(SessionProjector.layer), - Layer.provide(SessionExecutionLocal.layer), - Layer.provide(SessionStore.layer), - Layer.provide(EventV2.layer), - Layer.provide(Database.defaultLayer), - Layer.provide(ProjectV2.defaultLayer), - Layer.orDie, - ), - SessionModelValidationLayer, -).pipe(Layer.provide(LocationServicesLayer)) // TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence. export const layer = Layer.effect( Service, Effect.gen(function* () { const sessions = yield* SessionV2.Service const tools = yield* ApplicationTools.Service - const validation = yield* SessionModelValidation return Service.of({ tools: { register: tools.register }, sessions: { @@ -98,11 +49,7 @@ export const layer = Layer.effect( }), get: sessions.get, list: sessions.list, - switchModel: Effect.fn("OpenCode.sessions.switchModel")(function* (input) { - const session = yield* sessions.get(input.sessionID) - yield* validation.validate({ ...input, location: session.location }) - yield* sessions.switchModel(input) - }), + switchModel: sessions.switchModel, interrupt: sessions.interrupt, prompt: (input) => sessions.prompt({ @@ -124,6 +71,6 @@ export const layer = Layer.effect( }, }) }), -).pipe(Layer.provide(Layer.merge(ApplicationToolsLayer, SessionsLayer))) +).pipe(Layer.provide(Layer.merge(ApplicationTools.layer, SessionsLayer))) // TODO: Add OpenCode.create(...) as the Promise facade over the same native API semantics. diff --git a/packages/core/src/public/session.ts b/packages/core/src/public/session.ts index 2610cec004..212583b559 100644 --- a/packages/core/src/public/session.ts +++ b/packages/core/src/public/session.ts @@ -1,7 +1,6 @@ export * as Session from "./session" -import { Effect, Schema, Stream } from "effect" -import { ModelV2 } from "../model" +import { Effect, Stream } from "effect" import { SessionV2 } from "../session" import { MessageDecodeError } from "../session/error" import { SessionEvent } from "../session/event" @@ -41,23 +40,6 @@ export type NotFoundError = SessionV2.NotFoundError export const PromptConflictError = SessionV2.PromptConflictError export type PromptConflictError = SessionV2.PromptConflictError -export class ModelUnavailableError extends Schema.TaggedErrorClass()( - "Session.ModelUnavailableError", - { - providerID: Model.Ref.fields.providerID, - modelID: Model.Ref.fields.id, - }, -) {} - -export class VariantUnavailableError extends Schema.TaggedErrorClass()( - "Session.VariantUnavailableError", - { - providerID: Model.Ref.fields.providerID, - modelID: Model.Ref.fields.id, - variant: ModelV2.VariantID, - }, -) {} - export { MessageDecodeError } export interface CreateInput { @@ -104,9 +86,7 @@ export interface Interface { readonly get: (sessionID: ID) => Effect.Effect readonly list: (input?: ListInput) => Effect.Effect readonly prompt: (input: PromptInput) => Effect.Effect - readonly switchModel: ( - input: SwitchModelInput, - ) => Effect.Effect + readonly switchModel: (input: SwitchModelInput) => Effect.Effect /** Interrupt the active V2 execution chain for one Session on this process. Interrupting an idle or missing Session is a no-op. */ readonly interrupt: (sessionID: ID) => Effect.Effect readonly messages: (input: MessagesInput) => Effect.Effect diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 787c62c190..968933a6b5 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -5,7 +5,7 @@ import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-message import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat" import * as OpenAIResponses from "@opencode-ai/llm/protocols/openai-responses" import { Auth, type AnyRoute } from "@opencode-ai/llm/route" -import { Context, Effect, Layer, Option, Schema } from "effect" +import { Context, Effect, Layer, Schema } from "effect" import { produce } from "immer" import { Catalog } from "../../catalog" import { Credential } from "../../credential" @@ -24,6 +24,23 @@ export class ModelNotSelectedError extends Schema.TaggedErrorClass()( + "SessionRunnerModel.ModelUnavailableError", + { + providerID: ProviderV2.ID, + modelID: ModelV2.ID, + }, +) {} + +export class VariantUnavailableError extends Schema.TaggedErrorClass()( + "SessionRunnerModel.VariantUnavailableError", + { + providerID: ProviderV2.ID, + modelID: ModelV2.ID, + variant: ModelV2.VariantID, + }, +) {} + export class UnsupportedApiError extends Schema.TaggedErrorClass()( "SessionRunnerModel.UnsupportedApiError", { @@ -33,7 +50,7 @@ export class UnsupportedApiError extends Schema.TaggedErrorClass Effect.Effect @@ -70,13 +87,27 @@ const withDefaults = (model: ModelV2.Info, route: AnyRoute) => { }) } -const withVariant = (model: ModelV2.Info, variantID: ModelV2.VariantID | undefined) => { +const withVariant = ( + model: ModelV2.Info, + variantID: ModelV2.VariantID | undefined, +): Effect.Effect => { const id = variantID === "default" || variantID === undefined ? model.request.variant : variantID const variant = model.variants.find((item) => item.id === id) - if (!variant) return model - return produce(model, (draft) => { - ModelRequest.assign(draft.request, variant) - }) + if (!variant && variantID !== undefined && variantID !== "default") + return Effect.fail( + new VariantUnavailableError({ + providerID: model.providerID, + modelID: model.id, + variant: variantID, + }), + ) + return Effect.succeed( + variant + ? produce(model, (draft) => { + ModelRequest.assign(draft.request, variant) + }) + : model, + ) } const apiName = (model: ModelV2.Info) => @@ -124,8 +155,15 @@ export const fromCatalogModel = ( ) } -export const resolve = (session: SessionSchema.Info, model: ModelV2.Info) => - fromCatalogModel(withVariant(model, session.model?.variant)) +export const resolve = ( + session: SessionSchema.Info, + model: ModelV2.Info, + connection?: IntegrationConnection.Info, + credential?: Credential.Info, +) => + withVariant(model, session.model?.variant).pipe( + Effect.flatMap((model) => fromCatalogModel(model, connection, credential)), + ) export const supported = (model: ModelV2.Info) => model.api.type === "aisdk" && @@ -147,14 +185,22 @@ export const locationLayer = Layer.effect( yield* boot.wait() const defaultModel = session.model ? undefined : yield* catalog.model.default() const selected = session.model - ? yield* catalog.model.get(session.model.providerID, session.model.id) + ? (yield* catalog.model.available()).find( + (model) => model.providerID === session.model?.providerID && model.id === session.model.id, + ) : defaultModel && supported(defaultModel) ? defaultModel : (yield* catalog.model.available()).find(supported) + if (!selected && session.model) + return yield* new ModelUnavailableError({ + providerID: session.model.providerID, + modelID: session.model.id, + }) if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id }) const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID)) - return yield* fromCatalogModel( - withVariant(selected, session.model?.variant), + return yield* resolve( + session, + selected, connection, connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined, ) diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 21acc40ee7..0b3e0c8e54 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -1,16 +1,20 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { Deferred, Effect, Equal, Hash, Layer, Schema, Stream } from "effect" +import { DateTime, Deferred, Effect, Equal, Hash, Layer, Schema, Stream } from "effect" import { Tool } from "@opencode-ai/core/public" import { define } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { Location } from "@opencode-ai/core/location" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginBoot } from "@opencode-ai/core/plugin/boot" +import { ProjectV2 } from "@opencode-ai/core/project" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" import { toolDefinitions } from "./lib/tool" @@ -136,6 +140,56 @@ describe("LocationServiceMap", () => { ), ) + it.live("rejects an unavailable selected model during location model resolution", () => + 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) }) + yield* Effect.promise(() => + fs.writeFile( + path.join(dir.path, "opencode.json"), + JSON.stringify({ + providers: { + unavailable: { + name: "Unavailable", + api: { type: "native", settings: {} }, + models: { chat: { disabled: true } }, + }, + }, + }), + ), + ) + const failure = yield* SessionRunnerModel.Service.use((models) => + models.resolve( + SessionV2.Info.make({ + id: SessionV2.ID.make("ses_unavailable_model"), + projectID: ProjectV2.ID.global, + title: "test", + model: { + id: ModelV2.ID.make("chat"), + providerID: ProviderV2.ID.make("unavailable"), + }, + 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.get(location)), Effect.flip) + + expect(failure).toMatchObject({ + _tag: "SessionRunnerModel.ModelUnavailableError", + providerID: "unavailable", + modelID: "chat", + }) + }), + ), + ), + ) + it.live("installs public plugins into a location", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), diff --git a/packages/core/test/public-opencode.test.ts b/packages/core/test/public-opencode.test.ts index c5f90e92c4..fb9397e572 100644 --- a/packages/core/test/public-opencode.test.ts +++ b/packages/core/test/public-opencode.test.ts @@ -1,9 +1,6 @@ -import fs from "fs/promises" -import path from "path" import { describe, expect } from "bun:test" import { Effect, Schema } from "effect" import { AbsolutePath, Location, Model, OpenCode, Session, Tool } from "@opencode-ai/core/public" -import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" const it = testEffect(OpenCode.layer) @@ -41,94 +38,24 @@ describe("public native OpenCode API", () => { }), ) - it.effect("switches to an available model and variant", () => - Effect.acquireRelease( - Effect.promise(() => tmpdir()), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe( - Effect.flatMap((tmp) => - Effect.gen(function* () { - yield* writeProvider(tmp.path) - const opencode = yield* OpenCode.Service - const sessionID = Session.ID.make("ses_public_switch_available") - const model = ref({ variant: "fast" }) - yield* opencode.sessions.create({ - id: sessionID, - location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }), - }) + it.effect("records model selection without resolving the Location catalog", () => + Effect.gen(function* () { + const opencode = yield* OpenCode.Service + const sessionID = Session.ID.make("ses_public_switch_deferred") + const model = Schema.decodeUnknownSync(Model.Ref)({ + id: "missing", + providerID: "missing", + variant: "unknown", + }) + yield* opencode.sessions.create({ + id: sessionID, + location: Location.Ref.make({ directory: AbsolutePath.make("/public-session-switch-model") }), + }) - yield* opencode.sessions.switchModel({ sessionID, model }) + yield* opencode.sessions.switchModel({ sessionID, model }) - expect((yield* opencode.sessions.get(sessionID)).model).toEqual(model) - }), - ), - ), - ) - - it.effect("rejects missing and Location-disabled models without changing the Session", () => - Effect.acquireRelease( - Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), - (dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)), - ).pipe( - Effect.flatMap(([available, disabled]) => - Effect.gen(function* () { - yield* writeProvider(available.path) - yield* writeProvider(disabled.path, true) - const opencode = yield* OpenCode.Service - const availableID = Session.ID.make("ses_public_switch_exact_available") - const disabledID = Session.ID.make("ses_public_switch_exact_disabled") - yield* opencode.sessions.create({ - id: availableID, - location: Location.Ref.make({ directory: AbsolutePath.make(available.path) }), - }) - yield* opencode.sessions.create({ - id: disabledID, - location: Location.Ref.make({ directory: AbsolutePath.make(disabled.path) }), - }) - - yield* opencode.sessions.switchModel({ sessionID: availableID, model: ref({ variant: "default" }) }) - const disabledError = yield* opencode.sessions - .switchModel({ sessionID: disabledID, model: ref() }) - .pipe(Effect.flip) - const missingError = yield* opencode.sessions - .switchModel({ sessionID: disabledID, model: ref({ id: "missing" }) }) - .pipe(Effect.flip) - - expect(disabledError).toBeInstanceOf(Session.ModelUnavailableError) - expect(missingError).toBeInstanceOf(Session.ModelUnavailableError) - expect((yield* opencode.sessions.get(availableID)).model).toEqual(ref({ variant: "default" })) - expect((yield* opencode.sessions.get(disabledID)).model).toBeUndefined() - }), - ), - ), - ) - - it.effect("rejects an unavailable variant without changing the Session", () => - Effect.acquireRelease( - Effect.promise(() => tmpdir()), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe( - Effect.flatMap((tmp) => - Effect.gen(function* () { - yield* writeProvider(tmp.path) - const opencode = yield* OpenCode.Service - const sessionID = Session.ID.make("ses_public_switch_variant") - const selected = ref({ variant: "fast" }) - yield* opencode.sessions.create({ - id: sessionID, - location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }), - }) - yield* opencode.sessions.switchModel({ sessionID, model: selected }) - - const error = yield* opencode.sessions - .switchModel({ sessionID, model: ref({ variant: "unknown" }) }) - .pipe(Effect.flip) - - expect(error).toBeInstanceOf(Session.VariantUnavailableError) - expect((yield* opencode.sessions.get(sessionID)).model).toEqual(selected) - }), - ), - ), + expect((yield* opencode.sessions.get(sessionID)).model).toEqual(model) + }), ) it.effect("preserves the typed not-found error for a missing Session", () => @@ -147,31 +74,3 @@ describe("public native OpenCode API", () => { }), ) }) - -const ref = (input: { id?: string; variant?: string } = {}) => - Schema.decodeUnknownSync(Model.Ref)({ - id: input.id ?? "chat", - providerID: "public-test", - variant: input.variant, - }) - -const writeProvider = (directory: string, disabled = false) => - Effect.promise(() => - fs.writeFile( - path.join(directory, "opencode.json"), - JSON.stringify({ - providers: { - "public-test": { - name: "Public test", - api: { type: "native", settings: {} }, - models: { - chat: { - disabled, - variants: [{ id: "fast" }], - }, - }, - }, - }, - }), - ), - ) diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 50e60a3616..e67ade131b 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -43,14 +43,6 @@ const model = (api: Api, variants: ModelV2.Info["variants"] = []) => limit: { context: 100, output: 20 }, }) -const provider = (api: ProviderV2.Info["api"]) => - new ProviderV2.Info({ - id: ProviderV2.ID.make("test-provider"), - name: "Test provider", - api, - request: { headers: {}, body: {} }, - }) - describe("SessionRunnerModel", () => { it.effect("maps catalog OpenAI AI SDK models into native Responses routes", () => Effect.gen(function* () { @@ -194,6 +186,35 @@ describe("SessionRunnerModel", () => { }), ) + it.effect("rejects an explicit unavailable Session variant during model resolution", () => + Effect.gen(function* () { + const catalog = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }) + const session = SessionV2.Info.make({ + id: SessionV2.ID.make("ses_model_variant_unavailable"), + projectID: ProjectV2.ID.global, + title: "test", + model: { + id: catalog.id, + providerID: catalog.providerID, + variant: ModelV2.VariantID.make("unknown"), + }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + location: { directory: AbsolutePath.make("/project") }, + }) + + const failure = yield* SessionRunnerModel.resolve(session, catalog).pipe(Effect.flip) + + expect(failure).toMatchObject({ + _tag: "SessionRunnerModel.VariantUnavailableError", + providerID: "test-provider", + modelID: "test-model", + variant: "unknown", + }) + }), + ) + it.effect("lowers selected Anthropic Session variants into Messages options", () => Effect.gen(function* () { const catalog = model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://anthropic.example/v1" }, [