Browse Source

fix(core): defer session model validation (#33377)

Kit Langton 2 months ago
parent
commit
f9f2280452

+ 11 - 64
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<Service, Interface>()("@opencode/public/OpenCode") {}
 
-class SessionModelValidation extends Context.Service<
-  SessionModelValidation,
-  {
-    readonly validate: (
-      input: Session.SwitchModelInput & { readonly location: Session.Info["location"] },
-    ) => Effect.Effect<void, Session.ModelUnavailableError | Session.VariantUnavailableError>
-  }
->()("@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.

+ 2 - 22
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<ModelUnavailableError>()(
-  "Session.ModelUnavailableError",
-  {
-    providerID: Model.Ref.fields.providerID,
-    modelID: Model.Ref.fields.id,
-  },
-) {}
-
-export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
-  "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<Info, NotFoundError>
   readonly list: (input?: ListInput) => Effect.Effect<Info[]>
   readonly prompt: (input: PromptInput) => Effect.Effect<Admission, NotFoundError | PromptConflictError>
-  readonly switchModel: (
-    input: SwitchModelInput,
-  ) => Effect.Effect<void, NotFoundError | ModelUnavailableError | VariantUnavailableError>
+  readonly switchModel: (input: SwitchModelInput) => Effect.Effect<void, NotFoundError>
   /** 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<void>
   readonly messages: (input: MessagesInput) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>

+ 58 - 12
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<ModelNotSelec
   },
 ) {}
 
+export class ModelUnavailableError extends Schema.TaggedErrorClass<ModelUnavailableError>()(
+  "SessionRunnerModel.ModelUnavailableError",
+  {
+    providerID: ProviderV2.ID,
+    modelID: ModelV2.ID,
+  },
+) {}
+
+export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
+  "SessionRunnerModel.VariantUnavailableError",
+  {
+    providerID: ProviderV2.ID,
+    modelID: ModelV2.ID,
+    variant: ModelV2.VariantID,
+  },
+) {}
+
 export class UnsupportedApiError extends Schema.TaggedErrorClass<UnsupportedApiError>()(
   "SessionRunnerModel.UnsupportedApiError",
   {
@@ -33,7 +50,7 @@ export class UnsupportedApiError extends Schema.TaggedErrorClass<UnsupportedApiE
   },
 ) {}
 
-export type Error = ModelNotSelectedError | UnsupportedApiError
+export type Error = ModelNotSelectedError | ModelUnavailableError | VariantUnavailableError | UnsupportedApiError
 
 export interface Interface {
   readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Model, Error>
@@ -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<ModelV2.Info, VariantUnavailableError> => {
   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,
         )

+ 55 - 1
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()),

+ 16 - 117
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) }),
-          })
-
-          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 })
+  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") }),
+      })
 
-          const error = yield* opencode.sessions
-            .switchModel({ sessionID, model: ref({ variant: "unknown" }) })
-            .pipe(Effect.flip)
+      yield* opencode.sessions.switchModel({ sessionID, model })
 
-          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" }],
-              },
-            },
-          },
-        },
-      }),
-    ),
-  )

+ 29 - 8
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" }, [