diff --git a/packages/core/src/generate.ts b/packages/core/src/generate.ts index f4a79e9aa2..432827bce0 100644 --- a/packages/core/src/generate.ts +++ b/packages/core/src/generate.ts @@ -2,12 +2,10 @@ export * as Generate from "./generate" import { LLM, LLMClient, LLMError } from "@opencode-ai/ai" import { Context, Effect, Layer, Schema } from "effect" -import { Catalog } from "./catalog" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { llmClient } from "./effect/app-node-platform" -import { Integration } from "./integration" +import { ModelResolver } from "./model-resolver" import { ModelV2 } from "./model" -import { SessionRunnerModel } from "./session/runner/model" export interface TextInput { readonly prompt: string @@ -19,10 +17,10 @@ export class ModelSelectionError extends Schema.TaggedErrorClass()( - "Generate.UnavailableError", - { message: Schema.String, service: Schema.optional(Schema.String) }, -) {} +export class UnavailableError extends Schema.TaggedErrorClass()("Generate.UnavailableError", { + message: Schema.String, + service: Schema.optional(Schema.String), +}) {} export type Error = ModelSelectionError | UnavailableError @@ -35,56 +33,34 @@ export class Service extends Context.Service()("@opencode/v2 export const layer = Layer.effect( Service, Effect.gen(function* () { - const catalog = yield* Catalog.Service - const integrations = yield* Integration.Service const llm = yield* LLMClient.Service - - const selectModel = Effect.fn("Generate.selectModel")(function* (requested?: ModelV2.Ref) { - const selected = requested - ? yield* catalog.model.get(requested.providerID, requested.id) - : yield* catalog.model.default().pipe( - Effect.flatMap((model) => - model && SessionRunnerModel.supported(model) - ? Effect.succeed(model) - : Effect.map(catalog.model.available(), (models) => models.find(SessionRunnerModel.supported)), - ), - ) - if (!selected) - return yield* new ModelSelectionError({ - message: requested - ? `Model unavailable: ${requested.providerID}/${requested.id}` - : "No model specified and no supported model is available", - }) - return yield* SessionRunnerModel.withVariant(selected, requested?.variant).pipe( - Effect.mapError( - () => - new ModelSelectionError({ - message: `Variant unavailable for ${selected.providerID}/${selected.id}: ${requested?.variant}`, - }), - ), - ) - }) + const resolver = yield* ModelResolver.Service const runText = Effect.fn("Generate.text")(function* (input: TextInput) { - const selected = yield* selectModel(input.model) - const provider = yield* catalog.provider.get(selected.providerID) - const connection = yield* integrations.connection.active( - provider?.integrationID ?? Integration.ID.make(selected.providerID), + const resolved = yield* resolver.resolve(input.model).pipe( + Effect.catchTags({ + "SessionRunnerModel.VariantUnavailableError": (error) => + input.model + ? new ModelSelectionError({ message: error.message }) + : new UnavailableError({ message: error.message, service: error.providerID }), + "SessionRunnerModel.UnsupportedPackageError": (error) => + input.model + ? new ModelSelectionError({ message: error.message }) + : new UnavailableError({ message: error.message, service: error.providerID }), + }), ) - const credential = connection ? yield* integrations.connection.resolve(connection) : undefined - const model = yield* SessionRunnerModel.fromCatalogModel(selected, credential).pipe( - Effect.mapError((error) => - input.model - ? new ModelSelectionError({ message: error.message }) - : new UnavailableError({ message: error.message, service: selected.providerID }), - ), - ) - const response = yield* llm.generate(LLM.request({ model, prompt: input.prompt })).pipe( + if (!resolved) + return yield* new ModelSelectionError({ + message: input.model + ? `Model unavailable: ${input.model.providerID}/${input.model.id}` + : "No model specified and no supported model is available", + }) + const response = yield* llm.generate(LLM.request({ model: resolved.model, prompt: input.prompt })).pipe( Effect.mapError( (error: LLMError) => new UnavailableError({ message: error.message, - service: selected.providerID, + service: resolved.ref.providerID, }), ), ) @@ -106,4 +82,8 @@ export const layer = Layer.effect( }), ) -export const node = makeLocationNode({ service: Service, layer, deps: [Catalog.node, Integration.node, llmClient] }) +export const node = makeLocationNode({ + service: Service, + layer, + deps: [ModelResolver.node, llmClient], +}) diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 7be9fefe3f..adf1afe62b 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -20,6 +20,7 @@ import { Integration } from "./integration" import { Location } from "./location" import { LocationMutation } from "./location-mutation" import { LocationServiceMap } from "./location-service-map" +import { ModelResolver } from "./model-resolver" import { MCP } from "./mcp/index" import { PermissionV2 } from "./permission" import { PluginV2 } from "./plugin" @@ -58,6 +59,7 @@ const locationServiceNodes = [ Reference.node, Integration.node, Catalog.node, + ModelResolver.node, AISDK.node, PluginV2.node, PluginSupervisor.node, diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts new file mode 100644 index 0000000000..44faea8b12 --- /dev/null +++ b/packages/core/src/model-resolver.ts @@ -0,0 +1,344 @@ +export * as ModelResolver from "./model-resolver" + +import { makeLocationNode } from "@opencode-ai/util/effect/app-node" +import { Model } from "@opencode-ai/ai" +// ast-grep-ignore: no-star-import +import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages" +// ast-grep-ignore: no-star-import +import * as OpenAICompatibleChat from "@opencode-ai/ai/protocols/openai-compatible-chat" +// ast-grep-ignore: no-star-import +import * as OpenAIResponses from "@opencode-ai/ai/protocols/openai-responses" +import { Auth, type AnyRoute } from "@opencode-ai/ai/route" +import { Context, Effect, Layer, Schema } from "effect" +import { produce } from "immer" +import { AISDK } from "./aisdk" +import { Catalog } from "./catalog" +import { Credential } from "./credential" +import { Integration } from "./integration" +import { ModelV2 } from "./model" +import { Npm } from "@opencode-ai/util/npm" +import { OpenAICodex } from "./plugin/provider/openai-codex" +import { ProviderV2 } from "./provider" + +export class VariantUnavailableError extends Schema.TaggedErrorClass()( + "SessionRunnerModel.VariantUnavailableError", + { + providerID: ProviderV2.ID, + modelID: ModelV2.ID, + variant: ModelV2.VariantID, + }, +) { + override get message() { + return `Variant unavailable for ${this.providerID}/${this.modelID}: ${this.variant}` + } +} + +export class UnsupportedPackageError extends Schema.TaggedErrorClass()( + "SessionRunnerModel.UnsupportedPackageError", + { + providerID: ProviderV2.ID, + modelID: ModelV2.ID, + package: Schema.String, + }, +) { + override get message() { + return `Unsupported package for ${this.providerID}/${this.modelID}: ${this.package}` + } +} + +export type Error = VariantUnavailableError | UnsupportedPackageError | Integration.AuthorizationError + +export interface Resolved { + /** Route-level model for provider requests; its id is the provider API model id, which may differ from the catalog id. */ + readonly model: Model + /** Selected catalog identity. Durable records and displays must use this, never the API model id. */ + readonly ref: ModelV2.Ref + /** Catalog capabilities used to shape requests before provider lowering. */ + readonly capabilities: ModelV2.Capabilities + /** Catalog pricing in dollars per million tokens. */ + readonly cost: ModelV2.Info["cost"] +} + +export interface Interface { + readonly resolve: (requested?: ModelV2.Ref) => Effect.Effect + readonly resolveModel: (model: ModelV2.Info, variant?: ModelV2.VariantID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/ModelResolver") {} + +const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => { + if (credential?.type === "key") return Auth.value(credential.key) + if (credential?.type === "oauth") return Auth.value(credential.access) + const value = model.settings?.apiKey + if (typeof value === "string") return Auth.value(value) + return undefined +} + +const withDefaults = (model: ModelV2.Info, route: AnyRoute) => + route.with({ + provider: model.providerID, + endpoint: typeof model.settings?.baseURL === "string" ? { baseURL: model.settings.baseURL } : undefined, + headers: providerHeaders(model), + providerOptions: providerOptions(model), + http: model.body === undefined ? undefined : { body: model.body }, + limits: { context: model.limit.context, output: model.limit.output }, + }) + +const providerHeaders = (model: ModelV2.Info) => { + const packageName = ProviderV2.packageName(model.package) + const generated = new Map() + if (packageName === "@ai-sdk/openai" && typeof model.settings?.organization === "string") + generated.set("OpenAI-Organization", model.settings.organization) + if (packageName === "@ai-sdk/openai" && typeof model.settings?.project === "string") + generated.set("OpenAI-Project", model.settings.project) + if (packageName === "@ai-sdk/anthropic" && typeof model.settings?.authToken === "string") + generated.set("Authorization", `Bearer ${model.settings.authToken}`) + return ProviderV2.mergeHeaders(generated.size === 0 ? undefined : Object.fromEntries(generated), model.headers) +} + +const providerOptions = ( + model: ModelV2.Info, +): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => { + if (!ProviderV2.isAISDK(model.package) || model.settings === undefined) return undefined + const { apiKey: _, baseURL: _baseURL, ...settings } = model.settings + if (Object.keys(settings).length === 0) return undefined + const packageName = ProviderV2.packageName(model.package) + if (packageName === "@ai-sdk/openai") return { openai: settings } + if (packageName === "@ai-sdk/anthropic") return { anthropic: settings } + if (packageName === "@ai-sdk/openai-compatible") return { openai: settings } + return undefined +} + +export const withVariant = ( + model: ModelV2.Info, + variantID: ModelV2.VariantID | undefined, +): Effect.Effect => { + const id = variantID === "default" ? undefined : variantID + const variant = model.variants?.find((item) => item.id === id) + 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) => { + draft.settings = ProviderV2.mergeOverlay(draft.settings, variant.settings) + draft.headers = ProviderV2.mergeHeaders(draft.headers, variant.headers) + draft.body = ProviderV2.mergeOverlay(draft.body, variant.body) + }) + : model, + ) +} + +export interface Dependencies { + readonly loadPackage?: (specifier: string) => Effect.Effect + readonly loadAISDK?: (model: ModelV2.Info) => Effect.Effect +} + +export const fromCatalogModel = ( + model: ModelV2.Info, + credential?: Credential.Value, + dependencies?: Dependencies, +): Effect.Effect => { + const resolved = produce(model, (draft) => { + if (draft.settings?.apiKey === "") delete draft.settings.apiKey + if (credential?.type === "key" && credential.metadata !== undefined) + draft.body = ProviderV2.mergeOverlay(draft.body, credential.metadata) + }) + const packageName = ProviderV2.packageName(resolved.package) + const key = apiKey(resolved, credential) + + if (OpenAICodex.isChatGPT(credential) && !ProviderV2.isAISDK(resolved.package) && isNativeOpenAI(resolved.package)) { + return Effect.succeed(codexModel(resolved, credential, key)) + } + + if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") { + if (OpenAICodex.isChatGPT(credential)) return Effect.succeed(codexModel(resolved, credential, key)) + return Effect.succeed( + withDefaults(resolved, OpenAIResponses.route) + .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) + .model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }), + ) + } + if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") { + return Effect.succeed( + withDefaults(resolved, AnthropicMessages.route) + .with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) }) + .model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }), + ) + } + if ( + ProviderV2.isAISDK(resolved.package) && + packageName === "@ai-sdk/openai-compatible" && + typeof resolved.settings?.baseURL === "string" + ) { + return Effect.succeed( + withDefaults(resolved, OpenAICompatibleChat.route) + .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) + .model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }), + ) + } + if (ProviderV2.isAISDK(resolved.package)) { + if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved)) + const runtime = produce(resolved, (draft) => { + draft.settings = ProviderV2.mergeOverlay(draft.settings, { + ...(credential?.type === "key" ? { apiKey: credential.key } : {}), + ...(credential?.type === "oauth" ? { apiKey: credential.access } : {}), + ...credential?.metadata, + }) + }) + return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved))) + } + if (!resolved.package) return Effect.fail(unsupported(resolved)) + + const specifier = resolved.package + return Effect.gen(function* () { + const module = yield* (dependencies?.loadPackage ?? ProviderV2.loadPackage)(specifier).pipe( + Effect.mapError(() => unsupported(resolved)), + ) + const configured = { ...resolved.settings, ...credential?.metadata } + const settings = { + ...(credential ? withoutNativeAuthSettings(configured) : configured), + ...nativeCredentialSettings(specifier, credential), + headers: resolved.headers, + body: resolved.body, + limits: { context: resolved.limit.context, output: resolved.limit.output }, + } + return yield* Effect.try({ + try: () => { + const runtime = module.model(resolved.modelID ?? resolved.id, settings) + return Model.update(runtime, { + provider: resolved.providerID, + compatibility: resolved.compatibility + ? Object.assign({}, runtime.compatibility, resolved.compatibility) + : runtime.compatibility, + }) + }, + catch: () => unsupported(resolved), + }) + }) +} + +const isNativeOpenAI = (packageName: string | undefined) => + packageName === "@opencode-ai/ai/providers/openai" || + packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true + +const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => { + if (!credential) return {} + if (credential.type === "key") return { apiKey: credential.key } + if ( + specifier === "@opencode-ai/ai/providers/anthropic" || + specifier === "@opencode-ai/ai/providers/anthropic-compatible" + ) + return { authToken: credential.access } + if ( + specifier === "@opencode-ai/ai/providers/google-vertex" || + specifier.startsWith("@opencode-ai/ai/providers/google-vertex/") + ) + return { accessToken: credential.access } + return { apiKey: credential.access } +} + +const withoutNativeAuthSettings = (settings: Record) => { + const { accessToken: _accessToken, apiKey: _apiKey, authToken: _authToken, ...rest } = settings + return rest +} + +const codexModel = ( + model: ModelV2.Info, + credential: Credential.Value | undefined, + key: ReturnType | undefined, +) => { + const account = OpenAICodex.accountID(credential) + return withDefaults(model, OpenAIResponses.route) + .with({ + endpoint: { baseURL: OpenAICodex.baseURL }, + auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen( + account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }), + ), + }) + .model({ id: model.modelID ?? model.id, compatibility: model.compatibility }) +} + +const unsupported = (model: ModelV2.Info) => + new UnsupportedPackageError({ + providerID: model.providerID, + modelID: model.id, + package: model.package ?? "unknown", + }) + +export const resolveModel = ( + model: ModelV2.Info, + variant: ModelV2.VariantID | undefined, + credential?: Credential.Value, + dependencies?: Dependencies, +) => withVariant(model, variant).pipe(Effect.flatMap((model) => fromCatalogModel(model, credential, dependencies))) + +export const supported = (model: ModelV2.Info) => Boolean(model.package) + +/** Resolves catalog selections into runtime models for the current Location. */ +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const integrations = yield* Integration.Service + const npm = yield* Npm.Service + const aisdk = yield* AISDK.Service + const load = Effect.fn("ModelResolver.resolveModel")(function* ( + selected: ModelV2.Info, + variant?: ModelV2.VariantID, + ) { + const provider = yield* catalog.provider.get(selected.providerID) + const connection = yield* integrations.connection.active( + provider?.integrationID ?? Integration.ID.make(selected.providerID), + ) + const model = yield* resolveModel( + selected, + variant, + connection ? yield* integrations.connection.resolve(connection) : undefined, + { + loadPackage: (specifier) => ProviderV2.loadPackage(specifier, npm), + loadAISDK: (model) => aisdk.model(model), + }, + ) + return { + model, + ref: ModelV2.Ref.make({ + id: selected.id, + providerID: selected.providerID, + ...(variant === undefined ? {} : { variant }), + }), + capabilities: selected.capabilities, + cost: selected.cost, + } + }) + return Service.of({ + resolve: Effect.fn("ModelResolver.resolve")(function* (requested) { + const selected = requested + ? yield* catalog.model.get(requested.providerID, requested.id) + : yield* catalog.model + .default() + .pipe( + Effect.flatMap((model) => + model && supported(model) + ? Effect.succeed(model) + : Effect.map(catalog.model.available(), (models) => models.find(supported)), + ), + ) + if (!selected) return undefined + return yield* load(selected, requested?.variant) + }), + resolveModel: load, + }) + }), +) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [Catalog.node, Integration.node, Npm.node, AISDK.node], +}) diff --git a/packages/core/src/plugin/provider/openai-codex.ts b/packages/core/src/plugin/provider/openai-codex.ts index 8d4389d969..eb734b29f3 100644 --- a/packages/core/src/plugin/provider/openai-codex.ts +++ b/packages/core/src/plugin/provider/openai-codex.ts @@ -1,7 +1,7 @@ export * as OpenAICodex from "./openai-codex" // TEMPORARY SEAM (#34765): plugins have no hook into LLM route construction, so -// codex routing lives in SessionRunnerModel.fromCatalogModel and catalog filtering +// Codex routing lives in ModelResolver and catalog filtering. // in OpenAIPlugin, sharing this module. Once the native provider packages land // (#33689/#33925/#34462) this should collapse into the native OpenAI provider. // The eligibility rules mirror V1's CodexAuthPlugin allowlist; models.dev has no diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 6013ce7b74..d0902d7d67 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -2,30 +2,16 @@ export * as SessionRunnerModel from "./model" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { Model } from "@opencode-ai/ai" -// ast-grep-ignore: no-star-import -import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages" -// ast-grep-ignore: no-star-import -import * as OpenAICompatibleChat from "@opencode-ai/ai/protocols/openai-compatible-chat" -// ast-grep-ignore: no-star-import -import * as OpenAIResponses from "@opencode-ai/ai/protocols/openai-responses" -import { Auth, type AnyRoute } from "@opencode-ai/ai/route" import { Context, Effect, Layer, Schema } from "effect" -import { produce } from "immer" -import { AISDK } from "../../aisdk" import { Catalog } from "../../catalog" -import { Credential } from "../../credential" -import { Integration } from "../../integration" +import { ModelResolver } from "../../model-resolver" import { ModelV2 } from "../../model" -import { Npm } from "@opencode-ai/util/npm" -import { OpenAICodex } from "../../plugin/provider/openai-codex" import { ProviderV2 } from "../../provider" import { SessionSchema } from "../schema" export class ModelNotSelectedError extends Schema.TaggedErrorClass()( "SessionRunnerModel.ModelNotSelectedError", - { - sessionID: SessionSchema.ID, - }, + { sessionID: SessionSchema.ID }, ) { override get message() { return `No model is available for session ${this.sessionID}` @@ -34,59 +20,19 @@ export class ModelNotSelectedError extends Schema.TaggedErrorClass()( "SessionRunnerModel.ModelUnavailableError", - { - providerID: ProviderV2.ID, - modelID: ModelV2.ID, - }, + { providerID: ProviderV2.ID, modelID: ModelV2.ID }, ) { override get message() { return `Model unavailable: ${this.providerID}/${this.modelID}` } } +export const VariantUnavailableError = ModelResolver.VariantUnavailableError +export type VariantUnavailableError = ModelResolver.VariantUnavailableError +export const UnsupportedPackageError = ModelResolver.UnsupportedPackageError +export type UnsupportedPackageError = ModelResolver.UnsupportedPackageError -export class VariantUnavailableError extends Schema.TaggedErrorClass()( - "SessionRunnerModel.VariantUnavailableError", - { - providerID: ProviderV2.ID, - modelID: ModelV2.ID, - variant: ModelV2.VariantID, - }, -) { - override get message() { - return `Variant unavailable for ${this.providerID}/${this.modelID}: ${this.variant}` - } -} - -export class UnsupportedPackageError extends Schema.TaggedErrorClass()( - "SessionRunnerModel.UnsupportedPackageError", - { - providerID: ProviderV2.ID, - modelID: ModelV2.ID, - package: Schema.String, - }, -) { - override get message() { - return `Unsupported package for ${this.providerID}/${this.modelID}: ${this.package}` - } -} - -export type Error = - | ModelNotSelectedError - | ModelUnavailableError - | VariantUnavailableError - | UnsupportedPackageError - | Integration.AuthorizationError - -export interface Resolved { - /** Route-level model for provider requests; its id is the provider API model id, which may differ from the catalog id. */ - readonly model: Model - /** Selected catalog identity. Durable records and displays must use this, never the API model id. */ - readonly ref: ModelV2.Ref - /** Catalog capabilities used to shape requests before provider lowering. */ - readonly capabilities: ModelV2.Capabilities - /** Catalog pricing in dollars per million tokens. */ - readonly cost: ModelV2.Info["cost"] -} +export type Error = ModelNotSelectedError | ModelUnavailableError | ModelResolver.Error +export type Resolved = ModelResolver.Resolved export interface Interface { readonly resolve: (session: SessionSchema.Info) => Effect.Effect @@ -94,9 +40,6 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/SessionRunnerModel") {} -/** Test or embedding seam for supplying a model resolver directly. */ -export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve })) - /** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */ export const resolved = ( model: Model, @@ -116,276 +59,31 @@ export const resolved = ( cost: options.cost, }) -const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => { - if (credential?.type === "key") return Auth.value(credential.key) - if (credential?.type === "oauth") return Auth.value(credential.access) - const value = model.settings?.apiKey - if (typeof value === "string") return Auth.value(value) -} - -const withDefaults = (model: ModelV2.Info, route: AnyRoute) => - route.with({ - provider: model.providerID, - endpoint: typeof model.settings?.baseURL === "string" ? { baseURL: model.settings.baseURL } : undefined, - headers: providerHeaders(model), - providerOptions: providerOptions(model), - http: model.body === undefined ? undefined : { body: model.body }, - limits: { context: model.limit.context, output: model.limit.output }, - }) - -const providerHeaders = (model: ModelV2.Info) => { - const packageName = ProviderV2.packageName(model.package) - const generated = new Map() - if (packageName === "@ai-sdk/openai" && typeof model.settings?.organization === "string") - generated.set("OpenAI-Organization", model.settings.organization) - if (packageName === "@ai-sdk/openai" && typeof model.settings?.project === "string") - generated.set("OpenAI-Project", model.settings.project) - if (packageName === "@ai-sdk/anthropic" && typeof model.settings?.authToken === "string") - generated.set("Authorization", `Bearer ${model.settings.authToken}`) - return ProviderV2.mergeHeaders(generated.size === 0 ? undefined : Object.fromEntries(generated), model.headers) -} - -const providerOptions = ( - model: ModelV2.Info, -): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => { - if (!ProviderV2.isAISDK(model.package) || model.settings === undefined) return undefined - const { apiKey: _, baseURL: _baseURL, ...settings } = model.settings - if (Object.keys(settings).length === 0) return undefined - const packageName = ProviderV2.packageName(model.package) - if (packageName === "@ai-sdk/openai") return { openai: settings } - if (packageName === "@ai-sdk/anthropic") return { anthropic: settings } - if (packageName === "@ai-sdk/openai-compatible") return { openai: settings } -} - -export const withVariant = ( - model: ModelV2.Info, - variantID: ModelV2.VariantID | undefined, -): Effect.Effect => { - const id = variantID === "default" ? undefined : variantID - const variant = model.variants?.find((item) => item.id === id) - 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) => { - draft.settings = ProviderV2.mergeOverlay(draft.settings, variant.settings) - draft.headers = ProviderV2.mergeHeaders(draft.headers, variant.headers) - draft.body = ProviderV2.mergeOverlay(draft.body, variant.body) - }) - : model, - ) -} - -export interface Dependencies { - readonly loadPackage?: (specifier: string) => Effect.Effect - readonly loadAISDK?: (model: ModelV2.Info) => Effect.Effect -} - -export const fromCatalogModel = ( - model: ModelV2.Info, - credential?: Credential.Value, - dependencies: Dependencies = {}, -): Effect.Effect => { - const resolved = produce(model, (draft) => { - if (draft.settings?.apiKey === "") delete draft.settings.apiKey - if (credential?.type === "key" && credential.metadata !== undefined) - draft.body = ProviderV2.mergeOverlay(draft.body, credential.metadata) - }) - const packageName = ProviderV2.packageName(resolved.package) - const key = apiKey(resolved, credential) - - if (OpenAICodex.isChatGPT(credential) && !ProviderV2.isAISDK(resolved.package) && isNativeOpenAI(resolved.package)) { - return Effect.succeed(codexModel(resolved, credential, key)) - } - - if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") { - if (OpenAICodex.isChatGPT(credential)) return Effect.succeed(codexModel(resolved, credential, key)) - return Effect.succeed( - withDefaults(resolved, OpenAIResponses.route) - .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) - .model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }), - ) - } - if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") { - return Effect.succeed( - withDefaults(resolved, AnthropicMessages.route) - .with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) }) - .model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }), - ) - } - if ( - ProviderV2.isAISDK(resolved.package) && - packageName === "@ai-sdk/openai-compatible" && - typeof resolved.settings?.baseURL === "string" - ) { - return Effect.succeed( - withDefaults(resolved, OpenAICompatibleChat.route) - .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) - .model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }), - ) - } - if (ProviderV2.isAISDK(resolved.package)) { - if (!dependencies.loadAISDK) return Effect.fail(unsupported(resolved)) - const runtime = produce(resolved, (draft) => { - draft.settings = ProviderV2.mergeOverlay(draft.settings, { - ...(credential?.type === "key" ? { apiKey: credential.key } : {}), - ...(credential?.type === "oauth" ? { apiKey: credential.access } : {}), - ...credential?.metadata, - }) - }) - return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved))) - } - if (!resolved.package) return Effect.fail(unsupported(resolved)) - - const specifier = resolved.package - return Effect.gen(function* () { - const module = yield* (dependencies.loadPackage ?? ProviderV2.loadPackage)(specifier).pipe( - Effect.mapError(() => unsupported(resolved)), - ) - const configured = { ...resolved.settings, ...credential?.metadata } - const settings = { - ...(credential ? withoutNativeAuthSettings(configured) : configured), - ...nativeCredentialSettings(specifier, credential), - headers: resolved.headers, - body: resolved.body, - limits: { context: resolved.limit.context, output: resolved.limit.output }, - } - return yield* Effect.try({ - try: () => { - const runtime = module.model(resolved.modelID ?? resolved.id, settings) - return Model.update(runtime, { - provider: resolved.providerID, - compatibility: resolved.compatibility - ? { ...runtime.compatibility, ...resolved.compatibility } - : runtime.compatibility, - }) - }, - catch: () => unsupported(resolved), - }) - }) -} - -const isNativeOpenAI = (packageName: string | undefined) => - packageName === "@opencode-ai/ai/providers/openai" || - packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true - -const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => { - if (!credential) return {} - if (credential.type === "key") return { apiKey: credential.key } - if ( - specifier === "@opencode-ai/ai/providers/anthropic" || - specifier === "@opencode-ai/ai/providers/anthropic-compatible" - ) - return { authToken: credential.access } - if ( - specifier === "@opencode-ai/ai/providers/google-vertex" || - specifier.startsWith("@opencode-ai/ai/providers/google-vertex/") - ) - return { accessToken: credential.access } - return { apiKey: credential.access } -} - -const withoutNativeAuthSettings = (settings: Record) => { - const { accessToken: _accessToken, apiKey: _apiKey, authToken: _authToken, ...rest } = settings - return rest -} - -const codexModel = ( - model: ModelV2.Info, - credential: Credential.Value | undefined, - key: ReturnType | undefined, -) => { - const account = OpenAICodex.accountID(credential) - return withDefaults(model, OpenAIResponses.route) - .with({ - endpoint: { baseURL: OpenAICodex.baseURL }, - auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen( - account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }), - ), - }) - .model({ id: model.modelID ?? model.id, compatibility: model.compatibility }) -} - -const unsupported = (model: ModelV2.Info) => - new UnsupportedPackageError({ - providerID: model.providerID, - modelID: model.id, - package: model.package ?? "unknown", - }) - -export const resolve = ( - session: SessionSchema.Info, - model: ModelV2.Info, - credential?: Credential.Value, - dependencies?: Dependencies, -) => - withVariant(model, session.model?.variant).pipe( - Effect.flatMap((model) => fromCatalogModel(model, credential, dependencies)), - ) - -export const supported = (model: ModelV2.Info) => Boolean(model.package) - -/** Resolves models from the catalog belonging to the current Location runtime. */ const layer = Layer.effect( Service, Effect.gen(function* () { const catalog = yield* Catalog.Service - const integrations = yield* Integration.Service - const npm = yield* Npm.Service - const aisdk = yield* AISDK.Service + const resolver = yield* ModelResolver.Service return Service.of({ resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) { // Location plugins populate and filter the catalog asynchronously during layer startup. - const defaultModel = session.model ? undefined : yield* catalog.model.default() - const selected = session.model - ? (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) + if (!session.model) { + const resolved = yield* resolver.resolve() + if (resolved) return resolved + return yield* new ModelNotSelectedError({ sessionID: session.id }) + } + const selected = (yield* catalog.model.available()).find( + (model) => model.providerID === session.model?.providerID && model.id === session.model.id, + ) + if (!selected) return yield* new ModelUnavailableError({ providerID: session.model.providerID, modelID: session.model.id, }) - if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id }) - const provider = yield* catalog.provider.get(selected.providerID) - const connection = yield* integrations.connection.active( - provider?.integrationID ?? Integration.ID.make(selected.providerID), - ) - const model = yield* resolve( - session, - selected, - connection ? yield* integrations.connection.resolve(connection) : undefined, - { - loadPackage: (specifier) => ProviderV2.loadPackage(specifier, npm), - loadAISDK: (model) => aisdk.model(model), - }, - ) - return { - model, - ref: ModelV2.Ref.make({ - id: selected.id, - providerID: selected.providerID, - ...(session.model?.variant === undefined ? {} : { variant: session.model.variant }), - }), - capabilities: selected.capabilities, - cost: selected.cost, - } + return yield* resolver.resolveModel(selected, session.model.variant) }), }) }), ) -export const node = makeLocationNode({ - service: Service, - layer, - deps: [Catalog.node, Integration.node, Npm.node, AISDK.node], -}) +export const node = makeLocationNode({ service: Service, layer, deps: [Catalog.node, ModelResolver.node] }) diff --git a/packages/core/test/generate.test.ts b/packages/core/test/generate.test.ts new file mode 100644 index 0000000000..d990f96ace --- /dev/null +++ b/packages/core/test/generate.test.ts @@ -0,0 +1,111 @@ +import { expect } from "bun:test" +import { LLMClient, LLMEvent, LLMResponse, Model } from "@opencode-ai/ai" +import { OpenAIChat } from "@opencode-ai/ai/protocols" +import { AISDK } from "@opencode-ai/core/aisdk" +import { Catalog } from "@opencode-ai/core/catalog" +import { Generate } from "@opencode-ai/core/generate" +import { Integration } from "@opencode-ai/core/integration" +import { ModelResolver } from "@opencode-ai/core/model-resolver" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { Npm } from "@opencode-ai/util/npm" +import { Effect, Layer, Stream } from "effect" +import { testEffect } from "./lib/effect" + +const selected = ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("test-provider"), ModelV2.ID.make("gemini")), + package: ProviderV2.aisdk("@ai-sdk/google"), +}) +const runtime = Model.make({ id: "gemini", provider: "test-provider", route: OpenAIChat.route }) + +const catalog = Layer.mock(Catalog.Service, { + provider: { + get: () => Effect.succeed(undefined), + all: () => Effect.die("unused"), + available: () => Effect.die("unused"), + }, + model: { + get: () => Effect.succeed(selected), + all: () => Effect.die("unused"), + available: () => Effect.die("unused"), + default: () => Effect.die("unused"), + small: () => Effect.die("unused"), + }, +}) +const integrations = Layer.mock(Integration.Service, { + connection: { + active: () => Effect.succeed(undefined), + resolve: () => Effect.die("unused"), + key: () => Effect.die("unused"), + update: () => Effect.die("unused"), + remove: () => Effect.die("unused"), + }, + oauth: { + connect: () => Effect.die("unused"), + status: () => Effect.die("unused"), + complete: () => Effect.die("unused"), + cancel: () => Effect.die("unused"), + }, + command: { + connect: () => Effect.die("unused"), + status: () => Effect.die("unused"), + cancel: () => Effect.die("unused"), + }, +}) +const npm = Layer.mock(Npm.Service, { + add: () => Effect.die("unused"), + install: () => Effect.die("unused"), + which: () => Effect.die("unused"), +}) +const aisdk = Layer.mock(AISDK.Service, { + hook: { + sdk: () => Effect.die("unused"), + language: () => Effect.die("unused"), + }, + model: () => Effect.succeed(runtime), +}) +const client = Layer.mock(LLMClient.Service)({ + prepare: () => Effect.die("unused"), + stream: () => Stream.die("unused"), + generate: () => + Effect.sync(() => { + const response = LLMResponse.fromEvents([ + LLMEvent.textStart({ id: "generate" }), + LLMEvent.textDelta({ id: "generate", text: "OK" }), + LLMEvent.textEnd({ id: "generate" }), + LLMEvent.finish({ reason: "stop" }), + ]) + if (!response) throw new Error("Incomplete generate response") + return response + }), +}) + +const resolver = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk))) +const it = testEffect(Generate.layer.pipe(Layer.provide(Layer.merge(resolver, client)))) +const resolverIt = testEffect(resolver) + +it.effect("loads dynamic AI SDK models", () => + Effect.gen(function* () { + const generate = yield* Generate.Service + const result = yield* generate.text({ + prompt: "Return exactly OK", + model: ModelV2.Ref.make({ providerID: selected.providerID, id: selected.id }), + }) + + expect(result).toBe("OK") + }), +) + +resolverIt.effect("resolves dynamic models with their catalog metadata", () => + Effect.gen(function* () { + const resolver = yield* ModelResolver.Service + const result = yield* resolver.resolve(ModelV2.Ref.make({ providerID: selected.providerID, id: selected.id })) + + expect(result).toEqual({ + model: runtime, + ref: ModelV2.Ref.make({ providerID: selected.providerID, id: selected.id }), + capabilities: selected.capabilities, + cost: selected.cost, + }) + }), +) diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/model-resolver.test.ts similarity index 80% rename from packages/core/test/session-runner-model.test.ts rename to packages/core/test/model-resolver.test.ts index 38fc707918..9eedc4265b 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -1,17 +1,13 @@ import { describe, expect } from "bun:test" import { LLM, Model } from "@opencode-ai/ai" import { LLMClient } from "@opencode-ai/ai/route" -import { DateTime, Effect } from "effect" -import { Money } from "@opencode-ai/schema/money" +import { Effect } from "effect" import { Headers } from "effect/unstable/http" import { Credential } from "@opencode-ai/core/credential" import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" -import { ProjectV2 } from "@opencode-ai/core/project" -import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" -import { SessionV2 } from "@opencode-ai/core/session" -import { AbsolutePath } from "@opencode-ai/core/schema" +import { ModelResolver } from "@opencode-ai/core/model-resolver" import { it } from "./lib/effect" interface ModelOptions { @@ -43,13 +39,13 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) => limit: { context: 100, output: 20 }, }) -describe("SessionRunnerModel", () => { +describe("ModelResolver", () => { it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () => Effect.gen(function* () { const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { baseURL: "https://openai.example/v1" }, }) - const resolved = yield* SessionRunnerModel.fromCatalogModel(catalog) + const resolved = yield* ModelResolver.fromCatalogModel(catalog) expect(catalog.id).toBe(ModelV2.ID.make("test-model")) expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" }) @@ -68,7 +64,7 @@ describe("SessionRunnerModel", () => { it.effect("keeps catalog apiKey credentials out of provider JSON", () => Effect.gen(function* () { - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { apiKey: "secret", baseURL: "https://openai.example/v1" }, }), @@ -82,7 +78,7 @@ describe("SessionRunnerModel", () => { it.effect("treats an empty configured API key as omitted", () => Effect.gen(function* () { - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { apiKey: "", baseURL: "https://openai.example/v1" }, }), @@ -101,7 +97,7 @@ describe("SessionRunnerModel", () => { it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () => Effect.gen(function* () { - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai-compatible"), { compatibility: { reasoningField: "vendor_reasoning" }, settings: { @@ -130,7 +126,7 @@ describe("SessionRunnerModel", () => { }), ) - it.effect("overlays selected OpenAI Session variant settings and bodies", () => + it.effect("overlays selected OpenAI variant settings and bodies", () => Effect.gen(function* () { const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { baseURL: "https://openai.example/v1" }, @@ -147,22 +143,7 @@ describe("SessionRunnerModel", () => { }, ], }) - const session = SessionV2.Info.make({ - id: SessionV2.ID.make("ses_model_variant"), - projectID: ProjectV2.ID.global, - title: "test", - model: { - id: catalog.id, - providerID: catalog.providerID, - variant: ModelV2.VariantID.make("high"), - }, - cost: Money.USD.zero, - 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 resolved = yield* SessionRunnerModel.resolve(session, catalog) + const resolved = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("high")) expect(resolved.route.defaults.headers).toMatchObject({ "x-test": "header", "x-variant": "high" }) expect(resolved.route.defaults.http?.body).toEqual({ @@ -177,7 +158,7 @@ describe("SessionRunnerModel", () => { }), ) - it.effect("overlays selected OpenAI-compatible Session variant bodies", () => + it.effect("overlays selected OpenAI-compatible variant bodies", () => Effect.gen(function* () { const catalog = model(ProviderV2.aisdk("@ai-sdk/openai-compatible"), { settings: { baseURL: "https://compatible.example/v1" }, @@ -190,18 +171,7 @@ describe("SessionRunnerModel", () => { }, ], }) - const session = SessionV2.Info.make({ - id: SessionV2.ID.make("ses_compatible_variant"), - projectID: ProjectV2.ID.global, - title: "test", - model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") }, - cost: Money.USD.zero, - 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 resolved = yield* SessionRunnerModel.resolve(session, catalog) + const resolved = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("high")) expect(resolved.route.defaults.http?.body).toEqual({ custom_extension: { enabled: true }, @@ -211,27 +181,12 @@ describe("SessionRunnerModel", () => { }), ) - it.effect("rejects an explicit unavailable Session variant during model resolution", () => + it.effect("rejects an explicit unavailable variant during model resolution", () => Effect.gen(function* () { const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { baseURL: "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: Money.USD.zero, - 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) + const failure = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("unknown")).pipe(Effect.flip) expect(failure).toMatchObject({ _tag: "SessionRunnerModel.VariantUnavailableError", @@ -243,7 +198,7 @@ describe("SessionRunnerModel", () => { }), ) - it.effect("overlays selected Anthropic Session variant settings", () => + it.effect("overlays selected Anthropic variant settings", () => Effect.gen(function* () { const catalog = model(ProviderV2.aisdk("@ai-sdk/anthropic"), { settings: { baseURL: "https://anthropic.example/v1" }, @@ -256,18 +211,7 @@ describe("SessionRunnerModel", () => { }, ], }) - const session = SessionV2.Info.make({ - id: SessionV2.ID.make("ses_anthropic_variant"), - projectID: ProjectV2.ID.global, - title: "test", - model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") }, - cost: Money.USD.zero, - 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 resolved = yield* SessionRunnerModel.resolve(session, catalog) + const resolved = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("high")) expect(resolved.route.defaults.http?.body).toEqual({ custom_extension: { enabled: true }, @@ -280,7 +224,7 @@ describe("SessionRunnerModel", () => { it.effect("maps catalog Anthropic AI SDK models into native routes", () => Effect.gen(function* () { - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/anthropic"), { settings: { baseURL: "https://anthropic.example/v1" }, }), @@ -296,7 +240,7 @@ describe("SessionRunnerModel", () => { it.effect("uses resolved credentials for bearer auth", () => Effect.gen(function* () { - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { baseURL: "https://openai.example/v1" }, headers: {}, @@ -320,7 +264,7 @@ describe("SessionRunnerModel", () => { it.effect("prefers stored credentials over configured auth", () => Effect.gen(function* () { const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } }) - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { apiKey: "configured-secret", baseURL: "https://openai.example/v1" }, headers: {}, @@ -343,7 +287,7 @@ describe("SessionRunnerModel", () => { it.effect("does not project OAuth account metadata into the request body", () => Effect.gen(function* () { - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { baseURL: "https://openai.example/v1" }, headers: {}, @@ -365,7 +309,7 @@ describe("SessionRunnerModel", () => { it.effect("routes ChatGPT OAuth credentials to the codex backend", () => Effect.gen(function* () { - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { baseURL: "https://openai.example/v1" }, headers: {}, @@ -400,7 +344,7 @@ describe("SessionRunnerModel", () => { it.effect("routes native OpenAI provider packages with ChatGPT credentials to the codex backend", () => Effect.gen(function* () { - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model("@opencode-ai/ai/providers/openai", { settings: { baseURL: "https://openai.example/v1" }, }), @@ -429,7 +373,7 @@ describe("SessionRunnerModel", () => { it.effect("does not route native OpenAI-compatible packages to the codex backend", () => Effect.gen(function* () { - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model("@opencode-ai/ai/providers/openai-compatible", { settings: { baseURL: "https://compatible.example/v1" }, }), @@ -450,7 +394,7 @@ describe("SessionRunnerModel", () => { it.effect("maps legacy OpenAI organization and project settings to headers", () => Effect.gen(function* () { - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { organization: "org_123", project: "proj_123" }, }), @@ -465,7 +409,7 @@ describe("SessionRunnerModel", () => { it.effect("routes ChatGPT OAuth credentials without an account id to the codex backend", () => Effect.gen(function* () { - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { baseURL: "https://openai.example/v1" }, headers: {}, @@ -496,7 +440,7 @@ describe("SessionRunnerModel", () => { it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () => Effect.gen(function* () { - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { baseURL: "https://openai.example/v1" }, headers: {}, @@ -528,12 +472,12 @@ describe("SessionRunnerModel", () => { it.effect("loads dynamic native provider packages through the injected package loader", () => Effect.gen(function* () { - const native = yield* SessionRunnerModel.fromCatalogModel( + const native = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { baseURL: "https://openai.example/v1" }, }), ) - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model("@opencode-ai/ai/providers/custom", { settings: { region: "test" }, headers: { "x-package": "header" }, @@ -565,7 +509,7 @@ describe("SessionRunnerModel", () => { it.effect("maps OAuth credentials to native provider auth settings", () => Effect.gen(function* () { - const native = yield* SessionRunnerModel.fromCatalogModel( + const native = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { baseURL: "https://openai.example/v1" }, }), @@ -588,7 +532,7 @@ describe("SessionRunnerModel", () => { ] as const yield* Effect.forEach(packages, ([specifier, key]) => - SessionRunnerModel.fromCatalogModel(model(specifier, { settings: { apiKey: "configured-key" } }), credential, { + ModelResolver.fromCatalogModel(model(specifier, { settings: { apiKey: "configured-key" } }), credential, { loadPackage: () => Effect.succeed({ model: (modelID, settings) => { @@ -604,12 +548,12 @@ describe("SessionRunnerModel", () => { it.effect("loads arbitrary AISDK packages through the injected AISDK loader", () => Effect.gen(function* () { - const native = yield* SessionRunnerModel.fromCatalogModel( + const native = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { baseURL: "https://openai.example/v1" }, }), ) - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/google"), { modelID: "gemini-api-model", settings: { project: "test" }, @@ -644,7 +588,7 @@ describe("SessionRunnerModel", () => { it.effect("rejects AISDK packages without an available loader", () => Effect.gen(function* () { - const failure = yield* SessionRunnerModel.fromCatalogModel( + const failure = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/google"), { settings: { baseURL: "https://google.example/v1" }, }), @@ -662,12 +606,12 @@ describe("SessionRunnerModel", () => { it.effect("drops an empty API key before loading an AISDK package", () => Effect.gen(function* () { - const native = yield* SessionRunnerModel.fromCatalogModel( + const native = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { baseURL: "https://openai.example/v1" }, }), ) - yield* SessionRunnerModel.fromCatalogModel( + yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/google"), { settings: { apiKey: "", baseURL: "https://google.example/v1" }, }), @@ -685,9 +629,9 @@ describe("SessionRunnerModel", () => { it.effect("reports whether a catalog model declares a provider package", () => Effect.sync(() => { - expect(SessionRunnerModel.supported(model(ProviderV2.aisdk("@ai-sdk/openai")))).toBe(true) - expect(SessionRunnerModel.supported(model("@opencode-ai/ai/providers/custom"))).toBe(true) - expect(SessionRunnerModel.supported(model(undefined))).toBe(false) + expect(ModelResolver.supported(model(ProviderV2.aisdk("@ai-sdk/openai")))).toBe(true) + expect(ModelResolver.supported(model("@opencode-ai/ai/providers/custom"))).toBe(true) + expect(ModelResolver.supported(model(undefined))).toBe(false) }), ) }) diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts index d37cc83c12..3ad15fc730 100644 --- a/packages/core/test/session-compact.test.ts +++ b/packages/core/test/session-compact.test.ts @@ -49,14 +49,15 @@ const client = Layer.mock(LLMClient.Service)({ generate: () => Effect.die("unused"), }) const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) }) -const models = SessionRunnerModel.layerWith(() => - Effect.succeed( - SessionRunnerModel.resolved(model, { - capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, - cost: [], - }), - ), -) +const models = Layer.mock(SessionRunnerModel.Service)({ + resolve: () => + Effect.succeed( + SessionRunnerModel.resolved(model, { + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + cost: [], + }), + ), +}) const locations = Layer.effect( LocationServiceMap.Service, LayerMap.make( diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index 3f0183167c..98e7aedc0c 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -66,14 +66,15 @@ const client = Layer.mock(LLMClient.Service)({ return response }), }) -const models = SessionRunnerModel.layerWith(() => - Effect.succeed( - SessionRunnerModel.resolved(model, { - capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, - cost: [], - }), - ), -) +const models = Layer.mock(SessionRunnerModel.Service)({ + resolve: () => + Effect.succeed( + SessionRunnerModel.resolved(model, { + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + cost: [], + }), + ), +}) const builtins = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed( diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index d26fa37df4..fb3619e4c6 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -73,14 +73,15 @@ const model = OpenAIChat.route generation: { maxTokens: 20, temperature: 0 }, }) .model({ id: "gpt-4o-mini" }) -const models = SessionRunnerModel.layerWith(() => - Effect.succeed( - SessionRunnerModel.resolved(model, { - capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, - cost: [], - }), - ), -) +const models = Layer.mock(SessionRunnerModel.Service)({ + resolve: () => + Effect.succeed( + SessionRunnerModel.resolved(model, { + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + cost: [], + }), + ), +}) const systemContext = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) }) const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) }) const skillInstructions = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) }) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 6ce9cf0f20..86e9d7c576 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -280,17 +280,18 @@ const echo = Layer.effectDiscard( const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] }) let modelResolveHook = Effect.void let currentModel = model -const models = SessionRunnerModel.layerWith((session) => - modelResolveHook.pipe( - Effect.as( - SessionRunnerModel.resolved(session.model?.id === "replacement" ? replacementModel : currentModel, { - capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, - cost: [], - variant: session.model?.variant, - }), +const models = Layer.mock(SessionRunnerModel.Service)({ + resolve: (session) => + modelResolveHook.pipe( + Effect.as( + SessionRunnerModel.resolved(session.model?.id === "replacement" ? replacementModel : currentModel, { + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + cost: [], + variant: session.model?.variant, + }), + ), ), - ), -) +}) const systemContextKey = Instructions.Key.make("test/context") let systemBaseline = "Initial context" let systemRemoved = false diff --git a/packages/core/test/tool-search.test.ts b/packages/core/test/tool-search.test.ts index 41e25953c4..4367d1b82e 100644 --- a/packages/core/test/tool-search.test.ts +++ b/packages/core/test/tool-search.test.ts @@ -88,8 +88,8 @@ describe("search tools", () => { expect(glob.output?.structured).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT }) expect(grep.output?.structured).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT }) - expect(glob.output?.content).toEqual([{ type: "text", text: glob.result.value }]) - expect(grep.output?.content).toEqual([{ type: "text", text: grep.result.value }]) + expect(glob.output?.content).toEqual([{ type: "text", text: String(glob.result.value) }]) + expect(grep.output?.content).toEqual([{ type: "text", text: String(grep.result.value) }]) expect(String(glob.result.value).split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT) expect(grep.result.value).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`) }),