Skip to content

Commit f9f2280

Browse files
authored
fix(core): defer session model validation (#33377)
1 parent d5980b4 commit f9f2280

6 files changed

Lines changed: 171 additions & 224 deletions

File tree

Lines changed: 11 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
export * as OpenCode from "./opencode"
22

33
import { Context, Effect, Layer } from "effect"
4-
import { Catalog } from "../catalog"
54
import { Database } from "../database/database"
65
import { EventV2 } from "../event"
76
import { LocationServiceMap } from "../location-layer"
8-
import { PluginBoot } from "../plugin/boot"
97
import { ProjectV2 } from "../project"
108
import { SessionV2 } from "../session"
119
import * as SessionExecutionLocal from "../session/execution/local"
@@ -23,69 +21,22 @@ export interface Interface {
2321
/** Intentional public native API for Effect applications embedding OpenCode. */
2422
export class Service extends Context.Service<Service, Interface>()("@opencode/public/OpenCode") {}
2523

26-
class SessionModelValidation extends Context.Service<
27-
SessionModelValidation,
28-
{
29-
readonly validate: (
30-
input: Session.SwitchModelInput & { readonly location: Session.Info["location"] },
31-
) => Effect.Effect<void, Session.ModelUnavailableError | Session.VariantUnavailableError>
32-
}
33-
>()("@opencode/public/OpenCode/SessionModelValidation") {}
34-
35-
const ApplicationToolsLayer = ApplicationTools.layer
36-
const LocationServicesLayer = LocationServiceMap.layer.pipe(Layer.provide(ApplicationToolsLayer))
37-
const SessionModelValidationLayer = Layer.effect(
38-
SessionModelValidation,
39-
Effect.gen(function* () {
40-
const locations = yield* LocationServiceMap
41-
return SessionModelValidation.of({
42-
validate: Effect.fn("OpenCode.sessions.validateModel")(function* (input) {
43-
yield* Effect.gen(function* () {
44-
yield* (yield* PluginBoot.Service).wait()
45-
const catalog = yield* Catalog.Service
46-
const model = (yield* catalog.model.available()).find(
47-
(model) => model.providerID === input.model.providerID && model.id === input.model.id,
48-
)
49-
if (!model)
50-
return yield* new Session.ModelUnavailableError({
51-
providerID: input.model.providerID,
52-
modelID: input.model.id,
53-
})
54-
if (
55-
input.model.variant !== undefined &&
56-
input.model.variant !== "default" &&
57-
!model.variants.some((variant) => variant.id === input.model.variant)
58-
)
59-
return yield* new Session.VariantUnavailableError({
60-
providerID: input.model.providerID,
61-
modelID: input.model.id,
62-
variant: input.model.variant,
63-
})
64-
}).pipe(Effect.provide(locations.get(input.location)))
65-
}),
66-
})
67-
}),
24+
const SessionsLayer = SessionV2.layer.pipe(
25+
Layer.provide(SessionProjector.layer),
26+
Layer.provide(SessionExecutionLocal.layer),
27+
Layer.provide(SessionStore.layer),
28+
Layer.provide(EventV2.layer),
29+
Layer.provide(Database.defaultLayer),
30+
Layer.provide(ProjectV2.defaultLayer),
31+
Layer.provide(LocationServiceMap.layer.pipe(Layer.provide(ApplicationTools.layer))),
32+
Layer.orDie,
6833
)
69-
70-
const SessionsLayer = Layer.merge(
71-
SessionV2.layer.pipe(
72-
Layer.provide(SessionProjector.layer),
73-
Layer.provide(SessionExecutionLocal.layer),
74-
Layer.provide(SessionStore.layer),
75-
Layer.provide(EventV2.layer),
76-
Layer.provide(Database.defaultLayer),
77-
Layer.provide(ProjectV2.defaultLayer),
78-
Layer.orDie,
79-
),
80-
SessionModelValidationLayer,
81-
).pipe(Layer.provide(LocationServicesLayer))
8234
// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
8335
export const layer = Layer.effect(
8436
Service,
8537
Effect.gen(function* () {
8638
const sessions = yield* SessionV2.Service
8739
const tools = yield* ApplicationTools.Service
88-
const validation = yield* SessionModelValidation
8940
return Service.of({
9041
tools: { register: tools.register },
9142
sessions: {
@@ -98,11 +49,7 @@ export const layer = Layer.effect(
9849
}),
9950
get: sessions.get,
10051
list: sessions.list,
101-
switchModel: Effect.fn("OpenCode.sessions.switchModel")(function* (input) {
102-
const session = yield* sessions.get(input.sessionID)
103-
yield* validation.validate({ ...input, location: session.location })
104-
yield* sessions.switchModel(input)
105-
}),
52+
switchModel: sessions.switchModel,
10653
interrupt: sessions.interrupt,
10754
prompt: (input) =>
10855
sessions.prompt({
@@ -124,6 +71,6 @@ export const layer = Layer.effect(
12471
},
12572
})
12673
}),
127-
).pipe(Layer.provide(Layer.merge(ApplicationToolsLayer, SessionsLayer)))
74+
).pipe(Layer.provide(Layer.merge(ApplicationTools.layer, SessionsLayer)))
12875

12976
// TODO: Add OpenCode.create(...) as the Promise facade over the same native API semantics.

packages/core/src/public/session.ts

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
export * as Session from "./session"
22

3-
import { Effect, Schema, Stream } from "effect"
4-
import { ModelV2 } from "../model"
3+
import { Effect, Stream } from "effect"
54
import { SessionV2 } from "../session"
65
import { MessageDecodeError } from "../session/error"
76
import { SessionEvent } from "../session/event"
@@ -41,23 +40,6 @@ export type NotFoundError = SessionV2.NotFoundError
4140
export const PromptConflictError = SessionV2.PromptConflictError
4241
export type PromptConflictError = SessionV2.PromptConflictError
4342

44-
export class ModelUnavailableError extends Schema.TaggedErrorClass<ModelUnavailableError>()(
45-
"Session.ModelUnavailableError",
46-
{
47-
providerID: Model.Ref.fields.providerID,
48-
modelID: Model.Ref.fields.id,
49-
},
50-
) {}
51-
52-
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
53-
"Session.VariantUnavailableError",
54-
{
55-
providerID: Model.Ref.fields.providerID,
56-
modelID: Model.Ref.fields.id,
57-
variant: ModelV2.VariantID,
58-
},
59-
) {}
60-
6143
export { MessageDecodeError }
6244

6345
export interface CreateInput {
@@ -104,9 +86,7 @@ export interface Interface {
10486
readonly get: (sessionID: ID) => Effect.Effect<Info, NotFoundError>
10587
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
10688
readonly prompt: (input: PromptInput) => Effect.Effect<Admission, NotFoundError | PromptConflictError>
107-
readonly switchModel: (
108-
input: SwitchModelInput,
109-
) => Effect.Effect<void, NotFoundError | ModelUnavailableError | VariantUnavailableError>
89+
readonly switchModel: (input: SwitchModelInput) => Effect.Effect<void, NotFoundError>
11090
/** Interrupt the active V2 execution chain for one Session on this process. Interrupting an idle or missing Session is a no-op. */
11191
readonly interrupt: (sessionID: ID) => Effect.Effect<void>
11292
readonly messages: (input: MessagesInput) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>

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

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-message
55
import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat"
66
import * as OpenAIResponses from "@opencode-ai/llm/protocols/openai-responses"
77
import { Auth, type AnyRoute } from "@opencode-ai/llm/route"
8-
import { Context, Effect, Layer, Option, Schema } from "effect"
8+
import { Context, Effect, Layer, Schema } from "effect"
99
import { produce } from "immer"
1010
import { Catalog } from "../../catalog"
1111
import { Credential } from "../../credential"
@@ -24,6 +24,23 @@ export class ModelNotSelectedError extends Schema.TaggedErrorClass<ModelNotSelec
2424
},
2525
) {}
2626

27+
export class ModelUnavailableError extends Schema.TaggedErrorClass<ModelUnavailableError>()(
28+
"SessionRunnerModel.ModelUnavailableError",
29+
{
30+
providerID: ProviderV2.ID,
31+
modelID: ModelV2.ID,
32+
},
33+
) {}
34+
35+
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
36+
"SessionRunnerModel.VariantUnavailableError",
37+
{
38+
providerID: ProviderV2.ID,
39+
modelID: ModelV2.ID,
40+
variant: ModelV2.VariantID,
41+
},
42+
) {}
43+
2744
export class UnsupportedApiError extends Schema.TaggedErrorClass<UnsupportedApiError>()(
2845
"SessionRunnerModel.UnsupportedApiError",
2946
{
@@ -33,7 +50,7 @@ export class UnsupportedApiError extends Schema.TaggedErrorClass<UnsupportedApiE
3350
},
3451
) {}
3552

36-
export type Error = ModelNotSelectedError | UnsupportedApiError
53+
export type Error = ModelNotSelectedError | ModelUnavailableError | VariantUnavailableError | UnsupportedApiError
3754

3855
export interface Interface {
3956
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Model, Error>
@@ -70,13 +87,27 @@ const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
7087
})
7188
}
7289

73-
const withVariant = (model: ModelV2.Info, variantID: ModelV2.VariantID | undefined) => {
90+
const withVariant = (
91+
model: ModelV2.Info,
92+
variantID: ModelV2.VariantID | undefined,
93+
): Effect.Effect<ModelV2.Info, VariantUnavailableError> => {
7494
const id = variantID === "default" || variantID === undefined ? model.request.variant : variantID
7595
const variant = model.variants.find((item) => item.id === id)
76-
if (!variant) return model
77-
return produce(model, (draft) => {
78-
ModelRequest.assign(draft.request, variant)
79-
})
96+
if (!variant && variantID !== undefined && variantID !== "default")
97+
return Effect.fail(
98+
new VariantUnavailableError({
99+
providerID: model.providerID,
100+
modelID: model.id,
101+
variant: variantID,
102+
}),
103+
)
104+
return Effect.succeed(
105+
variant
106+
? produce(model, (draft) => {
107+
ModelRequest.assign(draft.request, variant)
108+
})
109+
: model,
110+
)
80111
}
81112

82113
const apiName = (model: ModelV2.Info) =>
@@ -124,8 +155,15 @@ export const fromCatalogModel = (
124155
)
125156
}
126157

127-
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info) =>
128-
fromCatalogModel(withVariant(model, session.model?.variant))
158+
export const resolve = (
159+
session: SessionSchema.Info,
160+
model: ModelV2.Info,
161+
connection?: IntegrationConnection.Info,
162+
credential?: Credential.Info,
163+
) =>
164+
withVariant(model, session.model?.variant).pipe(
165+
Effect.flatMap((model) => fromCatalogModel(model, connection, credential)),
166+
)
129167

130168
export const supported = (model: ModelV2.Info) =>
131169
model.api.type === "aisdk" &&
@@ -147,14 +185,22 @@ export const locationLayer = Layer.effect(
147185
yield* boot.wait()
148186
const defaultModel = session.model ? undefined : yield* catalog.model.default()
149187
const selected = session.model
150-
? yield* catalog.model.get(session.model.providerID, session.model.id)
188+
? (yield* catalog.model.available()).find(
189+
(model) => model.providerID === session.model?.providerID && model.id === session.model.id,
190+
)
151191
: defaultModel && supported(defaultModel)
152192
? defaultModel
153193
: (yield* catalog.model.available()).find(supported)
194+
if (!selected && session.model)
195+
return yield* new ModelUnavailableError({
196+
providerID: session.model.providerID,
197+
modelID: session.model.id,
198+
})
154199
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
155200
const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID))
156-
return yield* fromCatalogModel(
157-
withVariant(selected, session.model?.variant),
201+
return yield* resolve(
202+
session,
203+
selected,
158204
connection,
159205
connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined,
160206
)

packages/core/test/location-layer.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
import fs from "fs/promises"
22
import path from "path"
33
import { describe, expect } from "bun:test"
4-
import { Deferred, Effect, Equal, Hash, Layer, Schema, Stream } from "effect"
4+
import { DateTime, Deferred, Effect, Equal, Hash, Layer, Schema, Stream } from "effect"
55
import { Tool } from "@opencode-ai/core/public"
66
import { define } from "@opencode-ai/plugin/v2/effect"
77
import { AgentV2 } from "@opencode-ai/core/agent"
88
import { Catalog } from "@opencode-ai/core/catalog"
99
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
1010
import { Location } from "@opencode-ai/core/location"
11+
import { ModelV2 } from "@opencode-ai/core/model"
1112
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
13+
import { ProjectV2 } from "@opencode-ai/core/project"
1214
import { ProviderV2 } from "@opencode-ai/core/provider"
1315
import { AbsolutePath } from "@opencode-ai/core/schema"
16+
import { SessionV2 } from "@opencode-ai/core/session"
17+
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
1418
import { tmpdir } from "./fixture/tmpdir"
1519
import { testEffect } from "./lib/effect"
1620
import { toolDefinitions } from "./lib/tool"
@@ -136,6 +140,56 @@ describe("LocationServiceMap", () => {
136140
),
137141
)
138142

143+
it.live("rejects an unavailable selected model during location model resolution", () =>
144+
Effect.acquireRelease(
145+
Effect.promise(() => tmpdir()),
146+
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
147+
).pipe(
148+
Effect.flatMap((dir) =>
149+
Effect.gen(function* () {
150+
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
151+
yield* Effect.promise(() =>
152+
fs.writeFile(
153+
path.join(dir.path, "opencode.json"),
154+
JSON.stringify({
155+
providers: {
156+
unavailable: {
157+
name: "Unavailable",
158+
api: { type: "native", settings: {} },
159+
models: { chat: { disabled: true } },
160+
},
161+
},
162+
}),
163+
),
164+
)
165+
const failure = yield* SessionRunnerModel.Service.use((models) =>
166+
models.resolve(
167+
SessionV2.Info.make({
168+
id: SessionV2.ID.make("ses_unavailable_model"),
169+
projectID: ProjectV2.ID.global,
170+
title: "test",
171+
model: {
172+
id: ModelV2.ID.make("chat"),
173+
providerID: ProviderV2.ID.make("unavailable"),
174+
},
175+
cost: 0,
176+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
177+
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
178+
location,
179+
}),
180+
),
181+
).pipe(Effect.provide(LocationServiceMap.get(location)), Effect.flip)
182+
183+
expect(failure).toMatchObject({
184+
_tag: "SessionRunnerModel.ModelUnavailableError",
185+
providerID: "unavailable",
186+
modelID: "chat",
187+
})
188+
}),
189+
),
190+
),
191+
)
192+
139193
it.live("installs public plugins into a location", () =>
140194
Effect.acquireRelease(
141195
Effect.promise(() => tmpdir()),

0 commit comments

Comments
 (0)