From 78880f32889afad8ea0d7672bcfd884ed7d397a1 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 29 Jun 2026 20:48:01 +0530 Subject: [PATCH] fix(core): preserve aisdk provider compatibility --- packages/core/src/aisdk.ts | 365 +++++++++++++++++- packages/core/src/plugin/provider/opencode.ts | 6 +- packages/core/src/provider.ts | 12 +- packages/core/src/session/runner/model.ts | 138 +++---- packages/core/src/v1/config/migrate.ts | 13 +- .../core/src/v1/config/provider-options.ts | 38 +- .../core/test/config/provider-options.test.ts | 12 +- .../core/test/plugin/provider-google.test.ts | 20 + .../core/test/session-runner-model.test.ts | 63 +-- 9 files changed, 495 insertions(+), 172 deletions(-) diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 9eb47006f0..5ff78cde70 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -1,12 +1,46 @@ export * as AISDK from "./aisdk" -import type { LanguageModelV3 } from "@ai-sdk/provider" -import { Cause, Context, Effect, Layer, Schema, Scope } from "effect" +import type { + JSONSchema7, + JSONValue, + LanguageModelV3, + LanguageModelV3CallOptions, + LanguageModelV3FunctionTool, + LanguageModelV3Message, + LanguageModelV3Prompt, + LanguageModelV3StreamPart, + LanguageModelV3ToolChoice, + SharedV3ProviderOptions, +} from "@ai-sdk/provider" +import { + FinishReason, + InvalidProviderOutputReason, + LLMEvent, + LLMError, + Model, + ProviderID, + ProviderMetadata, + ToolResultValue, + UnknownProviderReason, + type ContentPart, + type LLMRequest, + type ToolDefinition, + type UsageInput, +} from "@opencode-ai/llm" +import { + Auth, + Endpoint, + type AnyRoute, +} from "@opencode-ai/llm/route" +import { Cause, Context, Effect, Layer, Schema, Scope, Stream } from "effect" import { ModelV2 } from "./model" import { ProviderV2 } from "./provider" import { State } from "./state" type SDK = any +type UserContent = Extract["content"] +type AssistantContent = Extract["content"] +type ToolResultContent = Extract export interface SDKEvent { readonly model: ModelV2.Info @@ -141,6 +175,7 @@ export interface Interface { readonly runSDK: (event: SDKEvent) => Effect.Effect readonly runLanguage: (event: LanguageEvent) => Effect.Effect readonly language: (model: ModelV2.Info) => Effect.Effect + readonly model: (model: ModelV2.Info) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/AISDK") {} @@ -228,9 +263,335 @@ export const locationLayer = Layer.effect( languages.set(key, language) return language }), + model: Effect.fn("AISDK.model")(function* (model) { + return modelFromLanguage(model, yield* service.language(model)) + }), }) return service }), ) export const defaultLayer = locationLayer + +function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) { + const route: AnyRoute = { + id: `ai-sdk:${ProviderV2.packageName(info.package) ?? "unknown"}`, + provider: ProviderID.make(info.providerID), + protocol: "ai-sdk", + endpoint: Endpoint.path("/", { baseURL: "https://ai-sdk.local" }), + auth: Auth.none, + transport: { + id: "ai-sdk", + prepare: (input) => Effect.succeed(input.body), + frames: () => Stream.empty, + }, + defaults: { + headers: info.headers, + http: info.body === undefined ? undefined : { body: { ...info.body } }, + limits: { context: info.limit.context, output: info.limit.output }, + }, + body: { + schema: Schema.Unknown, + from: (request) => Effect.succeed(callOptions(request)), + }, + with: () => route, + model: (input) => + Model.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }), + prepareTransport: (body) => Effect.succeed(body), + streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions), + } + return Model.make({ id: info.modelID ?? info.id, provider: info.providerID, route }) +} + +function callOptions(request: LLMRequest): LanguageModelV3CallOptions { + return { + prompt: prompt(request), + maxOutputTokens: request.generation?.maxTokens ?? request.model.route.defaults.limits?.output, + temperature: request.generation?.temperature, + stopSequences: request.generation?.stop === undefined ? undefined : [...request.generation.stop], + topP: request.generation?.topP, + topK: request.generation?.topK, + presencePenalty: request.generation?.presencePenalty, + frequencyPenalty: request.generation?.frequencyPenalty, + seed: request.generation?.seed, + responseFormat: responseFormat(request), + tools: request.tools.map(tool), + toolChoice: toolChoice(request.toolChoice), + headers: request.http?.headers, + providerOptions: providerOptions(request.providerOptions), + } +} + +function prompt(request: LLMRequest): LanguageModelV3Prompt { + const system = request.system.map((part) => part.text).filter(Boolean).join("\n\n") + const messages = request.messages.flatMap(message) + if (!system.length) return messages + return [{ role: "system", content: system }, ...messages] +} + +function message(input: LLMRequest["messages"][number]): LanguageModelV3Message[] { + switch (input.role) { + case "system": + return [{ role: "system", content: input.content.flatMap(text).join("\n\n") }] + case "user": + return [{ role: "user", content: input.content.flatMap(userPart) }] + case "assistant": + return [{ role: "assistant", content: input.content.flatMap(assistantPart) }] + case "tool": { + const content = input.content.flatMap(toolResultPart) + return content.length ? [{ role: "tool", content }] : [] + } + } +} + +function text(part: ContentPart) { + return part.type === "text" ? [part.text] : [] +} + +function userPart(part: ContentPart): UserContent { + if (part.type === "text") return [{ type: "text", text: part.text }] + if (part.type === "media") return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }] + return [] +} + +function assistantPart(part: ContentPart): AssistantContent { + switch (part.type) { + case "text": + return [{ type: "text", text: part.text }] + case "media": + return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }] + case "reasoning": + return [{ type: "reasoning", text: part.text }] + case "tool-call": + return [ + { + type: "tool-call", + toolCallId: part.id, + toolName: part.name, + input: part.input, + providerExecuted: part.providerExecuted, + }, + ] + case "tool-result": + return toolResultPart(part) + } +} + +function toolResultPart(part: ContentPart): ToolResultContent[] { + if (part.type !== "tool-result") return [] + return [ + { + type: "tool-result", + toolCallId: part.id, + toolName: part.name, + output: toolOutput(part.result), + }, + ] +} + +function toolOutput(result: ToolResultValue) { + switch (result.type) { + case "text": + case "error": + return { type: "text" as const, value: messageValue(result.value) } + } + return { type: "json" as const, value: jsonValue(result.value) } +} + +function tool(input: ToolDefinition): LanguageModelV3FunctionTool { + return { + type: "function", + name: input.name, + description: input.description, + inputSchema: input.inputSchema as JSONSchema7, + } +} + +function toolChoice(input: LLMRequest["toolChoice"]): LanguageModelV3ToolChoice | undefined { + if (!input) return undefined + if (input.type === "tool") return input.name === undefined ? undefined : { type: "tool", toolName: input.name } + return { type: input.type } +} + +function responseFormat(request: LLMRequest): LanguageModelV3CallOptions["responseFormat"] { + if (request.responseFormat?.type === "json") return { type: "json", schema: request.responseFormat.schema as JSONSchema7 } + if (request.responseFormat) return { type: "text" } +} + +function providerOptions(input: LLMRequest["providerOptions"]): SharedV3ProviderOptions | undefined { + if (!input) return undefined + return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)])) +} + +function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallOptions) { + const state = { step: 0, toolNames: {} as Record } + return Stream.concat( + Stream.make(LLMEvent.stepStart({ index: state.step })), + Stream.unwrap( + Effect.tryPromise({ + try: () => language.doStream(options), + catch: (error) => llmError("doStream", error), + }).pipe( + Effect.map((result) => + Stream.fromReadableStream({ + evaluate: () => result.stream, + onError: (error) => llmError("readStream", error), + }).pipe( + Stream.mapEffect((event) => streamPartEvents(state, event)), + Stream.flatMap((events) => Stream.fromIterable(events)), + ), + ), + ), + ), + ) +} + +function streamPartEvents( + state: { step: number; toolNames: Record }, + event: LanguageModelV3StreamPart, +): Effect.Effect, LLMError> { + switch (event.type) { + case "stream-start": + case "response-metadata": + case "raw": + case "file": + case "source": + case "tool-approval-request": + return Effect.succeed([]) + case "text-start": + return Effect.succeed([LLMEvent.textStart({ id: event.id, providerMetadata: providerMetadata(event.providerMetadata) })]) + case "text-delta": + return Effect.succeed([ + LLMEvent.textDelta({ id: event.id, text: event.delta, providerMetadata: providerMetadata(event.providerMetadata) }), + ]) + case "text-end": + return Effect.succeed([LLMEvent.textEnd({ id: event.id, providerMetadata: providerMetadata(event.providerMetadata) })]) + case "reasoning-start": + return Effect.succeed([ + LLMEvent.reasoningStart({ id: event.id, providerMetadata: providerMetadata(event.providerMetadata) }), + ]) + case "reasoning-delta": + return Effect.succeed([ + LLMEvent.reasoningDelta({ id: event.id, text: event.delta, providerMetadata: providerMetadata(event.providerMetadata) }), + ]) + case "reasoning-end": + return Effect.succeed([ + LLMEvent.reasoningEnd({ id: event.id, providerMetadata: providerMetadata(event.providerMetadata) }), + ]) + case "tool-input-start": + state.toolNames[event.id] = event.toolName + return Effect.succeed([ + LLMEvent.toolInputStart({ id: event.id, name: event.toolName, providerMetadata: providerMetadata(event.providerMetadata) }), + ]) + case "tool-input-delta": + return Effect.succeed([LLMEvent.toolInputDelta({ id: event.id, name: state.toolNames[event.id] ?? "unknown", text: event.delta })]) + case "tool-input-end": + return Effect.succeed([ + LLMEvent.toolInputEnd({ id: event.id, name: state.toolNames[event.id] ?? "unknown", providerMetadata: providerMetadata(event.providerMetadata) }), + ]) + case "tool-call": + state.toolNames[event.toolCallId] = event.toolName + return Effect.succeed([ + LLMEvent.toolCall({ + id: event.toolCallId, + name: event.toolName, + input: parseToolInput(event.input), + providerExecuted: event.providerExecuted, + providerMetadata: providerMetadata(event.providerMetadata), + }), + ]) + case "tool-result": + delete state.toolNames[event.toolCallId] + return Effect.succeed([ + LLMEvent.toolResult({ + id: event.toolCallId, + name: event.toolName, + result: ToolResultValue.make(event.result, event.isError ? "error" : "json"), + providerExecuted: true, + providerMetadata: providerMetadata(event.providerMetadata), + }), + ]) + case "finish": + return Effect.succeed([ + LLMEvent.stepFinish({ + index: state.step++, + reason: finishReason(event.finishReason), + usage: usage(event.usage), + providerMetadata: providerMetadata(event.providerMetadata), + }), + LLMEvent.finish({ + reason: finishReason(event.finishReason), + usage: usage(event.usage), + providerMetadata: providerMetadata(event.providerMetadata), + }), + ]) + case "error": + return Effect.fail(llmError("stream", event.error)) + } +} + +function usage(input: Extract["usage"]): UsageInput | undefined { + const output = { + inputTokens: input.inputTokens.total, + nonCachedInputTokens: input.inputTokens.noCache, + cacheReadInputTokens: input.inputTokens.cacheRead, + cacheWriteInputTokens: input.inputTokens.cacheWrite, + outputTokens: input.outputTokens.total, + reasoningTokens: input.outputTokens.reasoning, + totalTokens: + input.inputTokens.total === undefined || input.outputTokens.total === undefined + ? undefined + : input.inputTokens.total + input.outputTokens.total, + } + return Object.values(output).some((value) => value !== undefined) ? output : undefined +} + +function finishReason(value: unknown): FinishReason { + return Schema.is(FinishReason)(value) ? value : "unknown" +} + +function providerMetadata(value: unknown) { + return Schema.is(ProviderMetadata)(value) ? value : undefined +} + +function parseToolInput(value: string) { + try { + return JSON.parse(value) as unknown + } catch { + return value + } +} + +function jsonObject(input: Record) { + return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonValue(value)])) +} + +function jsonValue(input: unknown): JSONValue { + try { + const encoded = JSON.stringify(input) + return encoded === undefined ? null : (JSON.parse(encoded) as JSONValue) + } catch { + return messageValue(input) + } +} + +function messageValue(input: unknown) { + if (typeof input === "string") return input + try { + return JSON.stringify(input) ?? String(input) + } catch { + return String(input) + } +} + +function llmError(method: string, error: unknown) { + const reason = + error instanceof LLMError + ? new InvalidProviderOutputReason({ message: error.message }) + : new UnknownProviderReason({ message: error instanceof Error ? error.message : String(error) }) + return new LLMError({ + module: "AISDK", + method, + reason, + }) +} diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index f515d6cc23..24e0c3c54b 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -132,15 +132,13 @@ export const OpencodePlugin = define ({ id: ModelV2.VariantID.make(id), headers: { ...(options.headers ?? {}) }, - settings: lowerer.model(withoutCredentials(options)), + settings: ConfigProviderOptionsV1.model(withoutCredentials(options)), })) } if (config.release_date !== undefined) { diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index d7a48f0e8d..b9e1928a94 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -3,7 +3,7 @@ export * as ProviderV2 from "./provider" import { Effect, Schema } from "effect" import { pathToFileURL } from "url" import { Provider } from "@opencode-ai/schema/provider" -import type { Model, ProviderPackageDefinition, ProviderPackageSettings } from "@opencode-ai/llm" +import type { ProviderPackageDefinition } from "@opencode-ai/llm" import { Npm } from "./npm" import type { DeepMutable } from "./schema" @@ -43,12 +43,6 @@ export const loadPackage = Effect.fn("ProviderV2.loadPackage")(function* (specif return yield* importPackage(specifier, entrypoint) }) -export const makeModel = ( - module: ProviderPackageDefinition, - modelID: string, - settings: ProviderPackageSettings, -): Model => module.model(modelID, settings) - export function mergeOverlay( base: Readonly> | undefined, overlay: Readonly> | undefined, @@ -81,8 +75,10 @@ export function mergeHeaders( base: Readonly> | undefined, overlay: Readonly> | undefined, ) { + if (base === undefined) return overlay && { ...overlay } + if (overlay === undefined) return { ...base } return Object.fromEntries( - [...Object.entries(base ?? {}), ...Object.entries(overlay ?? {})] + [...Object.entries(base), ...Object.entries(overlay)] .reduce((result, entry) => { result.set(entry[0].toLowerCase(), entry) return result diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 4b3045182e..a347c9919d 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -1,12 +1,9 @@ export * as SessionRunnerModel from "./model" -import { type Model } from "@opencode-ai/llm" -import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages" -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 { Model } from "@opencode-ai/llm" 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" @@ -80,27 +77,6 @@ export class Service extends Context.Service()("@opencode/v2 /** Test or embedding seam for supplying a model resolver directly. */ export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve })) -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) => { - const body = model.body ?? {} - const httpBody = Object.hasOwn(body, "apiKey") - ? Object.fromEntries(Object.entries(body).filter(([key]) => key !== "apiKey")) - : body - return route.with({ - provider: model.providerID, - endpoint: typeof model.settings?.baseURL === "string" ? { baseURL: model.settings.baseURL } : undefined, - headers: model.headers, - http: { body: httpBody }, - limits: { context: model.limit.context, output: model.limit.output }, - }) -} - const withVariant = ( model: ModelV2.Info, variantID: ModelV2.VariantID | undefined, @@ -126,105 +102,79 @@ const withVariant = ( ) } +export interface Dependencies { + readonly loadPackage?: (specifier: string) => Effect.Effect + readonly loadAISDK?: (model: ModelV2.Info) => Effect.Effect +} + +const unsupported = (model: ModelV2.Info, packageName = model.package ?? "unknown") => + new UnsupportedPackageError({ + providerID: model.providerID, + modelID: model.id, + package: packageName, + }) + +const credentialSettings = (credential: Credential.Value | undefined) => ({ + ...(credential?.type === "key" ? { apiKey: credential.key } : {}), + ...(credential?.type === "oauth" ? { apiKey: credential.access } : {}), + ...credential?.metadata, +}) + export const fromCatalogModel = ( model: ModelV2.Info, credential?: Credential.Value, - loadPackage: ( - specifier: string, - ) => Effect.Effect = ProviderV2.loadPackage, + dependencies: Dependencies = {}, ): Effect.Effect => { const resolved = credential?.metadata === undefined ? model : produce(model, (draft) => { draft.settings = ProviderV2.mergeOverlay(draft.settings, credential.metadata) - }) - const key = apiKey(resolved, credential) - const packageName = ProviderV2.packageName(resolved.package) - if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") { - return Effect.succeed( - withDefaults(resolved, OpenAIResponses.route) - .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) - .model({ id: resolved.modelID ?? resolved.id }), + }) + if (ProviderV2.isAISDK(resolved.package)) { + if (!dependencies.loadAISDK) { + return Effect.fail(unsupported(resolved)) + } + const runtime = produce(resolved, (draft) => { + draft.settings = ProviderV2.mergeOverlay(draft.settings, credentialSettings(credential)) + }) + return dependencies.loadAISDK(runtime).pipe( + Effect.mapError(() => unsupported(resolved)), ) } - 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 }), - ) - } - 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 }), - ) - } - if (!ProviderV2.isAISDK(resolved.package) && resolved.package) { + if (resolved.package) { const specifier = resolved.package return Effect.gen(function* () { - const module = yield* loadPackage(specifier).pipe( - Effect.mapError( - () => - new UnsupportedPackageError({ - providerID: resolved.providerID, - modelID: resolved.id, - package: specifier, - }), - ), + const module = yield* (dependencies.loadPackage ?? ProviderV2.loadPackage)(specifier).pipe( + Effect.mapError(() => unsupported(resolved, specifier)), ) const settings = { ...resolved.settings, - ...(credential?.type === "key" ? { apiKey: credential.key } : {}), - ...(credential?.type === "oauth" ? { apiKey: credential.access } : {}), - ...credential?.metadata, + ...credentialSettings(credential), headers: resolved.headers, body: resolved.body, limits: { context: resolved.limit.context, output: resolved.limit.output }, } return yield* Effect.try({ - try: () => ProviderV2.makeModel(module, resolved.modelID ?? resolved.id, settings), - catch: () => - new UnsupportedPackageError({ - providerID: resolved.providerID, - modelID: resolved.id, - package: specifier, - }), + try: () => Model.update(module.model(resolved.modelID ?? resolved.id, settings), { provider: resolved.providerID }), + catch: () => unsupported(resolved, specifier), }) }) } - return Effect.fail( - new UnsupportedPackageError({ - providerID: resolved.providerID, - modelID: resolved.id, - package: resolved.package ?? "unknown", - }), - ) + return Effect.fail(unsupported(resolved)) } export const resolve = ( session: SessionSchema.Info, model: ModelV2.Info, credential?: Credential.Value, - loadPackage?: (specifier: string) => Effect.Effect, + dependencies?: Dependencies, ) => withVariant(model, session.model?.variant).pipe( - Effect.flatMap((model) => fromCatalogModel(model, credential, loadPackage)), + Effect.flatMap((model) => fromCatalogModel(model, credential, dependencies)), ) -export const supported = (model: ModelV2.Info) => - Boolean(model.package) && - (!ProviderV2.isAISDK(model.package) || - ProviderV2.packageName(model.package) === "@ai-sdk/openai" || - ProviderV2.packageName(model.package) === "@ai-sdk/anthropic" || - (ProviderV2.packageName(model.package) === "@ai-sdk/openai-compatible" && - typeof model.settings?.baseURL === "string")) +export const supported = (model: ModelV2.Info) => Boolean(model.package) /** Resolves models from the catalog belonging to the current Location runtime. */ export const locationLayer = Layer.effect( @@ -233,6 +183,7 @@ export const locationLayer = Layer.effect( const catalog = yield* Catalog.Service const integrations = yield* Integration.Service const npm = yield* Npm.Service + const aisdk = yield* AISDK.Service return Service.of({ resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) { // Location plugins populate and filter the catalog asynchronously during layer startup. @@ -258,7 +209,10 @@ export const locationLayer = Layer.effect( session, selected, connection ? yield* integrations.connection.resolve(connection) : undefined, - (specifier) => ProviderV2.loadPackage(specifier, npm), + { + loadPackage: (specifier) => ProviderV2.loadPackage(specifier, npm), + loadAISDK: (model) => aisdk.model(model), + }, ) }), }) diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 7489878c6e..49a4c4bf45 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -169,8 +169,7 @@ function providers(info?: Readonly>) { } function migrateProvider(info: ConfigProviderV1.Info) { - const lowerer = ConfigProviderOptionsV1.get(info.npm) - const options = lowerer.provider(info.options ?? {}) + const options = ConfigProviderOptionsV1.provider(info.options ?? {}) return { name: info.name, env: info.env, @@ -180,14 +179,12 @@ function migrateProvider(info: ConfigProviderV1.Info) { body: info.options && options.body, models: info.models && - Object.fromEntries(Object.entries(info.models).map(([name, model]) => [name, migrateModel(model, info.npm)])), + Object.fromEntries(Object.entries(info.models).map(([name, model]) => [name, migrateModel(model)])), } } -function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: string) { - const packageID = info.provider?.npm ?? packageName - const lowerer = ConfigProviderOptionsV1.get(packageID) - const settings = info.options && lowerer.model(info.options) +function migrateModel(info: typeof ConfigProviderV1.Model.Type) { + const settings = info.options && ConfigProviderOptionsV1.model(info.options) const costs = info.cost && [ { input: info.cost.input, @@ -221,7 +218,7 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: st info.variants && Object.entries(info.variants).map(([id, options]) => ({ id, - settings: lowerer.model(options), + settings: ConfigProviderOptionsV1.model(options), })), cost: costs, disabled: info.status === "deprecated" ? true : undefined, diff --git a/packages/core/src/v1/config/provider-options.ts b/packages/core/src/v1/config/provider-options.ts index 16206aa631..6b61391100 100644 --- a/packages/core/src/v1/config/provider-options.ts +++ b/packages/core/src/v1/config/provider-options.ts @@ -8,27 +8,23 @@ export interface ProviderResult { readonly body?: Record } -export interface Lowerer { - readonly provider: (options: Options) => ProviderResult - readonly model: (options: Options) => Record +export function provider(options: Options): ProviderResult { + const headers = options.headers + const body = options.body + const entries = Object.entries(options) + const settings = Object.fromEntries(entries.filter(([key]) => key !== "headers" && key !== "body")) + const headerOverlay = + typeof headers === "object" && headers !== null && !Array.isArray(headers) + ? Object.fromEntries(Object.entries(headers).filter((entry): entry is [string, string] => typeof entry[1] === "string")) + : undefined + const bodyOverlay = typeof body === "object" && body !== null && !Array.isArray(body) ? { ...body } : undefined + return { + settings, + headers: headerOverlay, + body: bodyOverlay, + } } -const lowerer: Lowerer = { - provider(options) { - return { - settings: Object.fromEntries(Object.entries(options).filter(([key]) => key !== "headers" && key !== "body")), - headers: record(options.headers, (value): value is string => typeof value === "string"), - body: record(options.body, (_value): _value is unknown => true), - } - }, - model: (options) => ({ ...options }), -} - -export function get(_packageName?: string): Lowerer { - return lowerer -} - -function record(input: unknown, guard: (value: unknown) => value is T) { - if (typeof input !== "object" || input === null || Array.isArray(input)) return undefined - return Object.fromEntries(Object.entries(input).filter((entry): entry is [string, T] => guard(entry[1]))) +export function model(options: Options) { + return { ...options } } diff --git a/packages/core/test/config/provider-options.test.ts b/packages/core/test/config/provider-options.test.ts index d9fbe2dfd7..ac746e4441 100644 --- a/packages/core/test/config/provider-options.test.ts +++ b/packages/core/test/config/provider-options.test.ts @@ -3,10 +3,8 @@ import { ConfigProviderOptionsV1 } from "@opencode-ai/core/v1/config/provider-op describe("ConfigProviderOptionsV1", () => { test("splits provider overlays without changing package settings", () => { - const lowerer = ConfigProviderOptionsV1.get("@ai-sdk/openai") - expect( - lowerer.provider({ + ConfigProviderOptionsV1.provider({ apiKey: "secret", baseURL: "https://openai.example/v1", organization: "org", @@ -27,10 +25,8 @@ describe("ConfigProviderOptionsV1", () => { }) test("keeps model and variant options unchanged", () => { - const lowerer = ConfigProviderOptionsV1.get("@ai-sdk/anthropic") - expect( - lowerer.model({ + ConfigProviderOptionsV1.model({ reasoningEffort: "high", taskBudget: 1024, metadata: { userId: "user" }, @@ -42,8 +38,8 @@ describe("ConfigProviderOptionsV1", () => { }) }) - test("uses the same mechanical lowering for every package", () => { - expect(ConfigProviderOptionsV1.get("custom-provider").provider({ enabled: true })).toEqual({ + test("uses mechanical lowering for custom provider options", () => { + expect(ConfigProviderOptionsV1.provider({ enabled: true })).toEqual({ settings: { enabled: true }, headers: undefined, body: undefined, diff --git a/packages/core/test/plugin/provider-google.test.ts b/packages/core/test/plugin/provider-google.test.ts index fd9a4c90dd..d04e00e3b3 100644 --- a/packages/core/test/plugin/provider-google.test.ts +++ b/packages/core/test/plugin/provider-google.test.ts @@ -80,4 +80,24 @@ describe("GooglePlugin", () => { expect(language.provider).toBe("custom-google") }), ) + + it.effect("wraps AI SDK language models for the native runner", () => + Effect.gen(function* () { + const aisdk = yield* AISDK.Service + yield* addPlugin() + + const resolved = yield* aisdk.model( + ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")), + modelID: ModelV2.ID.make("gemini-api"), + package: "aisdk:@ai-sdk/google", + settings: { apiKey: "test" }, + }), + ) + + expect(String(resolved.id)).toBe("gemini-api") + expect(String(resolved.provider)).toBe("custom-google") + expect(resolved.route.id).toBe("ai-sdk:@ai-sdk/google") + }), + ) }) diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 824e0278f1..b53b656f54 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" import { LLM } from "@opencode-ai/llm" +import { OpenAI } from "@opencode-ai/llm/providers" import { LLMClient } from "@opencode-ai/llm/route" import { DateTime, Effect } from "effect" import { Headers } from "effect/unstable/http" @@ -36,6 +37,18 @@ const model = (transport: Transport, variants?: NonNullable + Effect.succeed( + OpenAI.model(input.modelID ?? input.id, { + ...input.settings, + headers: input.headers, + body: input.body, + limits: { context: input.limit.context, output: input.limit.output }, + }), + ), +} + describe("SessionRunnerModel", () => { it.effect("constructs native provider package models mechanically", () => Effect.gen(function* () { @@ -63,10 +76,10 @@ describe("SessionRunnerModel", () => { }), ) - it.effect("maps catalog OpenAI AI SDK models into native Responses routes", () => + it.effect("maps catalog OpenAI native provider packages into Responses routes", () => Effect.gen(function* () { const resolved = yield* SessionRunnerModel.fromCatalogModel( - model({ package: "aisdk:@ai-sdk/openai", settings: { baseURL: "https://openai.example/v1" } }), + model({ package: "@opencode-ai/llm/providers/openai", settings: { baseURL: "https://openai.example/v1" } }), ) expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" }) @@ -85,7 +98,7 @@ describe("SessionRunnerModel", () => { it.effect("keeps catalog apiKey credentials out of provider JSON", () => Effect.gen(function* () { const resolved = yield* SessionRunnerModel.fromCatalogModel( - model({ package: "aisdk:@ai-sdk/openai", settings: { baseURL: "https://openai.example/v1" } }), + model({ package: "@opencode-ai/llm/providers/openai", settings: { baseURL: "https://openai.example/v1" } }), ) const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" })) @@ -99,7 +112,7 @@ describe("SessionRunnerModel", () => { const resolved = yield* SessionRunnerModel.fromCatalogModel( ModelV2.Info.make({ ...model({ - package: "aisdk:@ai-sdk/openai-compatible", + package: "@opencode-ai/llm/providers/openai-compatible", settings: { apiKey: "settings-secret", baseURL: "https://compatible.example/v1", @@ -126,7 +139,7 @@ describe("SessionRunnerModel", () => { it.effect("overlays selected OpenAI Session variant bodies", () => Effect.gen(function* () { - const catalog = model({ package: "aisdk:@ai-sdk/openai", settings: { baseURL: "https://openai.example/v1" } }, [ + const catalog = model({ package: "@opencode-ai/llm/providers/openai", settings: { baseURL: "https://openai.example/v1" } }, [ { id: ModelV2.VariantID.make("high"), headers: { "x-variant": "high" }, @@ -170,7 +183,7 @@ describe("SessionRunnerModel", () => { Effect.gen(function* () { const catalog = model( { - package: "aisdk:@ai-sdk/openai-compatible", + package: "@opencode-ai/llm/providers/openai-compatible", settings: { baseURL: "https://compatible.example/v1" }, }, [ @@ -204,7 +217,7 @@ describe("SessionRunnerModel", () => { it.effect("rejects an explicit unavailable Session variant during model resolution", () => Effect.gen(function* () { - const catalog = model({ package: "aisdk:@ai-sdk/openai", settings: { baseURL: "https://openai.example/v1" } }) + const catalog = model({ package: "@opencode-ai/llm/providers/openai", settings: { baseURL: "https://openai.example/v1" } }) const session = SessionV2.Info.make({ id: SessionV2.ID.make("ses_model_variant_unavailable"), projectID: ProjectV2.ID.global, @@ -235,7 +248,7 @@ describe("SessionRunnerModel", () => { it.effect("overlays selected Anthropic Session variant bodies", () => Effect.gen(function* () { const catalog = model( - { package: "aisdk:@ai-sdk/anthropic", settings: { baseURL: "https://anthropic.example/v1" } }, + { package: "@opencode-ai/llm/providers/anthropic", settings: { baseURL: "https://anthropic.example/v1" } }, [ { id: ModelV2.VariantID.make("high"), @@ -264,10 +277,10 @@ describe("SessionRunnerModel", () => { }), ) - it.effect("maps catalog Anthropic AI SDK models into native routes", () => + it.effect("maps catalog Anthropic native provider packages into native routes", () => Effect.gen(function* () { const resolved = yield* SessionRunnerModel.fromCatalogModel( - model({ package: "aisdk:@ai-sdk/anthropic", settings: { baseURL: "https://anthropic.example/v1" } }), + model({ package: "@opencode-ai/llm/providers/anthropic", settings: { baseURL: "https://anthropic.example/v1" } }), ) expect(resolved.route).toMatchObject({ @@ -281,7 +294,7 @@ describe("SessionRunnerModel", () => { Effect.gen(function* () { const resolved = yield* SessionRunnerModel.fromCatalogModel( ModelV2.Info.make({ - ...model({ package: "aisdk:@ai-sdk/openai", settings: { baseURL: "https://openai.example/v1" } }), + ...model({ package: "@opencode-ai/llm/providers/openai", settings: { baseURL: "https://openai.example/v1" } }), headers: {}, body: {}, }), @@ -305,7 +318,7 @@ describe("SessionRunnerModel", () => { const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } }) const resolved = yield* SessionRunnerModel.fromCatalogModel( ModelV2.Info.make({ - ...model({ package: "aisdk:@ai-sdk/openai", settings: { baseURL: "https://openai.example/v1" } }), + ...model({ package: "@opencode-ai/llm/providers/openai", settings: { baseURL: "https://openai.example/v1" } }), settings: { apiKey: "configured-secret", baseURL: "https://openai.example/v1" }, headers: {}, body: {}, @@ -325,34 +338,26 @@ describe("SessionRunnerModel", () => { }), ) - it.effect("rejects catalog APIs without a native route", () => + it.effect("delegates aisdk-prefixed packages to the compatibility resolver", () => Effect.gen(function* () { - const failure = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* SessionRunnerModel.fromCatalogModel( model({ package: "aisdk:@ai-sdk/google", settings: { baseURL: "https://google.example/v1" } }), - ).pipe(Effect.flip) + undefined, + aisdkDependencies, + ) - expect(failure).toMatchObject({ - _tag: "SessionRunnerModel.UnsupportedPackageError", - providerID: "test-provider", - modelID: "test-model", - package: "aisdk:@ai-sdk/google", - }) - expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/google") + expect(resolved.route.id).toBe("openai-responses") + expect(resolved.route.endpoint).toMatchObject({ baseURL: "https://google.example/v1" }) }), ) - it.effect("reports whether a catalog model has a supported native route", () => + it.effect("reports whether a catalog model has a supported package", () => Effect.sync(() => { - expect( - SessionRunnerModel.supported( - model({ package: "aisdk:@ai-sdk/openai", settings: { baseURL: "https://openai.example/v1" } }), - ), - ).toBe(true) expect( SessionRunnerModel.supported( model({ package: "aisdk:@ai-sdk/google", settings: { baseURL: "https://google.example/v1" } }), ), - ).toBe(false) + ).toBe(true) expect(SessionRunnerModel.supported(model({ package: "native-provider-package" }))).toBe(true) }), )