mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 00:38:31 +00:00
fix(core): defer session model validation (#33377)
This commit is contained in:
parent
d5980b47e9
commit
f9f2280452
6 changed files with 171 additions and 224 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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) }),
|
||||
})
|
||||
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") }),
|
||||
})
|
||||
|
||||
yield* opencode.sessions.switchModel({ sessionID, model })
|
||||
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 })
|
||||
|
||||
const error = yield* opencode.sessions
|
||||
.switchModel({ sessionID, model: ref({ variant: "unknown" }) })
|
||||
.pipe(Effect.flip)
|
||||
|
||||
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" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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" }, [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue