fix(core): preserve aisdk provider compatibility

This commit is contained in:
Shoubhit Dash 2026-06-29 20:48:01 +05:30
commit 78880f3288
9 changed files with 495 additions and 172 deletions

View file

@ -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<LanguageModelV3Message, { role: "user" }>["content"]
type AssistantContent = Extract<LanguageModelV3Message, { role: "assistant" }>["content"]
type ToolResultContent = Extract<AssistantContent[number], { type: "tool-result" }>
export interface SDKEvent {
readonly model: ModelV2.Info
@ -141,6 +175,7 @@ export interface Interface {
readonly runSDK: (event: SDKEvent) => Effect.Effect<SDKEvent>
readonly runLanguage: (event: LanguageEvent) => Effect.Effect<LanguageEvent>
readonly language: (model: ModelV2.Info) => Effect.Effect<LanguageModelV3, InitError>
readonly model: (model: ModelV2.Info) => Effect.Effect<Model, InitError>
}
export class Service extends Context.Service<Service, Interface>()("@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<string, string> }
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<string, string> },
event: LanguageModelV3StreamPart,
): Effect.Effect<ReadonlyArray<LLMEvent>, 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<LanguageModelV3StreamPart, { type: "finish" }>["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<string, unknown>) {
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,
})
}

View file

@ -132,15 +132,13 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
if (config.tool_call !== undefined) model.capabilities.tools = config.tool_call
if (config.modalities?.input !== undefined) model.capabilities.input = [...config.modalities.input]
if (config.modalities?.output !== undefined) model.capabilities.output = [...config.modalities.output]
const packageName = config.provider?.npm ?? item.npm
const lowerer = ConfigProviderOptionsV1.get(packageName)
model.headers = { ...model.headers, ...config.headers }
model.settings = { ...model.settings, ...lowerer.model(withoutCredentials(config.options)) }
model.settings = { ...model.settings, ...ConfigProviderOptionsV1.model(withoutCredentials(config.options)) }
if (config.variants !== undefined) {
model.variants = Object.entries(config.variants).map(([id, options]) => ({
id: ModelV2.VariantID.make(id),
headers: { ...(options.headers ?? {}) },
settings: lowerer.model(withoutCredentials(options)),
settings: ConfigProviderOptionsV1.model(withoutCredentials(options)),
}))
}
if (config.release_date !== undefined) {

View file

@ -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<Record<string, unknown>> | undefined,
overlay: Readonly<Record<string, unknown>> | undefined,
@ -81,8 +75,10 @@ export function mergeHeaders(
base: Readonly<Record<string, string>> | undefined,
overlay: Readonly<Record<string, string>> | 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

View file

@ -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<Service, Interface>()("@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<ProviderV2.ProviderPackage, ProviderV2.LoadError>
readonly loadAISDK?: (model: ModelV2.Info) => Effect.Effect<Model, AISDK.InitError>
}
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.ProviderPackage, ProviderV2.LoadError> = ProviderV2.loadPackage,
dependencies: Dependencies = {},
): Effect.Effect<Model, UnsupportedPackageError> => {
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<ProviderV2.ProviderPackage, ProviderV2.LoadError>,
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),
},
)
}),
})

View file

@ -169,8 +169,7 @@ function providers(info?: Readonly<Record<string, ConfigProviderV1.Info>>) {
}
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,

View file

@ -8,27 +8,23 @@ export interface ProviderResult {
readonly body?: Record<string, unknown>
}
export interface Lowerer {
readonly provider: (options: Options) => ProviderResult
readonly model: (options: Options) => Record<string, unknown>
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<T>(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 }
}

View file

@ -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,

View file

@ -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")
}),
)
})

View file

@ -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<ModelV2.Info["varian
limit: { context: 100, output: 20 },
})
const aisdkDependencies = {
loadAISDK: (input: ModelV2.Info) =>
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)
}),
)