Преглед изворни кода

fix(provider): type model not found errors (#27334)

Kit Langton пре 4 месеци
родитељ
комит
55e0af1405

+ 235 - 0
packages/opencode/specs/effect/error-boundaries-plan.md

@@ -0,0 +1,235 @@
+# Error Boundaries Plan
+
+Plan for removing `NamedError` as connective tissue while keeping public
+wire contracts stable.
+
+## Desired Shape
+
+```text
+Domain/service error
+  Schema.TaggedErrorClass
+  - catchable with catchTag / catchTags
+  - appears in service method error type
+  - no HTTP status
+  - no toObject()
+
+HTTP public error
+  Schema.ErrorClass / TaggedErrorClass with httpApiStatus
+  - endpoint-declared public contract
+  - owns legacy { name, data } only when that is the SDK wire shape
+
+CLI/user rendering
+  FormatError and small format helpers
+  - converts domain errors to text
+  - preserves useful structured fields
+
+Session/model-visible error
+  first-class session/message error schema or helper
+  - owns { name, data } event/message shape
+  - not a service error class
+```
+
+The important rule: a service error should not also be the HTTP body, CLI
+formatter, and session event body. Each seam adapts the error into the
+shape it owns.
+
+## Concrete Example: Provider Model Not Found
+
+Before:
+
+```ts
+export const ModelNotFoundError = NamedError.create("ProviderModelNotFoundError", {
+  providerID: ProviderID,
+  modelID: ModelID,
+  suggestions: Schema.optional(Schema.Array(Schema.String)),
+})
+```
+
+Problems:
+
+- Throwing it inside `Effect.fn` made it behave like a defect unless a
+  compatibility bridge caught it.
+- HTTP middleware knew that this one domain error should be a `400`.
+- Callers read `.data.*`, which couples them to the legacy `{ name, data }`
+  wire shape.
+
+After:
+
+```ts
+export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("ProviderModelNotFoundError", {
+  providerID: ProviderID,
+  modelID: ModelID,
+  suggestions: Schema.optional(Schema.Array(Schema.String)),
+  cause: Schema.optional(Schema.Defect),
+}) {}
+
+export interface Interface {
+  readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect<Model, ModelNotFoundError>
+}
+```
+
+Boundary adapters:
+
+```text
+CLI
+└─ FormatError sees _tag ProviderModelNotFoundError -> nice text
+
+Session prompt
+└─ catch ModelNotFoundError -> publish Session.Event.Error as message/session wire shape
+
+HTTP route
+└─ catch ModelNotFoundError -> declared BadRequest public API error when the endpoint needs it
+
+HTTP middleware
+└─ no Provider.ModelNotFoundError knowledge
+```
+
+## Refining Known Promise Failures
+
+Use `EffectPromise.refineRejection(...)` when a Promise boundary can reject
+with many unknown values, but only one or two rejection classes are expected
+domain failures. Unknown rejections stay defects; the helper maps only known
+rejection shapes to typed errors.
+
+```ts
+const language =
+  yield *
+  EffectPromise.refineRejection(
+    async () => loadFromProvider(),
+    (cause) => (cause instanceof NoSuchModelError ? new ModelNotFoundError({ providerID, modelID, cause }) : undefined),
+  )
+```
+
+Use this when the Promise can genuinely reject and most rejection values are
+still defects for the current module. Use `Effect.tryPromise({ try, catch })`
+when every rejection should become the same expected error type. Use
+`Effect.promise(...)` only when rejection means a defect and you do not need
+to refine known rejection classes.
+
+## Helper Modules We Probably Want
+
+Add helpers only when repeated call sites prove the seam is real.
+
+### HTTP API Errors
+
+Likely location: `src/server/routes/instance/httpapi/errors.ts`.
+
+Purpose:
+
+- construct public HTTP error bodies
+- preserve legacy `{ name, data }` where needed
+- attach `httpApiStatus`
+
+Good helpers:
+
+```ts
+notFound(message)
+badRequest(message)
+unknown()
+```
+
+Avoid:
+
+```ts
+mapAnyDomainError(error)
+```
+
+That recreates the giant middleware mapper problem.
+
+### Session / Message Error Wire Helpers
+
+Likely location: near `src/session/message-error.ts` or a new narrow
+module such as `src/session/event-error.ts`.
+
+Purpose:
+
+- construct the `{ name, data }` shape used by `Session.Event.Error` and
+  assistant message errors
+- replace `new NamedError.Unknown(...).toObject()` call sites
+- keep model-visible error bodies separate from service/domain errors
+
+Good helpers:
+
+```ts
+unknown(message)
+agentNotFound(agent, available)
+commandNotFound(command, available)
+modelNotFound(error: Provider.ModelNotFoundError)
+```
+
+### CLI Formatters
+
+Likely location: `src/cli/error.ts` until repetition demands domain-local
+format helpers.
+
+Purpose:
+
+- produce human-readable terminal messages from typed errors
+- support old `{ name, data }` shapes only while compatibility is needed
+
+## Migration Queue
+
+### Remove Domain Knowledge From HTTP Middleware
+
+- [x] Storage not found no longer maps through defect fallback.
+- [x] Worktree expected errors moved to typed errors.
+- [x] Provider auth expected errors moved to typed errors.
+- [x] Provider model not found no longer needs an HTTP middleware status
+      special case.
+- [ ] Convert `Session.BusyError` and map it at route boundaries.
+- [ ] Delete the broad `NamedError` middleware branch once no route relies
+      on defect-wrapped legacy domain errors.
+- [ ] Keep one final unknown-defect fallback that logs `Cause.pretty(cause)`
+      and returns a safe `500` body.
+
+### Remaining `NamedError.create(...)` Service Errors
+
+These should become `Schema.TaggedErrorClass` when touched:
+
+- [ ] `src/provider/provider.ts` — `ProviderInitError`.
+- [ ] `src/storage/db.ts` — database `NotFoundError`.
+- [ ] `src/mcp/index.ts` — `MCPFailed`.
+- [ ] `src/skill/index.ts` — `SkillInvalidError`,
+      `SkillNameMismatchError`.
+- [ ] `src/lsp/client.ts` — `LSPInitializeError`.
+- [ ] `src/ide/index.ts` — install errors.
+- [ ] `src/config/error.ts`, `src/config/config.ts`,
+      `src/config/markdown.ts` — config errors. These already render well
+      in the CLI, so migrate carefully and preserve diagnostics.
+
+### Session / Message Wire Errors
+
+These are not ordinary service errors. They mostly build `{ name, data }`
+objects for model-visible/session-visible output.
+
+- [ ] Add a first-class session/message error wire helper.
+- [ ] Replace `new NamedError.Unknown(...).toObject()` in
+      `src/session/prompt.ts`.
+- [ ] Replace `new NamedError.Unknown(...).toObject()` in config/skill/plugin
+      session event publishing.
+- [ ] Move `src/session/message-error.ts` and `src/session/message-v2.ts`
+      away from `NamedError.create(...)` once the wire helper exists.
+- [ ] Update retry/message tests to assert the wire schema/helper output,
+      not `NamedError` instances.
+
+### CLI Rendering
+
+- [x] Tagged config errors render with useful diagnostics.
+- [x] Provider model not found renders from both old `{ name, data }` and
+      new `_tag` shapes.
+- [ ] Add typed render cases as more `NamedError.create(...)` domains move
+      to `Schema.TaggedErrorClass`.
+- [ ] Eventually remove old-shape compatibility branches when no callers can
+      produce them.
+
+## PR Checklist
+
+For each migrated error:
+
+- [ ] Domain error is `Schema.TaggedErrorClass`.
+- [ ] Service method exposes the typed error in its error channel.
+- [ ] No service error has `toObject()` just for compatibility.
+- [ ] CLI, HTTP, and session/message adapters each own their output shape.
+- [ ] HTTP middleware gets smaller or stays unchanged.
+- [ ] Focused tests cover the domain error and any public rendering/wire
+      shape touched by the PR.

+ 3 - 0
packages/opencode/specs/effect/errors.md

@@ -4,6 +4,9 @@ This note expands the `ERR`, `RENDER`, and `HTTP` tracks from
 [`todo.md`](./todo.md). It is the current reference for expected failures,
 [`todo.md`](./todo.md). It is the current reference for expected failures,
 typed service errors, and HTTP error boundaries.
 typed service errors, and HTTP error boundaries.
 
 
+For the migration architecture and queue, see
+[`error-boundaries-plan.md`](./error-boundaries-plan.md).
+
 ## Goal
 ## Goal
 
 
 - Expected service failures live on the Effect error channel.
 - Expected service failures live on the Effect error channel.

+ 3 - 2
packages/opencode/specs/effect/todo.md

@@ -2,8 +2,9 @@
 
 
 Short roadmap for Effect cleanup in `packages/opencode`.
 Short roadmap for Effect cleanup in `packages/opencode`.
 
 
-Current patterns and examples live in [`guide.md`](./guide.md). Test
-migration rules live in
+Current patterns and examples live in [`guide.md`](./guide.md). Error
+boundary migration details live in
+[`error-boundaries-plan.md`](./error-boundaries-plan.md). Test migration rules live in
 [`test/EFFECT_TEST_MIGRATION.md`](../../test/EFFECT_TEST_MIGRATION.md).
 [`test/EFFECT_TEST_MIGRATION.md`](../../test/EFFECT_TEST_MIGRATION.md).
 Older deep-dive notes in this directory may still be useful, but treat
 Older deep-dive notes in this directory may still be useful, but treat
 this roadmap and the guide as the current entry points.
 this roadmap and the guide as the current entry points.

+ 8 - 5
packages/opencode/src/agent/agent.ts

@@ -62,11 +62,14 @@ export interface Interface {
   readonly generate: (input: {
   readonly generate: (input: {
     description: string
     description: string
     model?: { providerID: ProviderID; modelID: ModelID }
     model?: { providerID: ProviderID; modelID: ModelID }
-  }) => Effect.Effect<{
-    identifier: string
-    whenToUse: string
-    systemPrompt: string
-  }>
+  }) => Effect.Effect<
+    {
+      identifier: string
+      whenToUse: string
+      systemPrompt: string
+    },
+    Provider.ModelNotFoundError
+  >
 }
 }
 
 
 type State = Omit<Interface, "generate">
 type State = Omit<Interface, "generate">

+ 7 - 8
packages/opencode/src/cli/error.ts

@@ -1,5 +1,6 @@
 import { NamedError } from "@opencode-ai/core/util/error"
 import { NamedError } from "@opencode-ai/core/util/error"
 import { errorFormat } from "@/util/error"
 import { errorFormat } from "@/util/error"
+import { isRecord } from "@/util/record"
 
 
 interface ErrorLike {
 interface ErrorLike {
   name?: string
   name?: string
@@ -10,10 +11,6 @@ interface ErrorLike {
 
 
 type ConfigIssue = { message: string; path: string[] }
 type ConfigIssue = { message: string; path: string[] }
 
 
-function isRecord(input: unknown): input is Record<string, unknown> {
-  return typeof input === "object" && input !== null
-}
-
 function isTaggedError(error: unknown, tag: string): boolean {
 function isTaggedError(error: unknown, tag: string): boolean {
   return isRecord(error) && error._tag === tag
   return isRecord(error) && error._tag === tag
 }
 }
@@ -61,11 +58,13 @@ export function FormatError(input: unknown) {
   }
   }
 
 
   // ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
   // ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
-  if (NamedError.hasName(input, "ProviderModelNotFoundError")) {
-    const data = (input as ErrorLike).data
-    const suggestions = Array.isArray(data?.suggestions) ? data.suggestions.filter((x) => typeof x === "string") : []
+  const providerModelNotFound = configData(input, "ProviderModelNotFoundError")
+  if (providerModelNotFound) {
+    const suggestions = Array.isArray(providerModelNotFound.suggestions)
+      ? providerModelNotFound.suggestions.filter((x) => typeof x === "string")
+      : []
     return [
     return [
-      `Model not found: ${data?.providerID}/${data?.modelID}`,
+      `Model not found: ${providerModelNotFound.providerID}/${providerModelNotFound.modelID}`,
       ...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
       ...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
       `Try: \`opencode models\` to list available models`,
       `Try: \`opencode models\` to list available models`,
       `Or check your config (opencode.json) provider/model names`,
       `Or check your config (opencode.json) provider/model names`,

+ 17 - 0
packages/opencode/src/effect/promise.ts

@@ -0,0 +1,17 @@
+import { Cause, Effect } from "effect"
+
+export function refineRejection<A, E>(
+  evaluate: (signal: AbortSignal) => PromiseLike<A>,
+  refine: (cause: unknown) => E | undefined,
+) {
+  return Effect.tryPromise(evaluate).pipe(
+    Effect.catch((error) => {
+      const cause = Cause.isUnknownError(error) ? error.cause : error
+      const refined = refine(cause)
+      if (refined !== undefined) return Effect.fail(refined)
+      return Effect.die(cause)
+    }),
+  )
+}
+
+export * as EffectPromise from "./promise"

+ 33 - 32
packages/opencode/src/provider/provider.ts

@@ -21,6 +21,7 @@ import { pathToFileURL } from "url"
 import { Effect, Layer, Context, Schema, Types } from "effect"
 import { Effect, Layer, Context, Schema, Types } from "effect"
 import { EffectBridge } from "@/effect/bridge"
 import { EffectBridge } from "@/effect/bridge"
 import { InstanceState } from "@/effect/instance-state"
 import { InstanceState } from "@/effect/instance-state"
+import { EffectPromise } from "@/effect/promise"
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
 import { isRecord } from "@/util/record"
 import { isRecord } from "@/util/record"
 import { optionalOmitUndefined } from "@opencode-ai/core/schema"
 import { optionalOmitUndefined } from "@opencode-ai/core/schema"
@@ -963,11 +964,24 @@ export function defaultModelIDs<T extends { models: Record<string, { id: string
   return mapValues(providers, (item) => sort(Object.values(item.models))[0].id)
   return mapValues(providers, (item) => sort(Object.values(item.models))[0].id)
 }
 }
 
 
+export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("ProviderModelNotFoundError", {
+  providerID: ProviderID,
+  modelID: ModelID,
+  suggestions: Schema.optional(Schema.Array(Schema.String)),
+  cause: Schema.optional(Schema.Defect),
+}) {
+  static isInstance(input: unknown): input is ModelNotFoundError {
+    return input instanceof ModelNotFoundError
+  }
+}
+
+export type Error = ModelNotFoundError
+
 export interface Interface {
 export interface Interface {
   readonly list: () => Effect.Effect<Record<ProviderID, Info>>
   readonly list: () => Effect.Effect<Record<ProviderID, Info>>
   readonly getProvider: (providerID: ProviderID) => Effect.Effect<Info>
   readonly getProvider: (providerID: ProviderID) => Effect.Effect<Info>
-  readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect<Model>
-  readonly getLanguage: (model: Model) => Effect.Effect<LanguageModelV3>
+  readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect<Model, ModelNotFoundError>
+  readonly getLanguage: (model: Model) => Effect.Effect<LanguageModelV3, ModelNotFoundError>
   readonly closest: (
   readonly closest: (
     providerID: ProviderID,
     providerID: ProviderID,
     query: string[],
     query: string[],
@@ -1638,14 +1652,14 @@ const layer = Layer.effect(
           : fuzzysort
           : fuzzysort
               .go(providerID, Object.keys({ ...s.catalog, ...s.providers }), { limit: 3, threshold: -10000 })
               .go(providerID, Object.keys({ ...s.catalog, ...s.providers }), { limit: 3, threshold: -10000 })
               .map((m) => m.target)
               .map((m) => m.target)
-        throw new ModelNotFoundError({ providerID, modelID, suggestions })
+        return yield* new ModelNotFoundError({ providerID, modelID, suggestions })
       }
       }
 
 
       const info = provider.models[modelID]
       const info = provider.models[modelID]
       if (!info) {
       if (!info) {
         const current = modelSuggestions(provider, modelID)
         const current = modelSuggestions(provider, modelID)
         const suggestions = current.length ? current : modelSuggestions(s.catalog[providerID], modelID)
         const suggestions = current.length ? current : modelSuggestions(s.catalog[providerID], modelID)
-        throw new ModelNotFoundError({ providerID, modelID, suggestions })
+        return yield* new ModelNotFoundError({ providerID, modelID, suggestions })
       }
       }
       return info
       return info
     })
     })
@@ -1656,11 +1670,10 @@ const layer = Layer.effect(
       const key = `${model.providerID}/${model.id}`
       const key = `${model.providerID}/${model.id}`
       if (s.models.has(key)) return s.models.get(key)!
       if (s.models.has(key)) return s.models.get(key)!
 
 
-      return yield* Effect.promise(async () => {
-        const provider = s.providers[model.providerID]
-        const sdk = await resolveSDK(model, s, envs)
-
-        try {
+      const provider = s.providers[model.providerID]
+      return yield* EffectPromise.refineRejection(
+        async () => {
+          const sdk = await resolveSDK(model, s, envs)
           const language = s.modelLoaders[model.providerID]
           const language = s.modelLoaders[model.providerID]
             ? await s.modelLoaders[model.providerID](sdk, model.api.id, {
             ? await s.modelLoaders[model.providerID](sdk, model.api.id, {
                 ...provider.options,
                 ...provider.options,
@@ -1669,18 +1682,12 @@ const layer = Layer.effect(
             : sdk.languageModel(model.api.id)
             : sdk.languageModel(model.api.id)
           s.models.set(key, language)
           s.models.set(key, language)
           return language
           return language
-        } catch (e) {
-          if (e instanceof NoSuchModelError)
-            throw new ModelNotFoundError(
-              {
-                modelID: model.id,
-                providerID: model.providerID,
-              },
-              { cause: e },
-            )
-          throw e
-        }
-      })
+        },
+        (cause) =>
+          cause instanceof NoSuchModelError
+            ? new ModelNotFoundError({ modelID: model.id, providerID: model.providerID, cause })
+            : undefined,
+      )
     })
     })
 
 
     const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) {
     const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) {
@@ -1700,7 +1707,7 @@ const layer = Layer.effect(
 
 
       if (cfg.small_model) {
       if (cfg.small_model) {
         const parsed = parseModel(cfg.small_model)
         const parsed = parseModel(cfg.small_model)
-        return yield* getModel(parsed.providerID, parsed.modelID)
+        return yield* getModel(parsed.providerID, parsed.modelID).pipe(Effect.orDie)
       }
       }
 
 
       const s = yield* InstanceState.get(state)
       const s = yield* InstanceState.get(state)
@@ -1728,22 +1735,22 @@ const layer = Layer.effect(
           const candidates = Object.keys(provider.models).filter((m) => m.includes(item))
           const candidates = Object.keys(provider.models).filter((m) => m.includes(item))
 
 
           const globalMatch = candidates.find((m) => m.startsWith("global."))
           const globalMatch = candidates.find((m) => m.startsWith("global."))
-          if (globalMatch) return yield* getModel(providerID, ModelID.make(globalMatch))
+          if (globalMatch) return yield* getModel(providerID, ModelID.make(globalMatch)).pipe(Effect.orDie)
 
 
           const region = provider.options?.region
           const region = provider.options?.region
           if (region) {
           if (region) {
             const regionPrefix = region.split("-")[0]
             const regionPrefix = region.split("-")[0]
             if (regionPrefix === "us" || regionPrefix === "eu") {
             if (regionPrefix === "us" || regionPrefix === "eu") {
               const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`))
               const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`))
-              if (regionalMatch) return yield* getModel(providerID, ModelID.make(regionalMatch))
+              if (regionalMatch) return yield* getModel(providerID, ModelID.make(regionalMatch)).pipe(Effect.orDie)
             }
             }
           }
           }
 
 
           const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p)))
           const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p)))
-          if (unprefixed) return yield* getModel(providerID, ModelID.make(unprefixed))
+          if (unprefixed) return yield* getModel(providerID, ModelID.make(unprefixed)).pipe(Effect.orDie)
         } else {
         } else {
           for (const model of Object.keys(provider.models)) {
           for (const model of Object.keys(provider.models)) {
-            if (model.includes(item)) return yield* getModel(providerID, ModelID.make(model))
+            if (model.includes(item)) return yield* getModel(providerID, ModelID.make(model)).pipe(Effect.orDie)
           }
           }
         }
         }
       }
       }
@@ -1818,12 +1825,6 @@ export function parseModel(model: string) {
   }
   }
 }
 }
 
 
-export const ModelNotFoundError = NamedError.create("ProviderModelNotFoundError", {
-  providerID: ProviderID,
-  modelID: ModelID,
-  suggestions: Schema.optional(Schema.Array(Schema.String)),
-})
-
 export const InitError = NamedError.create("ProviderInitError", {
 export const InitError = NamedError.create("ProviderInitError", {
   providerID: ProviderID,
   providerID: ProviderID,
 })
 })

+ 1 - 10
packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts

@@ -1,5 +1,3 @@
-import { Provider } from "@/provider/provider"
-import { iife } from "@/util/iife"
 import { NamedError } from "@opencode-ai/core/util/error"
 import { NamedError } from "@opencode-ai/core/util/error"
 import * as Log from "@opencode-ai/core/util/log"
 import * as Log from "@opencode-ai/core/util/log"
 import { Cause, Effect } from "effect"
 import { Cause, Effect } from "effect"
@@ -23,14 +21,7 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect)
       log.error("failed", { error, cause: Cause.pretty(cause) })
       log.error("failed", { error, cause: Cause.pretty(cause) })
 
 
       if (error instanceof NamedError) {
       if (error instanceof NamedError) {
-        return Effect.succeed(
-          HttpServerResponse.jsonUnsafe(error.toObject(), {
-            status: iife(() => {
-              if (error instanceof Provider.ModelNotFoundError) return 400
-              return 500
-            }),
-          }),
-        )
+        return Effect.succeed(HttpServerResponse.jsonUnsafe(error.toObject(), { status: 500 }))
       }
       }
       return Effect.succeed(
       return Effect.succeed(
         HttpServerResponse.jsonUnsafe(
         HttpServerResponse.jsonUnsafe(

+ 5 - 3
packages/opencode/src/session/compaction.ts

@@ -390,8 +390,8 @@ export const layer: Layer.Layer<
 
 
       const agent = yield* agents.get("compaction")
       const agent = yield* agents.get("compaction")
       const model = agent.model
       const model = agent.model
-        ? yield* provider.getModel(agent.model.providerID, agent.model.modelID)
-        : yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
+        ? yield* provider.getModel(agent.model.providerID, agent.model.modelID).pipe(Effect.orDie)
+        : yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID).pipe(Effect.orDie)
       const cfg = yield* config.get()
       const cfg = yield* config.get()
       const history = compactionPart && messages.at(-1)?.info.id === input.parentID ? messages.slice(0, -1) : messages
       const history = compactionPart && messages.at(-1)?.info.id === input.parentID ? messages.slice(0, -1) : messages
       const prior = completedCompactions(history)
       const prior = completedCompactions(history)
@@ -519,7 +519,9 @@ export const layer: Layer.Layer<
               {
               {
                 sessionID: input.sessionID,
                 sessionID: input.sessionID,
                 agent: userMessage.agent,
                 agent: userMessage.agent,
-                model: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID),
+                model: yield* provider
+                  .getModel(userMessage.model.providerID, userMessage.model.modelID)
+                  .pipe(Effect.orDie),
                 provider: {
                 provider: {
                   source: info.source,
                   source: info.source,
                   info,
                   info,

+ 6 - 4
packages/opencode/src/session/prompt.ts

@@ -1057,15 +1057,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
       if (Exit.isSuccess(exit)) return exit.value
       if (Exit.isSuccess(exit)) return exit.value
       const err = Cause.squash(exit.cause)
       const err = Cause.squash(exit.cause)
       if (Provider.ModelNotFoundError.isInstance(err)) {
       if (Provider.ModelNotFoundError.isInstance(err)) {
-        const hint = err.data.suggestions?.length ? ` Did you mean: ${err.data.suggestions.join(", ")}?` : ""
+        const hint = err.suggestions?.length ? ` Did you mean: ${err.suggestions.join(", ")}?` : ""
         yield* bus.publish(Session.Event.Error, {
         yield* bus.publish(Session.Event.Error, {
           sessionID,
           sessionID,
           error: new NamedError.Unknown({
           error: new NamedError.Unknown({
-            message: `Model not found: ${err.data.providerID}/${err.data.modelID}.${hint}`,
+            message: `Model not found: ${err.providerID}/${err.modelID}.${hint}`,
           }).toObject(),
           }).toObject(),
         })
         })
       }
       }
-      return yield* Effect.failCause(exit.cause)
+      return yield* Effect.die(err)
     })
     })
 
 
     const currentModel = Effect.fnUntraced(function* (sessionID: SessionID) {
     const currentModel = Effect.fnUntraced(function* (sessionID: SessionID) {
@@ -1108,7 +1108,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
       const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID
       const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID
       const full =
       const full =
         !input.variant && ag.variant && same
         !input.variant && ag.variant && same
-          ? yield* provider.getModel(model.providerID, model.modelID).pipe(Effect.catchDefect(() => Effect.void))
+          ? yield* provider
+              .getModel(model.providerID, model.modelID)
+              .pipe(Effect.catchIf(Provider.ModelNotFoundError.isInstance, () => Effect.succeed(undefined)))
           : undefined
           : undefined
       const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined)
       const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined)
 
 

+ 17 - 0
packages/opencode/test/cli/error.test.ts

@@ -64,6 +64,23 @@ describe("cli.error", () => {
     expect(formatted).toContain("Check your network, proxy, or VPN configuration and try again.")
     expect(formatted).toContain("Check your network, proxy, or VPN configuration and try again.")
   })
   })
 
 
+  test("formats legacy and tagged provider model errors the same way", () => {
+    const data = {
+      providerID: "anthropic",
+      modelID: "claude-sonet-4",
+      suggestions: ["claude-sonnet-4"],
+    }
+    const expected = [
+      "Model not found: anthropic/claude-sonet-4",
+      "Did you mean: claude-sonnet-4",
+      "Try: `opencode models` to list available models",
+      "Or check your config (opencode.json) provider/model names",
+    ].join("\n")
+
+    expect(FormatError({ name: "ProviderModelNotFoundError", data })).toBe(expected)
+    expect(FormatError({ _tag: "ProviderModelNotFoundError", ...data })).toBe(expected)
+  })
+
   test("formats cancelled UI errors as empty output", () => {
   test("formats cancelled UI errors as empty output", () => {
     expect(FormatError(new UI.CancelledError())).toBe("")
     expect(FormatError(new UI.CancelledError())).toBe("")
   })
   })

+ 5 - 5
packages/opencode/test/provider/provider.test.ts

@@ -1572,8 +1572,8 @@ test("ModelNotFoundError includes suggestions for typos", async () => {
         await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet
         await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet
         expect(true).toBe(false) // Should not reach here
         expect(true).toBe(false) // Should not reach here
       } catch (e: any) {
       } catch (e: any) {
-        expect(e.data.suggestions).toBeDefined()
-        expect(e.data.suggestions.length).toBeGreaterThan(0)
+        expect(e.suggestions).toBeDefined()
+        expect(e.suggestions.length).toBeGreaterThan(0)
       }
       }
     },
     },
   })
   })
@@ -1598,8 +1598,8 @@ test("ModelNotFoundError for provider includes suggestions", async () => {
         await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic
         await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic
         expect(true).toBe(false) // Should not reach here
         expect(true).toBe(false) // Should not reach here
       } catch (e: any) {
       } catch (e: any) {
-        expect(e.data.suggestions).toBeDefined()
-        expect(e.data.suggestions).toContain("anthropic")
+        expect(e.suggestions).toBeDefined()
+        expect(e.suggestions).toContain("anthropic")
       }
       }
     },
     },
   })
   })
@@ -1625,7 +1625,7 @@ test("ModelNotFoundError suggests catalog models for unloaded providers", async
         throw new Error("expected model lookup to fail")
         throw new Error("expected model lookup to fail")
       } catch (e) {
       } catch (e) {
         if (!Provider.ModelNotFoundError.isInstance(e)) throw e
         if (!Provider.ModelNotFoundError.isInstance(e)) throw e
-        expect(e.data.suggestions).toContain("claude-haiku-4-5")
+        expect(e.suggestions).toContain("claude-haiku-4-5")
       }
       }
     },
     },
   })
   })