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

This commit is contained in:
Kit Langton
2026-05-13 22:23:09 +00:00
committed by GitHub
parent 5182a3698d
commit 55e0af1405
12 changed files with 340 additions and 69 deletions
@@ -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
View File
@@ -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
View File
@@ -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 Current patterns and examples live in [`guide.md`](./guide.md). Error
migration rules live in 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
View File
@@ -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<{ }) => Effect.Effect<
identifier: string {
whenToUse: string identifier: string
systemPrompt: string whenToUse: string
}> systemPrompt: string
},
Provider.ModelNotFoundError
>
} }
type State = Omit<Interface, "generate"> type State = Omit<Interface, "generate">
+7 -8
View File
@@ -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 providerModelNotFound = configData(input, "ProviderModelNotFoundError")
const data = (input as ErrorLike).data if (providerModelNotFound) {
const suggestions = Array.isArray(data?.suggestions) ? data.suggestions.filter((x) => typeof x === "string") : [] 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
View File
@@ -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
View File
@@ -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 getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect<Model, ModelNotFoundError>
readonly getLanguage: (model: Model) => Effect.Effect<LanguageModelV3> 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 provider = s.providers[model.providerID] return yield* EffectPromise.refineRejection(
const sdk = await resolveSDK(model, s, envs) async () => {
const sdk = await resolveSDK(model, s, envs)
try {
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) (cause) =>
throw new ModelNotFoundError( cause instanceof NoSuchModelError
{ ? new ModelNotFoundError({ modelID: model.id, providerID: model.providerID, cause })
modelID: model.id, : undefined,
providerID: model.providerID, )
},
{ cause: e },
)
throw e
}
})
}) })
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,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( return Effect.succeed(HttpServerResponse.jsonUnsafe(error.toObject(), { status: 500 }))
HttpServerResponse.jsonUnsafe(error.toObject(), {
status: iife(() => {
if (error instanceof Provider.ModelNotFoundError) return 400
return 500
}),
}),
)
} }
return Effect.succeed( return Effect.succeed(
HttpServerResponse.jsonUnsafe( HttpServerResponse.jsonUnsafe(
+5 -3
View File
@@ -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(agent.model.providerID, agent.model.modelID).pipe(Effect.orDie)
: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID) : 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
View File
@@ -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
View File
@@ -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("")
}) })
@@ -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.suggestions).toBeDefined()
expect(e.data.suggestions.length).toBeGreaterThan(0) 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.suggestions).toBeDefined()
expect(e.data.suggestions).toContain("anthropic") 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")
} }
}, },
}) })