fix(core): preserve aisdk provider compatibility
This commit is contained in:
parent
864f16c703
commit
78880f3288
9 changed files with 495 additions and 172 deletions
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue