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"
|
export * as AISDK from "./aisdk"
|
||||||
|
|
||||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
import type {
|
||||||
import { Cause, Context, Effect, Layer, Schema, Scope } from "effect"
|
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 { ModelV2 } from "./model"
|
||||||
import { ProviderV2 } from "./provider"
|
import { ProviderV2 } from "./provider"
|
||||||
import { State } from "./state"
|
import { State } from "./state"
|
||||||
|
|
||||||
type SDK = any
|
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 {
|
export interface SDKEvent {
|
||||||
readonly model: ModelV2.Info
|
readonly model: ModelV2.Info
|
||||||
|
|
@ -141,6 +175,7 @@ export interface Interface {
|
||||||
readonly runSDK: (event: SDKEvent) => Effect.Effect<SDKEvent>
|
readonly runSDK: (event: SDKEvent) => Effect.Effect<SDKEvent>
|
||||||
readonly runLanguage: (event: LanguageEvent) => Effect.Effect<LanguageEvent>
|
readonly runLanguage: (event: LanguageEvent) => Effect.Effect<LanguageEvent>
|
||||||
readonly language: (model: ModelV2.Info) => Effect.Effect<LanguageModelV3, InitError>
|
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") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/AISDK") {}
|
||||||
|
|
@ -228,9 +263,335 @@ export const locationLayer = Layer.effect(
|
||||||
languages.set(key, language)
|
languages.set(key, language)
|
||||||
return language
|
return language
|
||||||
}),
|
}),
|
||||||
|
model: Effect.fn("AISDK.model")(function* (model) {
|
||||||
|
return modelFromLanguage(model, yield* service.language(model))
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
return service
|
return service
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = locationLayer
|
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.tool_call !== undefined) model.capabilities.tools = config.tool_call
|
||||||
if (config.modalities?.input !== undefined) model.capabilities.input = [...config.modalities.input]
|
if (config.modalities?.input !== undefined) model.capabilities.input = [...config.modalities.input]
|
||||||
if (config.modalities?.output !== undefined) model.capabilities.output = [...config.modalities.output]
|
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.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) {
|
if (config.variants !== undefined) {
|
||||||
model.variants = Object.entries(config.variants).map(([id, options]) => ({
|
model.variants = Object.entries(config.variants).map(([id, options]) => ({
|
||||||
id: ModelV2.VariantID.make(id),
|
id: ModelV2.VariantID.make(id),
|
||||||
headers: { ...(options.headers ?? {}) },
|
headers: { ...(options.headers ?? {}) },
|
||||||
settings: lowerer.model(withoutCredentials(options)),
|
settings: ConfigProviderOptionsV1.model(withoutCredentials(options)),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
if (config.release_date !== undefined) {
|
if (config.release_date !== undefined) {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ export * as ProviderV2 from "./provider"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { Provider } from "@opencode-ai/schema/provider"
|
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 { Npm } from "./npm"
|
||||||
import type { DeepMutable } from "./schema"
|
import type { DeepMutable } from "./schema"
|
||||||
|
|
||||||
|
|
@ -43,12 +43,6 @@ export const loadPackage = Effect.fn("ProviderV2.loadPackage")(function* (specif
|
||||||
return yield* importPackage(specifier, entrypoint)
|
return yield* importPackage(specifier, entrypoint)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const makeModel = (
|
|
||||||
module: ProviderPackageDefinition,
|
|
||||||
modelID: string,
|
|
||||||
settings: ProviderPackageSettings,
|
|
||||||
): Model => module.model(modelID, settings)
|
|
||||||
|
|
||||||
export function mergeOverlay(
|
export function mergeOverlay(
|
||||||
base: Readonly<Record<string, unknown>> | undefined,
|
base: Readonly<Record<string, unknown>> | undefined,
|
||||||
overlay: Readonly<Record<string, unknown>> | undefined,
|
overlay: Readonly<Record<string, unknown>> | undefined,
|
||||||
|
|
@ -81,8 +75,10 @@ export function mergeHeaders(
|
||||||
base: Readonly<Record<string, string>> | undefined,
|
base: Readonly<Record<string, string>> | undefined,
|
||||||
overlay: 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(
|
return Object.fromEntries(
|
||||||
[...Object.entries(base ?? {}), ...Object.entries(overlay ?? {})]
|
[...Object.entries(base), ...Object.entries(overlay)]
|
||||||
.reduce((result, entry) => {
|
.reduce((result, entry) => {
|
||||||
result.set(entry[0].toLowerCase(), entry)
|
result.set(entry[0].toLowerCase(), entry)
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
export * as SessionRunnerModel from "./model"
|
export * as SessionRunnerModel from "./model"
|
||||||
|
|
||||||
import { type Model } from "@opencode-ai/llm"
|
import { 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 { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { produce } from "immer"
|
import { produce } from "immer"
|
||||||
|
import { AISDK } from "../../aisdk"
|
||||||
import { Catalog } from "../../catalog"
|
import { Catalog } from "../../catalog"
|
||||||
import { Credential } from "../../credential"
|
import { Credential } from "../../credential"
|
||||||
import { Integration } from "../../integration"
|
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. */
|
/** Test or embedding seam for supplying a model resolver directly. */
|
||||||
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
|
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 = (
|
const withVariant = (
|
||||||
model: ModelV2.Info,
|
model: ModelV2.Info,
|
||||||
variantID: ModelV2.VariantID | undefined,
|
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 = (
|
export const fromCatalogModel = (
|
||||||
model: ModelV2.Info,
|
model: ModelV2.Info,
|
||||||
credential?: Credential.Value,
|
credential?: Credential.Value,
|
||||||
loadPackage: (
|
dependencies: Dependencies = {},
|
||||||
specifier: string,
|
|
||||||
) => Effect.Effect<ProviderV2.ProviderPackage, ProviderV2.LoadError> = ProviderV2.loadPackage,
|
|
||||||
): Effect.Effect<Model, UnsupportedPackageError> => {
|
): Effect.Effect<Model, UnsupportedPackageError> => {
|
||||||
const resolved =
|
const resolved =
|
||||||
credential?.metadata === undefined
|
credential?.metadata === undefined
|
||||||
? model
|
? model
|
||||||
: produce(model, (draft) => {
|
: produce(model, (draft) => {
|
||||||
draft.settings = ProviderV2.mergeOverlay(draft.settings, credential.metadata)
|
draft.settings = ProviderV2.mergeOverlay(draft.settings, credential.metadata)
|
||||||
})
|
})
|
||||||
const key = apiKey(resolved, credential)
|
if (ProviderV2.isAISDK(resolved.package)) {
|
||||||
const packageName = ProviderV2.packageName(resolved.package)
|
if (!dependencies.loadAISDK) {
|
||||||
if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
|
return Effect.fail(unsupported(resolved))
|
||||||
return Effect.succeed(
|
}
|
||||||
withDefaults(resolved, OpenAIResponses.route)
|
const runtime = produce(resolved, (draft) => {
|
||||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
draft.settings = ProviderV2.mergeOverlay(draft.settings, credentialSettings(credential))
|
||||||
.model({ id: resolved.modelID ?? resolved.id }),
|
})
|
||||||
|
return dependencies.loadAISDK(runtime).pipe(
|
||||||
|
Effect.mapError(() => unsupported(resolved)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") {
|
if (resolved.package) {
|
||||||
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) {
|
|
||||||
const specifier = resolved.package
|
const specifier = resolved.package
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const module = yield* loadPackage(specifier).pipe(
|
const module = yield* (dependencies.loadPackage ?? ProviderV2.loadPackage)(specifier).pipe(
|
||||||
Effect.mapError(
|
Effect.mapError(() => unsupported(resolved, specifier)),
|
||||||
() =>
|
|
||||||
new UnsupportedPackageError({
|
|
||||||
providerID: resolved.providerID,
|
|
||||||
modelID: resolved.id,
|
|
||||||
package: specifier,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
const settings = {
|
const settings = {
|
||||||
...resolved.settings,
|
...resolved.settings,
|
||||||
...(credential?.type === "key" ? { apiKey: credential.key } : {}),
|
...credentialSettings(credential),
|
||||||
...(credential?.type === "oauth" ? { apiKey: credential.access } : {}),
|
|
||||||
...credential?.metadata,
|
|
||||||
headers: resolved.headers,
|
headers: resolved.headers,
|
||||||
body: resolved.body,
|
body: resolved.body,
|
||||||
limits: { context: resolved.limit.context, output: resolved.limit.output },
|
limits: { context: resolved.limit.context, output: resolved.limit.output },
|
||||||
}
|
}
|
||||||
return yield* Effect.try({
|
return yield* Effect.try({
|
||||||
try: () => ProviderV2.makeModel(module, resolved.modelID ?? resolved.id, settings),
|
try: () => Model.update(module.model(resolved.modelID ?? resolved.id, settings), { provider: resolved.providerID }),
|
||||||
catch: () =>
|
catch: () => unsupported(resolved, specifier),
|
||||||
new UnsupportedPackageError({
|
|
||||||
providerID: resolved.providerID,
|
|
||||||
modelID: resolved.id,
|
|
||||||
package: specifier,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return Effect.fail(
|
return Effect.fail(unsupported(resolved))
|
||||||
new UnsupportedPackageError({
|
|
||||||
providerID: resolved.providerID,
|
|
||||||
modelID: resolved.id,
|
|
||||||
package: resolved.package ?? "unknown",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const resolve = (
|
export const resolve = (
|
||||||
session: SessionSchema.Info,
|
session: SessionSchema.Info,
|
||||||
model: ModelV2.Info,
|
model: ModelV2.Info,
|
||||||
credential?: Credential.Value,
|
credential?: Credential.Value,
|
||||||
loadPackage?: (specifier: string) => Effect.Effect<ProviderV2.ProviderPackage, ProviderV2.LoadError>,
|
dependencies?: Dependencies,
|
||||||
) =>
|
) =>
|
||||||
withVariant(model, session.model?.variant).pipe(
|
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) =>
|
export const supported = (model: ModelV2.Info) => Boolean(model.package)
|
||||||
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"))
|
|
||||||
|
|
||||||
/** Resolves models from the catalog belonging to the current Location runtime. */
|
/** Resolves models from the catalog belonging to the current Location runtime. */
|
||||||
export const locationLayer = Layer.effect(
|
export const locationLayer = Layer.effect(
|
||||||
|
|
@ -233,6 +183,7 @@ export const locationLayer = Layer.effect(
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
const integrations = yield* Integration.Service
|
const integrations = yield* Integration.Service
|
||||||
const npm = yield* Npm.Service
|
const npm = yield* Npm.Service
|
||||||
|
const aisdk = yield* AISDK.Service
|
||||||
return Service.of({
|
return Service.of({
|
||||||
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
|
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
|
||||||
// Location plugins populate and filter the catalog asynchronously during layer startup.
|
// Location plugins populate and filter the catalog asynchronously during layer startup.
|
||||||
|
|
@ -258,7 +209,10 @@ export const locationLayer = Layer.effect(
|
||||||
session,
|
session,
|
||||||
selected,
|
selected,
|
||||||
connection ? yield* integrations.connection.resolve(connection) : undefined,
|
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) {
|
function migrateProvider(info: ConfigProviderV1.Info) {
|
||||||
const lowerer = ConfigProviderOptionsV1.get(info.npm)
|
const options = ConfigProviderOptionsV1.provider(info.options ?? {})
|
||||||
const options = lowerer.provider(info.options ?? {})
|
|
||||||
return {
|
return {
|
||||||
name: info.name,
|
name: info.name,
|
||||||
env: info.env,
|
env: info.env,
|
||||||
|
|
@ -180,14 +179,12 @@ function migrateProvider(info: ConfigProviderV1.Info) {
|
||||||
body: info.options && options.body,
|
body: info.options && options.body,
|
||||||
models:
|
models:
|
||||||
info.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) {
|
function migrateModel(info: typeof ConfigProviderV1.Model.Type) {
|
||||||
const packageID = info.provider?.npm ?? packageName
|
const settings = info.options && ConfigProviderOptionsV1.model(info.options)
|
||||||
const lowerer = ConfigProviderOptionsV1.get(packageID)
|
|
||||||
const settings = info.options && lowerer.model(info.options)
|
|
||||||
const costs = info.cost && [
|
const costs = info.cost && [
|
||||||
{
|
{
|
||||||
input: info.cost.input,
|
input: info.cost.input,
|
||||||
|
|
@ -221,7 +218,7 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: st
|
||||||
info.variants &&
|
info.variants &&
|
||||||
Object.entries(info.variants).map(([id, options]) => ({
|
Object.entries(info.variants).map(([id, options]) => ({
|
||||||
id,
|
id,
|
||||||
settings: lowerer.model(options),
|
settings: ConfigProviderOptionsV1.model(options),
|
||||||
})),
|
})),
|
||||||
cost: costs,
|
cost: costs,
|
||||||
disabled: info.status === "deprecated" ? true : undefined,
|
disabled: info.status === "deprecated" ? true : undefined,
|
||||||
|
|
|
||||||
|
|
@ -8,27 +8,23 @@ export interface ProviderResult {
|
||||||
readonly body?: Record<string, unknown>
|
readonly body?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Lowerer {
|
export function provider(options: Options): ProviderResult {
|
||||||
readonly provider: (options: Options) => ProviderResult
|
const headers = options.headers
|
||||||
readonly model: (options: Options) => Record<string, unknown>
|
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 = {
|
export function model(options: Options) {
|
||||||
provider(options) {
|
return { ...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])))
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,8 @@ import { ConfigProviderOptionsV1 } from "@opencode-ai/core/v1/config/provider-op
|
||||||
|
|
||||||
describe("ConfigProviderOptionsV1", () => {
|
describe("ConfigProviderOptionsV1", () => {
|
||||||
test("splits provider overlays without changing package settings", () => {
|
test("splits provider overlays without changing package settings", () => {
|
||||||
const lowerer = ConfigProviderOptionsV1.get("@ai-sdk/openai")
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
lowerer.provider({
|
ConfigProviderOptionsV1.provider({
|
||||||
apiKey: "secret",
|
apiKey: "secret",
|
||||||
baseURL: "https://openai.example/v1",
|
baseURL: "https://openai.example/v1",
|
||||||
organization: "org",
|
organization: "org",
|
||||||
|
|
@ -27,10 +25,8 @@ describe("ConfigProviderOptionsV1", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps model and variant options unchanged", () => {
|
test("keeps model and variant options unchanged", () => {
|
||||||
const lowerer = ConfigProviderOptionsV1.get("@ai-sdk/anthropic")
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
lowerer.model({
|
ConfigProviderOptionsV1.model({
|
||||||
reasoningEffort: "high",
|
reasoningEffort: "high",
|
||||||
taskBudget: 1024,
|
taskBudget: 1024,
|
||||||
metadata: { userId: "user" },
|
metadata: { userId: "user" },
|
||||||
|
|
@ -42,8 +38,8 @@ describe("ConfigProviderOptionsV1", () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("uses the same mechanical lowering for every package", () => {
|
test("uses mechanical lowering for custom provider options", () => {
|
||||||
expect(ConfigProviderOptionsV1.get("custom-provider").provider({ enabled: true })).toEqual({
|
expect(ConfigProviderOptionsV1.provider({ enabled: true })).toEqual({
|
||||||
settings: { enabled: true },
|
settings: { enabled: true },
|
||||||
headers: undefined,
|
headers: undefined,
|
||||||
body: undefined,
|
body: undefined,
|
||||||
|
|
|
||||||
|
|
@ -80,4 +80,24 @@ describe("GooglePlugin", () => {
|
||||||
expect(language.provider).toBe("custom-google")
|
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 { describe, expect } from "bun:test"
|
||||||
import { LLM } from "@opencode-ai/llm"
|
import { LLM } from "@opencode-ai/llm"
|
||||||
|
import { OpenAI } from "@opencode-ai/llm/providers"
|
||||||
import { LLMClient } from "@opencode-ai/llm/route"
|
import { LLMClient } from "@opencode-ai/llm/route"
|
||||||
import { DateTime, Effect } from "effect"
|
import { DateTime, Effect } from "effect"
|
||||||
import { Headers } from "effect/unstable/http"
|
import { Headers } from "effect/unstable/http"
|
||||||
|
|
@ -36,6 +37,18 @@ const model = (transport: Transport, variants?: NonNullable<ModelV2.Info["varian
|
||||||
limit: { context: 100, output: 20 },
|
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", () => {
|
describe("SessionRunnerModel", () => {
|
||||||
it.effect("constructs native provider package models mechanically", () =>
|
it.effect("constructs native provider package models mechanically", () =>
|
||||||
Effect.gen(function* () {
|
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* () {
|
Effect.gen(function* () {
|
||||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
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" })
|
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", () =>
|
it.effect("keeps catalog apiKey credentials out of provider JSON", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
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" }))
|
const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" }))
|
||||||
|
|
||||||
|
|
@ -99,7 +112,7 @@ describe("SessionRunnerModel", () => {
|
||||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||||
ModelV2.Info.make({
|
ModelV2.Info.make({
|
||||||
...model({
|
...model({
|
||||||
package: "aisdk:@ai-sdk/openai-compatible",
|
package: "@opencode-ai/llm/providers/openai-compatible",
|
||||||
settings: {
|
settings: {
|
||||||
apiKey: "settings-secret",
|
apiKey: "settings-secret",
|
||||||
baseURL: "https://compatible.example/v1",
|
baseURL: "https://compatible.example/v1",
|
||||||
|
|
@ -126,7 +139,7 @@ describe("SessionRunnerModel", () => {
|
||||||
|
|
||||||
it.effect("overlays selected OpenAI Session variant bodies", () =>
|
it.effect("overlays selected OpenAI Session variant bodies", () =>
|
||||||
Effect.gen(function* () {
|
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"),
|
id: ModelV2.VariantID.make("high"),
|
||||||
headers: { "x-variant": "high" },
|
headers: { "x-variant": "high" },
|
||||||
|
|
@ -170,7 +183,7 @@ describe("SessionRunnerModel", () => {
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const catalog = model(
|
const catalog = model(
|
||||||
{
|
{
|
||||||
package: "aisdk:@ai-sdk/openai-compatible",
|
package: "@opencode-ai/llm/providers/openai-compatible",
|
||||||
settings: { baseURL: "https://compatible.example/v1" },
|
settings: { baseURL: "https://compatible.example/v1" },
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
|
@ -204,7 +217,7 @@ describe("SessionRunnerModel", () => {
|
||||||
|
|
||||||
it.effect("rejects an explicit unavailable Session variant during model resolution", () =>
|
it.effect("rejects an explicit unavailable Session variant during model resolution", () =>
|
||||||
Effect.gen(function* () {
|
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({
|
const session = SessionV2.Info.make({
|
||||||
id: SessionV2.ID.make("ses_model_variant_unavailable"),
|
id: SessionV2.ID.make("ses_model_variant_unavailable"),
|
||||||
projectID: ProjectV2.ID.global,
|
projectID: ProjectV2.ID.global,
|
||||||
|
|
@ -235,7 +248,7 @@ describe("SessionRunnerModel", () => {
|
||||||
it.effect("overlays selected Anthropic Session variant bodies", () =>
|
it.effect("overlays selected Anthropic Session variant bodies", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const catalog = model(
|
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"),
|
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* () {
|
Effect.gen(function* () {
|
||||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
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({
|
expect(resolved.route).toMatchObject({
|
||||||
|
|
@ -281,7 +294,7 @@ describe("SessionRunnerModel", () => {
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||||
ModelV2.Info.make({
|
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: {},
|
headers: {},
|
||||||
body: {},
|
body: {},
|
||||||
}),
|
}),
|
||||||
|
|
@ -305,7 +318,7 @@ describe("SessionRunnerModel", () => {
|
||||||
const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } })
|
const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } })
|
||||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||||
ModelV2.Info.make({
|
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" },
|
settings: { apiKey: "configured-secret", baseURL: "https://openai.example/v1" },
|
||||||
headers: {},
|
headers: {},
|
||||||
body: {},
|
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* () {
|
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" } }),
|
model({ package: "aisdk:@ai-sdk/google", settings: { baseURL: "https://google.example/v1" } }),
|
||||||
).pipe(Effect.flip)
|
undefined,
|
||||||
|
aisdkDependencies,
|
||||||
|
)
|
||||||
|
|
||||||
expect(failure).toMatchObject({
|
expect(resolved.route.id).toBe("openai-responses")
|
||||||
_tag: "SessionRunnerModel.UnsupportedPackageError",
|
expect(resolved.route.endpoint).toMatchObject({ baseURL: "https://google.example/v1" })
|
||||||
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")
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
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(() => {
|
Effect.sync(() => {
|
||||||
expect(
|
|
||||||
SessionRunnerModel.supported(
|
|
||||||
model({ package: "aisdk:@ai-sdk/openai", settings: { baseURL: "https://openai.example/v1" } }),
|
|
||||||
),
|
|
||||||
).toBe(true)
|
|
||||||
expect(
|
expect(
|
||||||
SessionRunnerModel.supported(
|
SessionRunnerModel.supported(
|
||||||
model({ package: "aisdk:@ai-sdk/google", settings: { baseURL: "https://google.example/v1" } }),
|
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)
|
expect(SessionRunnerModel.supported(model({ package: "native-provider-package" }))).toBe(true)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue