Skip to content

Commit 0df1aeb

Browse files
committed
fix(core): name the configured model when it cannot be used
When no model resolves and config names a default, fail with `Model unavailable: <provider>/<model>`, as `--model` does, instead of `No model is available for session …`, so the user knows which entry to fix. Also document that providers removed by `disabled_providers` or a `provider.use` deny, and unknown provider IDs, no longer fall back.
1 parent 7827a33 commit 0df1aeb

3 files changed

Lines changed: 28 additions & 4 deletions

File tree

‎packages/core/src/model.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ export interface Interface extends State.Transformable<Editor> {
8181
/** Enabled models of the given types, chat when none are given. Only chat models are offered for sessions. */
8282
readonly available: (types?: readonly Model.Type[]) => Effect.Effect<readonly Info[]>
8383
readonly default: () => Effect.Effect<Info | undefined>
84+
/** The default model named by config or a plugin, whether or not it is available. */
85+
readonly configured: () => Effect.Effect<{ providerID: Provider.ID; modelID: ID } | undefined>
8486
readonly small: (providerID: Provider.ID) => Effect.Effect<Info | undefined>
8587
}
8688

@@ -303,12 +305,16 @@ const layer = Layer.effect(
303305
const value = yield* read()
304306
const requested = value.data.defaultModel
305307
if (!requested) return value.available.find(supportsText)
306-
// A provider that was never registered (for example, its config entry failed the schema) resolves to nothing,
307-
// so prompts never go to a provider the user did not choose. Registered but unavailable providers still fall back.
308+
// A configured provider that is not registered (its config entry failed the schema, `disabled_providers` or a
309+
// `provider.use` deny removed it, or `model` names an unknown ID) resolves to nothing, so prompts never go to a
310+
// provider the user did not choose. Registered providers without a connection still fall back.
308311
if (!(yield* providers.snapshot()).records.has(requested.providerID)) return undefined
309312
const model = value.byProvider.get(requested.providerID)?.get(requested.modelID)
310313
return model?.enabled ? model : value.available.find(supportsText)
311314
}),
315+
configured: Effect.fn("Model.configured")(function* () {
316+
return (yield* read()).data.defaultModel
317+
}),
312318
small: Effect.fn("Model.small")(function* (providerID) {
313319
const value = yield* read()
314320
const models = value.available.filter(

‎packages/core/src/session/runner/model.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ export * as SessionRunnerModel from "./model.js"
22

33
import { makeLocationNode } from "@opencode/util/effect/app-node"
44
import { LanguageModel } from "@opencode/ai/effect"
5-
import { Model } from "@opencode/schema/model"
5+
import { Model } from "../../model.js"
66
import { Provider } from "@opencode/schema/provider"
77
import { Context, Effect, Layer, Schema } from "effect"
88
import type { SessionRequestKind } from "@opencode/plugin/effect/session"
@@ -75,6 +75,7 @@ const layer = Layer.effect(
7575
Service,
7676
Effect.gen(function* () {
7777
const resolver = yield* ModelResolver.Service
78+
const models = yield* Model.Service
7879
const agents = yield* Agent.Service
7980
const hooks = yield* PluginHooks.Service
8081
// Location plugins populate and filter the catalog asynchronously during layer startup.
@@ -96,6 +97,9 @@ const layer = Layer.effect(
9697
if (preferred) return { info: preferred, variant: agent.variant }
9798
const info = yield* resolver.fallback()
9899
if (info) return { info, variant: undefined }
100+
// Name the configured model so the user knows which config entry to fix.
101+
const configured = yield* models.configured()
102+
if (configured) return yield* new ModelUnavailableError(configured)
99103
return yield* new ModelNotSelectedError({ sessionID: session.id })
100104
})
101105
return Service.of({
@@ -126,5 +130,5 @@ const layer = Layer.effect(
126130
export const node = makeLocationNode({
127131
service: Service,
128132
layer,
129-
deps: [ModelResolver.node, Agent.node, PluginHooks.node],
133+
deps: [ModelResolver.node, Model.node, Agent.node, PluginHooks.node],
130134
})

‎packages/core/test/model-free.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,5 +108,19 @@ it.effect("does not fall back to another provider when the configured default's
108108
expect(yield* models.default()).toBeUndefined()
109109
expect(yield* resolver.fallback()).toBeUndefined()
110110
expect(yield* resolver.resolve()).toBeUndefined()
111+
const runner = yield* SessionRunnerModel.Service
112+
const session = Session.Info.make({
113+
id: Session.ID.make("ses_missing_provider"),
114+
projectID: Project.ID.global,
115+
title: "test",
116+
cost: Money.USD.zero,
117+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
118+
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
119+
location: ref,
120+
})
121+
const failure = yield* runner
122+
.resolve(session, models.available, { kind: "primary", agent: Agent.ID.make("build") })
123+
.pipe(Effect.flip)
124+
expect(failure.message).toBe("Model unavailable: probe/probe-1")
111125
}).pipe(Effect.provide(layer)),
112126
)

0 commit comments

Comments
 (0)