feat(core): flatten provider config and load native packages (#35563)
Co-authored-by: Dax Raad <d@ironbay.co>
This commit is contained in:
parent
d603eed5a1
commit
e57d9ca390
107 changed files with 2593 additions and 1984 deletions
|
|
@ -1,13 +1,43 @@
|
|||
export * as AISDK from "./aisdk"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
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, Option, 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
|
||||
|
|
@ -74,10 +104,10 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
|
|||
function prepareOptions(model: ModelV2.Info, pkg: string) {
|
||||
const options: Record<string, any> = {
|
||||
name: model.providerID,
|
||||
...(model.api.type === "aisdk" ? (model.api.settings ?? {}) : {}),
|
||||
...model.request.body,
|
||||
...(model.settings ?? {}),
|
||||
headers: model.headers,
|
||||
body: model.body,
|
||||
}
|
||||
if (model.api.type === "aisdk" && model.api.url) options.baseURL = model.api.url
|
||||
|
||||
const customFetch = options.fetch
|
||||
const chunkTimeout = options.chunkTimeout
|
||||
|
|
@ -110,6 +140,13 @@ function prepareOptions(model: ModelV2.Info, pkg: string) {
|
|||
}
|
||||
}
|
||||
|
||||
if (typeof opts.body === "string" && model.body !== undefined) {
|
||||
const decoded = Option.getOrUndefined(Schema.decodeUnknownOption(Schema.UnknownFromJsonString)(opts.body))
|
||||
if (Schema.is(Schema.Record(Schema.String, Schema.Json))(decoded)) {
|
||||
opts.body = JSON.stringify(ProviderV2.mergeOverlay(decoded, model.body))
|
||||
}
|
||||
}
|
||||
|
||||
const res = await (typeof customFetch === "function" ? customFetch : fetch)(input, {
|
||||
...opts,
|
||||
timeout: false,
|
||||
|
|
@ -142,17 +179,29 @@ 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") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
export const locationLayer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
let sdkHooks: ((event: SDKEvent) => Effect.Effect<void> | void)[] = []
|
||||
let languageHooks: ((event: LanguageEvent) => Effect.Effect<void> | void)[] = []
|
||||
const languages = new Map<string, LanguageModelV3>()
|
||||
const sdks = new Map<string, SDK>()
|
||||
const functionIDs = new WeakMap<object, number>()
|
||||
let nextFunctionID = 0
|
||||
const cacheKey = (input: unknown) =>
|
||||
JSON.stringify(input, (_key, value: unknown) => {
|
||||
if (typeof value !== "function") return value
|
||||
const existing = functionIDs.get(value)
|
||||
if (existing !== undefined) return `function:${existing}`
|
||||
const id = nextFunctionID++
|
||||
functionIDs.set(value, id)
|
||||
return `function:${id}`
|
||||
}) ?? ""
|
||||
|
||||
const register = <Event>(
|
||||
hooks: () => ((event: Event) => Effect.Effect<void> | void)[],
|
||||
|
|
@ -196,24 +245,36 @@ const layer = Layer.effect(
|
|||
runSDK: (event) => run(sdkHooks, event),
|
||||
runLanguage: (event) => run(languageHooks, event),
|
||||
language: Effect.fn("AISDK.language")(function* (model) {
|
||||
const key = `${model.providerID}/${model.id}/${model.request.variant ?? "default"}`
|
||||
const key = cacheKey({
|
||||
providerID: model.providerID,
|
||||
id: model.id,
|
||||
modelID: model.modelID,
|
||||
package: model.package,
|
||||
settings: model.settings,
|
||||
headers: model.headers,
|
||||
body: model.body,
|
||||
limit: model.limit,
|
||||
})
|
||||
const existing = languages.get(key)
|
||||
if (existing) return existing
|
||||
if (model.api.type !== "aisdk")
|
||||
if (!ProviderV2.isAISDK(model.package))
|
||||
return yield* new InitError({
|
||||
providerID: model.providerID,
|
||||
cause: new Error(`Unsupported api ${model.api.type}`),
|
||||
cause: new Error(`Unsupported package ${model.package}`),
|
||||
})
|
||||
|
||||
const options = prepareOptions(model, model.api.package)
|
||||
const sdkKey = JSON.stringify({
|
||||
const packageName = ProviderV2.packageName(model.package) ?? ""
|
||||
const options = prepareOptions(model, packageName)
|
||||
const sdkKey = cacheKey({
|
||||
providerID: model.providerID,
|
||||
api: model.api,
|
||||
options,
|
||||
package: packageName,
|
||||
settings: model.settings,
|
||||
headers: model.headers,
|
||||
body: model.body,
|
||||
})
|
||||
const sdk =
|
||||
sdks.get(sdkKey) ??
|
||||
(yield* service.runSDK({ model, package: model.api.package, options }).pipe(initError(model.providerID))).sdk
|
||||
(yield* service.runSDK({ model, package: packageName, options }).pipe(initError(model.providerID))).sdk
|
||||
if (!sdk)
|
||||
return yield* new InitError({
|
||||
providerID: model.providerID,
|
||||
|
|
@ -221,15 +282,396 @@ const layer = Layer.effect(
|
|||
})
|
||||
sdks.set(sdkKey, sdk)
|
||||
const result = yield* service.runLanguage({ model, sdk, options }).pipe(initError(model.providerID))
|
||||
const language = yield* Effect.sync(() => result.language ?? sdk.languageModel(model.api.id)).pipe(
|
||||
const language = yield* Effect.sync(() => result.language ?? sdk.languageModel(model.modelID ?? model.id)).pipe(
|
||||
initError(model.providerID),
|
||||
)
|
||||
languages.set(key, language)
|
||||
return language
|
||||
}),
|
||||
model: Effect.fn("AISDK.model")(function* (model) {
|
||||
return modelFromLanguage(model, yield* service.language(model))
|
||||
}),
|
||||
})
|
||||
return service
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
export const defaultLayer = locationLayer
|
||||
|
||||
function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
|
||||
const settings = requestSettings(info.settings)
|
||||
const optionKey = providerOptionKey(ProviderV2.packageName(info.package), info.providerID)
|
||||
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 && info.headers === undefined
|
||||
? undefined
|
||||
: { body: info.body === undefined ? undefined : { ...info.body }, headers: info.headers },
|
||||
limits: { context: info.limit.context, output: info.limit.output },
|
||||
providerOptions: settings === undefined ? undefined : { [optionKey]: settings },
|
||||
},
|
||||
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 providerOptionKey(packageName: string | undefined, providerID: ProviderV2.ID) {
|
||||
if (packageName === "@ai-sdk/google") return "google"
|
||||
if (packageName === "@ai-sdk/google-vertex") return "vertex"
|
||||
if (packageName === "@ai-sdk/google-vertex/anthropic") return "anthropic"
|
||||
if (packageName === "@ai-sdk/amazon-bedrock" || packageName === "@ai-sdk/amazon-bedrock/mantle") return "bedrock"
|
||||
if (packageName === "@ai-sdk/azure") return "azure"
|
||||
if (packageName === "@openrouter/ai-sdk-provider") return "openrouter"
|
||||
if (packageName?.startsWith("@ai-sdk/")) return packageName.slice("@ai-sdk/".length)
|
||||
return providerID
|
||||
}
|
||||
|
||||
function requestSettings(settings: Readonly<Record<string, unknown>> | undefined) {
|
||||
if (settings === undefined) return undefined
|
||||
const result = Object.fromEntries(
|
||||
Object.entries(settings).filter(
|
||||
([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "timeout"].includes(key),
|
||||
),
|
||||
)
|
||||
return Object.keys(result).length === 0 ? undefined : result
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
|
||||
|
|
|
|||
|
|
@ -66,39 +66,21 @@ const layer = Layer.effect(
|
|||
|
||||
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => {
|
||||
if (provider.disabled) return false
|
||||
if (typeof provider.request.body.apiKey === "string") return true
|
||||
if (typeof provider.settings?.apiKey === "string") return true
|
||||
if (integration?.connections.length) return true
|
||||
return provider.integrationID === undefined && !integration
|
||||
}
|
||||
|
||||
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
|
||||
const api =
|
||||
model.api.type === "native" && !model.api.url && Object.keys(model.api.settings).length === 0
|
||||
? { ...provider.api, id: model.api.id }
|
||||
: model.api.type === "aisdk" && provider.api.type === "aisdk" && !model.api.url
|
||||
? { ...model.api, url: provider.api.url, settings: { ...provider.api.settings, ...model.api.settings } }
|
||||
: model.api.type === "aisdk" && provider.api.type === "aisdk"
|
||||
? { ...model.api, settings: { ...provider.api.settings, ...model.api.settings } }
|
||||
: model.api
|
||||
const request = {
|
||||
settings: { ...provider.request.settings, ...model.request.settings },
|
||||
headers: { ...provider.request.headers, ...model.request.headers },
|
||||
body: { ...provider.request.body, ...model.request.body },
|
||||
variant: model.request.variant,
|
||||
}
|
||||
return ModelV2.Info.make({
|
||||
...model,
|
||||
api,
|
||||
request,
|
||||
package: model.package ?? provider.package,
|
||||
settings: ProviderV2.mergeOverlay(provider.settings, model.settings),
|
||||
headers: ProviderV2.mergeHeaders(provider.headers, model.headers),
|
||||
body: ProviderV2.mergeOverlay(provider.body, model.body),
|
||||
})
|
||||
}
|
||||
|
||||
const normalizeApi = (item: ProviderV2.MutableInfo | ModelV2.MutableInfo) => {
|
||||
if (typeof item.request.body.baseURL !== "string") return
|
||||
item.api.url = item.request.body.baseURL
|
||||
delete item.request.body.baseURL
|
||||
}
|
||||
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ providers: new Map() }),
|
||||
draft: (draft) => {
|
||||
|
|
@ -116,7 +98,6 @@ const layer = Layer.effect(
|
|||
draft.providers.set(providerID, current)
|
||||
}
|
||||
fn(current.provider)
|
||||
normalizeApi(current.provider)
|
||||
},
|
||||
remove: (providerID) => {
|
||||
draft.providers.delete(providerID)
|
||||
|
|
@ -139,7 +120,6 @@ const layer = Layer.effect(
|
|||
fn(model)
|
||||
model.id = modelID
|
||||
model.providerID = providerID
|
||||
normalizeApi(model)
|
||||
},
|
||||
remove: (providerID, modelID) => {
|
||||
draft.providers.get(providerID)?.models.delete(modelID)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { ConfigCommand } from "./config/command"
|
|||
import { ConfigFormatter } from "./config/formatter"
|
||||
import { ConfigLSP } from "./config/lsp"
|
||||
import { ConfigMCP } from "./config/mcp"
|
||||
import { ConfigModel } from "./config/model"
|
||||
import { ConfigPlugin } from "./config/plugin"
|
||||
import { ConfigProvider } from "./config/provider"
|
||||
import { ConfigReference } from "./config/reference"
|
||||
|
|
@ -35,7 +36,7 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
|||
shell: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Default shell to use for terminal and shell tool execution",
|
||||
}),
|
||||
model: Schema.String.pipe(Schema.optional).annotate({
|
||||
model: ConfigModel.Selection.pipe(Schema.optional).annotate({
|
||||
description: "Default model to use when no session or agent model is selected",
|
||||
}),
|
||||
default_agent: Schema.String.pipe(Schema.optional).annotate({
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export * as ConfigAgent from "./agent"
|
|||
import { Schema } from "effect"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { ConfigProvider } from "./provider"
|
||||
import { ConfigModel } from "./model"
|
||||
import { PositiveInt } from "../schema"
|
||||
|
||||
export const Color = Schema.Union([
|
||||
|
|
@ -11,8 +12,7 @@ export const Color = Schema.Union([
|
|||
])
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigV2.Agent")({
|
||||
model: Schema.String.pipe(Schema.optional),
|
||||
variant: Schema.String.pipe(Schema.optional),
|
||||
model: ConfigModel.Selection.pipe(Schema.optional),
|
||||
request: ConfigProvider.Request.pipe(Schema.optional),
|
||||
system: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
export * as ConfigCommand from "./command"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { ConfigModel } from "./model"
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigV2.Command")({
|
||||
template: Schema.String,
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
agent: Schema.String.pipe(Schema.optional),
|
||||
model: Schema.String.pipe(Schema.optional),
|
||||
variant: Schema.String.pipe(Schema.optional),
|
||||
model: ConfigModel.Selection.pipe(Schema.optional),
|
||||
subtask: Schema.Boolean.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
|
|
|||
38
packages/core/src/config/model.ts
Normal file
38
packages/core/src/config/model.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
export * as ConfigModel from "./model"
|
||||
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
|
||||
const ProviderID = Provider.ID.check(Schema.isPattern(/^[^/#]+$/))
|
||||
const ModelID = Model.ID.check(Schema.isPattern(/^[^#]+$/))
|
||||
const VariantID = Model.VariantID.check(Schema.isPattern(/^[^#]+$/))
|
||||
|
||||
const Explicit = Schema.Struct({
|
||||
providerID: ProviderID,
|
||||
model: ModelID,
|
||||
variant: VariantID.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const Short = Schema.String.check(Schema.isPattern(/^[^/#]+\/[^#]+(?:#[^#]+)?$/))
|
||||
|
||||
export interface Selection extends Schema.Schema.Type<typeof Explicit> {}
|
||||
export const Selection = Schema.Union([Short, Explicit])
|
||||
.pipe(
|
||||
Schema.decodeTo(Explicit, {
|
||||
decode: SchemaGetter.transform((input) => (typeof input === "string" ? parse(input) : input)),
|
||||
encode: SchemaGetter.passthrough({ strict: false }),
|
||||
}),
|
||||
)
|
||||
.annotate({ identifier: "Config.ModelSelection" })
|
||||
|
||||
function parse(input: string): Selection {
|
||||
const providerEnd = input.indexOf("/")
|
||||
const variantStart = input.lastIndexOf("#")
|
||||
const hasVariant = variantStart > providerEnd
|
||||
return {
|
||||
providerID: Provider.ID.make(input.slice(0, providerEnd)),
|
||||
model: Model.ID.make(input.slice(providerEnd + 1, hasVariant ? variantStart : undefined)),
|
||||
...(hasVariant ? { variant: Model.VariantID.make(input.slice(variantStart + 1)) } : {}),
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,6 @@ import { Config } from "../../config"
|
|||
import { ConfigAgent } from "../agent"
|
||||
import { ConfigMarkdown } from "../markdown"
|
||||
import { FSUtil } from "../../fs-util"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { ConfigAgentV1 } from "../../v1/config/agent"
|
||||
import { ConfigMigrateV1 } from "../../v1/config/migrate"
|
||||
|
||||
|
|
@ -76,13 +75,12 @@ export const Plugin = define({
|
|||
const exists = draft.get(agentID) !== undefined
|
||||
draft.update(agentID, (agent) => {
|
||||
if (!exists) agent.permissions.push(...global)
|
||||
if (item.model !== undefined) {
|
||||
const model = ModelV2.parse(item.model)
|
||||
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
|
||||
}
|
||||
if (item.variant !== undefined && agent.model !== undefined) {
|
||||
agent.model.variant = ModelV2.VariantID.make(item.variant)
|
||||
}
|
||||
if (item.model !== undefined)
|
||||
agent.model = {
|
||||
id: item.model.model,
|
||||
providerID: item.model.providerID,
|
||||
...(item.model.variant === undefined ? {} : { variant: item.model.variant }),
|
||||
}
|
||||
if (item.request !== undefined) {
|
||||
Object.assign(agent.request.headers, item.request.headers ?? {})
|
||||
Object.assign(agent.request.body, item.request.body ?? {})
|
||||
|
|
@ -134,14 +132,16 @@ function decode(file: { directory: string; filepath: string; primary: boolean },
|
|||
.replace(/\.md$/, "")
|
||||
const body = markdown.content.trim()
|
||||
const legacy = Object.keys(markdown.data).some((key) => !agentKeys.has(key))
|
||||
const agent = Option.getOrUndefined(
|
||||
legacy
|
||||
? Option.map(
|
||||
const agent = legacy
|
||||
? Option.getOrUndefined(
|
||||
Option.map(
|
||||
decodeLegacyAgent({ name, ...markdown.data, prompt: body }, { errors: "all", propertyOrder: "original" }),
|
||||
ConfigMigrateV1.migrateAgent,
|
||||
)
|
||||
: decodeAgent({ ...markdown.data, system: body }, { errors: "all", propertyOrder: "original" }),
|
||||
)
|
||||
),
|
||||
)
|
||||
: Option.getOrUndefined(
|
||||
decodeAgent({ ...markdown.data, system: body }, { errors: "all", propertyOrder: "original" }),
|
||||
)
|
||||
if (!agent) return
|
||||
const info = Option.getOrUndefined(
|
||||
decodeConfig({
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { Effect, Option, Schema, Stream } from "effect"
|
|||
import { CommandV2 } from "../../command"
|
||||
import { Config } from "../../config"
|
||||
import { FSUtil } from "../../fs-util"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { ConfigCommand } from "../command"
|
||||
import { ConfigMarkdown } from "../markdown"
|
||||
|
||||
|
|
@ -35,13 +34,12 @@ export const Plugin = define({
|
|||
item.template = command.template
|
||||
if (command.description !== undefined) item.description = command.description
|
||||
if (command.agent !== undefined) item.agent = command.agent
|
||||
if (command.model !== undefined) {
|
||||
const model = ModelV2.parse(command.model)
|
||||
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
|
||||
}
|
||||
if (command.variant !== undefined && item.model !== undefined) {
|
||||
item.model.variant = ModelV2.VariantID.make(command.variant)
|
||||
}
|
||||
if (command.model !== undefined)
|
||||
item.model = {
|
||||
id: command.model.model,
|
||||
providerID: command.model.providerID,
|
||||
...(command.model.variant === undefined ? {} : { variant: command.model.variant }),
|
||||
}
|
||||
if (command.subtask !== undefined) item.subtask = command.subtask
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
|||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.provider",
|
||||
|
|
@ -39,27 +40,29 @@ export const Plugin = define({
|
|||
yield* ctx.catalog.transform((catalog) => {
|
||||
const files = loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
const configuredDefault = Config.latest(loaded.entries, "model")
|
||||
if (configuredDefault !== undefined) {
|
||||
const model = ModelV2.parse(configuredDefault)
|
||||
catalog.model.default.set(model.providerID, model.modelID)
|
||||
}
|
||||
if (configuredDefault !== undefined)
|
||||
catalog.model.default.set(configuredDefault.providerID, configuredDefault.model)
|
||||
for (const file of files) {
|
||||
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
|
||||
const providerID = id
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.api !== undefined) provider.api = { ...item.api }
|
||||
if (item.request !== undefined) {
|
||||
Object.assign(provider.request.settings, item.request.settings)
|
||||
Object.assign(provider.request.headers, item.request.headers)
|
||||
Object.assign(provider.request.body, item.request.body)
|
||||
}
|
||||
if (item.package !== undefined) provider.package = item.package
|
||||
if (item.settings !== undefined)
|
||||
provider.settings = ProviderV2.mergeOverlay(provider.settings, item.settings)
|
||||
if (item.headers !== undefined) provider.headers = ProviderV2.mergeHeaders(provider.headers, item.headers)
|
||||
if (item.body !== undefined) provider.body = ProviderV2.mergeOverlay(provider.body, item.body)
|
||||
})
|
||||
for (const [id, config] of Object.entries(item.models ?? {})) {
|
||||
catalog.model.update(providerID, id, (model) => {
|
||||
if (config.family !== undefined) model.family = config.family
|
||||
if (config.name !== undefined) model.name = config.name
|
||||
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
|
||||
if (config.modelID !== undefined) model.modelID = config.modelID
|
||||
if (config.package !== undefined) model.package = config.package
|
||||
if (config.settings !== undefined)
|
||||
model.settings = ProviderV2.mergeOverlay(model.settings, config.settings)
|
||||
if (config.headers !== undefined) model.headers = ProviderV2.mergeHeaders(model.headers, config.headers)
|
||||
if (config.body !== undefined) model.body = ProviderV2.mergeOverlay(model.body, config.body)
|
||||
if (config.capabilities !== undefined) {
|
||||
model.capabilities = {
|
||||
tools: config.capabilities.tools,
|
||||
|
|
@ -67,27 +70,19 @@ export const Plugin = define({
|
|||
output: [...config.capabilities.output],
|
||||
}
|
||||
}
|
||||
if (config.request !== undefined) {
|
||||
Object.assign(model.request.settings, config.request.settings)
|
||||
Object.assign(model.request.headers, config.request.headers)
|
||||
Object.assign(model.request.body, config.request.body)
|
||||
if (config.request.variant !== undefined) model.request.variant = config.request.variant
|
||||
}
|
||||
if (config.variants !== undefined) {
|
||||
model.variants ??= []
|
||||
for (const variant of config.variants) {
|
||||
let existing = model.variants.find((item) => item.id === variant.id)
|
||||
if (!existing) {
|
||||
existing = {
|
||||
id: variant.id,
|
||||
settings: {},
|
||||
headers: {},
|
||||
body: {},
|
||||
}
|
||||
existing = { id: variant.id }
|
||||
model.variants.push(existing)
|
||||
}
|
||||
Object.assign(existing.settings, variant.settings)
|
||||
Object.assign(existing.headers, variant.headers)
|
||||
Object.assign(existing.body, variant.body)
|
||||
if (variant.settings !== undefined)
|
||||
existing.settings = ProviderV2.mergeOverlay(existing.settings, variant.settings)
|
||||
if (variant.headers !== undefined)
|
||||
existing.headers = ProviderV2.mergeHeaders(existing.headers, variant.headers)
|
||||
if (variant.body !== undefined) existing.body = ProviderV2.mergeOverlay(existing.body, variant.body)
|
||||
}
|
||||
}
|
||||
if (config.cost !== undefined) {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
export * as ConfigProvider from "./provider"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { ProviderV2 } from "../provider"
|
||||
import { ModelV2 } from "../model"
|
||||
|
||||
export class Request extends Schema.Class<Request>("ConfigV2.Provider.Request")({
|
||||
settings: ProviderV2.Settings.pipe(Schema.optional),
|
||||
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
|
||||
|
||||
export const Overlays = {
|
||||
settings: JsonRecord.pipe(Schema.optional),
|
||||
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||
body: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
body: JsonRecord.pipe(Schema.optional),
|
||||
}
|
||||
|
||||
export class Request extends Schema.Class<Request>("ConfigV2.Provider.Request")({
|
||||
headers: Overlays.headers,
|
||||
body: Overlays.body,
|
||||
}) {}
|
||||
|
||||
class Cache extends Schema.Class<Cache>("ConfigV2.Model.Cost.Cache")({
|
||||
|
|
@ -31,32 +37,16 @@ class Limit extends Schema.Class<Limit>("ConfigV2.Model.Limit")({
|
|||
output: Schema.Int.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
const ModelApi = Schema.Union([
|
||||
Schema.Struct({
|
||||
id: ModelV2.ID.pipe(Schema.optional),
|
||||
...ProviderV2.AISDK.fields,
|
||||
}),
|
||||
Schema.Struct({
|
||||
id: ModelV2.ID.pipe(Schema.optional),
|
||||
...ProviderV2.Native.fields,
|
||||
}),
|
||||
Schema.Struct({
|
||||
id: ModelV2.ID,
|
||||
}),
|
||||
])
|
||||
|
||||
class Model extends Schema.Class<Model>("ConfigV2.Model")({
|
||||
modelID: ModelV2.ID.pipe(Schema.optional),
|
||||
family: ModelV2.Family.pipe(Schema.optional),
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
api: ModelApi.pipe(Schema.optional),
|
||||
package: Schema.String.pipe(Schema.optional),
|
||||
...Overlays,
|
||||
capabilities: ModelV2.Capabilities.pipe(Schema.optional),
|
||||
request: Schema.Struct({
|
||||
...Request.fields,
|
||||
variant: Schema.String.pipe(Schema.optional),
|
||||
}).pipe(Schema.optional),
|
||||
variants: Schema.Struct({
|
||||
id: ModelV2.VariantID,
|
||||
...Request.fields,
|
||||
...Overlays,
|
||||
}).pipe(Schema.Array, Schema.optional),
|
||||
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(Schema.optional),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
|
|
@ -66,7 +56,7 @@ class Model extends Schema.Class<Model>("ConfigV2.Model")({
|
|||
export class Info extends Schema.Class<Info>("ConfigV2.Provider")({
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
env: Schema.String.pipe(Schema.Array, Schema.optional),
|
||||
api: ProviderV2.Api.pipe(Schema.optional),
|
||||
request: Request.pipe(Schema.optional),
|
||||
package: Schema.String.pipe(Schema.optional),
|
||||
...Overlays,
|
||||
models: Schema.Record(Schema.String, Model).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Types } from "effect"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { ProviderV2 } from "./provider"
|
||||
import type { DeepMutable } from "./schema"
|
||||
|
||||
export const ID = Model.ID
|
||||
export type ID = typeof ID.Type
|
||||
|
|
@ -20,20 +20,10 @@ export const Cost = Model.Cost
|
|||
export const Ref = Model.Ref
|
||||
export type Ref = typeof Ref.Type
|
||||
|
||||
export const Api = Model.Api
|
||||
export type Api = Model.Api
|
||||
|
||||
export const Info = Model.Info
|
||||
export type Info = Model.Info
|
||||
|
||||
export type MutableRequest = ProviderV2.MutableRequest & { variant?: string }
|
||||
export type MutableVariant = ProviderV2.MutableRequest & { id: VariantID }
|
||||
|
||||
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api" | "request" | "variants"> & {
|
||||
api: ProviderV2.MutableApi<Api>
|
||||
request: MutableRequest
|
||||
variants: MutableVariant[]
|
||||
}
|
||||
export type MutableInfo = DeepMutable<Info>
|
||||
|
||||
export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } {
|
||||
const [providerID, ...modelID] = input.split("/")
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"]
|
|||
|
||||
const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"]
|
||||
|
||||
function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): ModelV2Info["variants"] {
|
||||
function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): NonNullable<ModelV2Info["variants"]> {
|
||||
const npm = model.provider?.npm ?? provider.npm
|
||||
const options = model.reasoning_options ?? []
|
||||
const effort = options.find((option) => option.type === "effort")
|
||||
|
|
@ -82,7 +82,7 @@ function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model)
|
|||
const id = raw === null ? "none" : typeof raw === "string" ? raw : undefined
|
||||
if (id === undefined) return []
|
||||
const settings = settingsForEffort(npm, id)
|
||||
return settings ? [{ id, settings, headers: {}, body: {} }] : []
|
||||
return settings ? [{ id: ModelV2.VariantID.make(id), settings }] : []
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -117,15 +117,18 @@ function settingsForEffort(npm: string | undefined, effort: string): ProviderV2.
|
|||
function budgetVariants(
|
||||
npm: string | undefined,
|
||||
option: Extract<NonNullable<ModelsDev.Model["reasoning_options"]>[number], { type: "budget_tokens" }>,
|
||||
): ModelV2Info["variants"] {
|
||||
): NonNullable<ModelV2Info["variants"]> {
|
||||
const max = option.max
|
||||
const high = option.max === undefined ? Math.max(option.min ?? 0, 16_000) : Math.min(Math.max(option.min ?? 0, 16_000), option.max)
|
||||
const high =
|
||||
option.max === undefined
|
||||
? Math.max(option.min ?? 0, 16_000)
|
||||
: Math.min(Math.max(option.min ?? 0, 16_000), option.max)
|
||||
return [
|
||||
{ id: "high", budget: high },
|
||||
...(max === undefined || max === high ? [] : [{ id: "max", budget: max }]),
|
||||
].flatMap((item) => {
|
||||
const settings = settingsForBudget(npm, item.budget)
|
||||
return settings ? [{ id: item.id, settings, headers: {}, body: {} }] : []
|
||||
return settings ? [{ id: ModelV2.VariantID.make(item.id), settings }] : []
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -143,12 +146,13 @@ function modeName(model: ModelsDev.Model, mode: string) {
|
|||
return `${model.name} ${mode.charAt(0).toUpperCase()}${mode.slice(1)}`
|
||||
}
|
||||
|
||||
function mergeVariants(model: ModelV2Info, next: ModelV2Info["variants"]) {
|
||||
const existing = new Map(model.variants.map((variant) => [variant.id, variant]))
|
||||
function mergeVariants(model: ModelV2Info, next: NonNullable<ModelV2Info["variants"]>) {
|
||||
const variants = model.variants ?? []
|
||||
const existing = new Map(variants.map((variant) => [variant.id, variant]))
|
||||
const nextIDs = new Set(next.map((variant) => variant.id))
|
||||
model.variants = [
|
||||
...next.map((variant) => existing.get(variant.id) ?? variant),
|
||||
...model.variants.filter((variant) => !nextIDs.has(variant.id)),
|
||||
...variants.filter((variant) => !nextIDs.has(variant.id)),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -159,24 +163,14 @@ function applyModel(
|
|||
readonly name?: string
|
||||
readonly cost?: ModelV2Info["cost"]
|
||||
readonly request?: NonNullable<NonNullable<ModelsDev.Model["experimental"]>["modes"]>[string]["provider"]
|
||||
readonly variants?: ModelV2Info["variants"]
|
||||
readonly variants?: NonNullable<ModelV2Info["variants"]>
|
||||
} = {},
|
||||
) {
|
||||
draft.name = input.name ?? model.name
|
||||
draft.modelID = model.id
|
||||
draft.family = model.family ? ModelV2.Family.make(model.family) : undefined
|
||||
draft.api = model.provider?.npm
|
||||
? {
|
||||
id: ModelV2.ID.make(model.id),
|
||||
type: "aisdk",
|
||||
package: model.provider.npm,
|
||||
url: model.provider.api,
|
||||
}
|
||||
: {
|
||||
id: ModelV2.ID.make(model.id),
|
||||
type: "native",
|
||||
url: model.provider?.api,
|
||||
settings: {},
|
||||
}
|
||||
draft.package = model.provider?.npm ? ProviderV2.aisdk(model.provider.npm) : undefined
|
||||
draft.settings = model.provider?.api ? { ...draft.settings, baseURL: model.provider.api } : draft.settings
|
||||
draft.capabilities = {
|
||||
tools: model.tool_call,
|
||||
input: [...(model.modalities?.input ?? [])],
|
||||
|
|
@ -184,7 +178,11 @@ function applyModel(
|
|||
}
|
||||
mergeVariants(draft, input.variants ?? [])
|
||||
draft.time.released = released(model.release_date)
|
||||
draft.cost = input.cost ?? cost(model.cost)
|
||||
draft.cost = (input.cost ?? cost(model.cost)).map((item) => ({
|
||||
...item,
|
||||
tier: item.tier && { ...item.tier },
|
||||
cache: { ...item.cache },
|
||||
}))
|
||||
draft.status = model.status ?? "active"
|
||||
draft.enabled = true
|
||||
draft.limit = {
|
||||
|
|
@ -192,8 +190,8 @@ function applyModel(
|
|||
input: model.limit.input,
|
||||
output: model.limit.output,
|
||||
}
|
||||
Object.assign(draft.request.headers, input.request?.headers ?? {})
|
||||
Object.assign(draft.request.body, input.request?.body ?? {})
|
||||
draft.headers = { ...draft.headers, ...input.request?.headers }
|
||||
draft.body = { ...draft.body, ...input.request?.body }
|
||||
}
|
||||
|
||||
export const ModelsDevPlugin = define({
|
||||
|
|
@ -222,25 +220,18 @@ export const ModelsDevPlugin = define({
|
|||
const providerID = ProviderV2.ID.make(item.id)
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.name = item.name
|
||||
provider.api = item.npm
|
||||
? {
|
||||
type: "aisdk",
|
||||
package: item.npm,
|
||||
url: item.api,
|
||||
}
|
||||
: {
|
||||
type: "native",
|
||||
url: item.api,
|
||||
settings: {},
|
||||
}
|
||||
provider.package = item.npm ? ProviderV2.aisdk(item.npm) : ""
|
||||
provider.settings = item.api ? { ...provider.settings, baseURL: item.api } : provider.settings
|
||||
})
|
||||
|
||||
for (const model of Object.values(item.models)) {
|
||||
const baseCost = cost(model.cost)
|
||||
const variants = reasoningVariants(item, model)
|
||||
catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost, variants }))
|
||||
catalog.model.update(providerID, ModelV2.ID.make(model.id), (draft) =>
|
||||
applyModel(draft, model, { cost: baseCost, variants }),
|
||||
)
|
||||
for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) {
|
||||
catalog.model.update(providerID, `${model.id}-${mode}`, (draft) =>
|
||||
catalog.model.update(providerID, ModelV2.ID.make(`${model.id}-${mode}`), (draft) =>
|
||||
applyModel(draft, model, {
|
||||
name: modeName(model, mode),
|
||||
cost: mergeCost(baseCost, options.cost),
|
||||
|
|
|
|||
|
|
@ -64,15 +64,14 @@ export const AmazonBedrockPlugin = define({
|
|||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/amazon-bedrock") continue
|
||||
if (!ProviderV2.isAISDK(item.provider.package)) continue
|
||||
if (ProviderV2.packageName(item.provider.package) !== "@ai-sdk/amazon-bedrock") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (provider.api.type !== "aisdk") return
|
||||
if (typeof provider.request.body.endpoint !== "string") return
|
||||
if (typeof provider.settings?.endpoint !== "string") return
|
||||
// The AI SDK expects a base URL, but users configure Bedrock private/VPC
|
||||
// endpoints as `endpoint`; move it into the catalog endpoint URL once.
|
||||
provider.api.url = provider.request.body.endpoint
|
||||
delete provider.request.body.endpoint
|
||||
provider.settings.baseURL = provider.settings.endpoint
|
||||
delete provider.settings.endpoint
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -112,12 +111,15 @@ export const AmazonBedrockPlugin = define({
|
|||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return
|
||||
if (evt.model.api.type === "aisdk" && evt.model.api.package === "@ai-sdk/amazon-bedrock/mantle") {
|
||||
evt.language = selectMantleModel(evt.sdk, evt.model.api.id)
|
||||
if (
|
||||
ProviderV2.isAISDK(evt.model.package) &&
|
||||
ProviderV2.packageName(evt.model.package) === "@ai-sdk/amazon-bedrock/mantle"
|
||||
) {
|
||||
evt.language = selectMantleModel(evt.sdk, evt.model.modelID ?? evt.model.id)
|
||||
return
|
||||
}
|
||||
const region = typeof evt.options.region === "string" ? evt.options.region : process.env.AWS_REGION
|
||||
evt.language = evt.sdk.languageModel(resolveModelID(evt.model.api.id, region))
|
||||
evt.language = evt.sdk.languageModel(resolveModelID(evt.model.modelID ?? evt.model.id, region))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const AnthropicPlugin = define({
|
||||
id: "opencode.provider.anthropic",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/anthropic") continue
|
||||
if (!ProviderV2.isAISDK(item.provider.package)) continue
|
||||
if (ProviderV2.packageName(item.provider.package) !== "@ai-sdk/anthropic") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["anthropic-beta"] =
|
||||
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
|
||||
provider.headers = {
|
||||
...provider.headers,
|
||||
"anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -15,14 +15,14 @@ export const AzurePlugin = define({
|
|||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/azure") continue
|
||||
const configured = item.provider.request.body.resourceName
|
||||
if (!ProviderV2.isAISDK(item.provider.package)) continue
|
||||
if (ProviderV2.packageName(item.provider.package) !== "@ai-sdk/azure") continue
|
||||
const configured = item.provider.settings?.resourceName
|
||||
const resourceName =
|
||||
typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME
|
||||
if (!resourceName) continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.body.resourceName = resourceName
|
||||
provider.settings = { ...provider.settings, resourceName }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -33,7 +33,7 @@ export const AzurePlugin = define({
|
|||
if (
|
||||
!evt.options.resourceName &&
|
||||
!evt.options.baseURL &&
|
||||
(evt.model.api.type !== "aisdk" || !evt.model.api.url)
|
||||
(!ProviderV2.isAISDK(evt.model.package) || typeof evt.model.settings?.baseURL !== "string")
|
||||
) {
|
||||
throw new Error(
|
||||
"AZURE_RESOURCE_NAME is missing, set it using env var or reconnecting the azure provider and setting it",
|
||||
|
|
@ -47,7 +47,11 @@ export const AzurePlugin = define({
|
|||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.azure) return
|
||||
evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls))
|
||||
evt.language = selectLanguage(
|
||||
evt.sdk,
|
||||
evt.model.modelID ?? evt.model.id,
|
||||
Boolean(evt.options.useCompletionUrls),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
|
@ -60,18 +64,25 @@ export const AzureCognitiveServicesPlugin = define({
|
|||
const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
|
||||
if (!resourceName) return
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (!ProviderV2.isAISDK(item.provider.package)) continue
|
||||
if (ProviderV2.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue
|
||||
if (!item.provider.id.includes("azure-cognitive-services")) continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.body.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai`
|
||||
provider.settings = {
|
||||
...provider.settings,
|
||||
baseURL: `https://${resourceName}.cognitiveservices.azure.com/openai`,
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return
|
||||
evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls))
|
||||
evt.language = selectLanguage(
|
||||
evt.sdk,
|
||||
evt.model.modelID ?? evt.model.id,
|
||||
Boolean(evt.options.useCompletionUrls),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const CerebrasPlugin = define({
|
||||
id: "opencode.provider.cerebras",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/cerebras") continue
|
||||
if (!ProviderV2.isAISDK(item.provider.package)) continue
|
||||
if (ProviderV2.packageName(item.provider.package) !== "@ai-sdk/cerebras") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["X-Cerebras-3rd-Party-Integration"] = "opencode"
|
||||
provider.headers = { ...provider.headers, "X-Cerebras-3rd-Party-Integration": "opencode" }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ export const CloudflareWorkersAIPlugin = define({
|
|||
const item = evt.provider.get(providerID)
|
||||
if (!item) return
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (provider.api.type !== "aisdk") return
|
||||
if (provider.api.url) return
|
||||
const accountId = resolveAccountId(provider.request.body)
|
||||
if (accountId) provider.api.url = workersEndpoint(accountId)
|
||||
if (!ProviderV2.isAISDK(provider.package)) return
|
||||
if (typeof provider.settings?.baseURL === "string") return
|
||||
const accountId = resolveAccountId(provider.settings ?? {})
|
||||
if (accountId) provider.settings = { ...provider.settings, baseURL: workersEndpoint(accountId) }
|
||||
})
|
||||
})
|
||||
yield* ctx.aisdk.sdk(
|
||||
|
|
@ -25,7 +25,7 @@ export const CloudflareWorkersAIPlugin = define({
|
|||
if (evt.package !== "@ai-sdk/openai-compatible") return
|
||||
|
||||
const accountId = resolveAccountId(evt.options)
|
||||
if (!hasWorkersEndpoint(evt.model.api) && !accountId) return
|
||||
if (!hasWorkersEndpoint(evt.model) && !accountId) return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
|
||||
evt.sdk = mod.createOpenAICompatible(
|
||||
sdkOptions({
|
||||
|
|
@ -38,7 +38,7 @@ export const CloudflareWorkersAIPlugin = define({
|
|||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== providerID) return
|
||||
evt.language = evt.sdk.languageModel(evt.model.api.id)
|
||||
evt.language = evt.sdk.languageModel(evt.model.modelID ?? evt.model.id)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
|
@ -52,8 +52,11 @@ function workersEndpoint(accountId: string) {
|
|||
return `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`
|
||||
}
|
||||
|
||||
function hasWorkersEndpoint(api: ProviderV2.Api) {
|
||||
return api.type === "aisdk" && Boolean(api.url)
|
||||
function hasWorkersEndpoint(model: {
|
||||
readonly package?: string
|
||||
readonly settings?: Readonly<Record<string, unknown>>
|
||||
}) {
|
||||
return ProviderV2.isAISDK(model.package) && typeof model.settings?.baseURL === "string"
|
||||
}
|
||||
|
||||
function sdkOptions(options: Record<string, any>) {
|
||||
|
|
|
|||
|
|
@ -34,12 +34,11 @@ export const GithubCopilotPlugin = define({
|
|||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return
|
||||
if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) {
|
||||
evt.language = evt.sdk.languageModel(evt.model.api.id)
|
||||
evt.language = evt.sdk.languageModel(evt.model.modelID ?? evt.model.id)
|
||||
return
|
||||
}
|
||||
evt.language = shouldUseResponses(evt.model.api.id)
|
||||
? evt.sdk.responses(evt.model.api.id)
|
||||
: evt.sdk.chat(evt.model.api.id)
|
||||
const id = evt.model.modelID ?? evt.model.id
|
||||
evt.language = shouldUseResponses(id) ? evt.sdk.responses(id) : evt.sdk.chat(id)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -36,26 +36,24 @@ export const GitLabPlugin = define({
|
|||
if (evt.model.providerID !== ProviderV2.ID.gitlab) return
|
||||
const featureFlags =
|
||||
typeof evt.options.featureFlags === "object" && evt.options.featureFlags ? evt.options.featureFlags : {}
|
||||
if (evt.model.api.id.startsWith("duo-workflow-")) {
|
||||
const id = evt.model.modelID ?? evt.model.id
|
||||
if (id.startsWith("duo-workflow-")) {
|
||||
const gitlab = yield* Effect.promise(() => import("gitlab-ai-provider")).pipe(Effect.orDie)
|
||||
const workflowRef =
|
||||
typeof evt.model.request.body.workflowRef === "string" ? evt.model.request.body.workflowRef : undefined
|
||||
typeof evt.model.settings?.workflowRef === "string" ? evt.model.settings.workflowRef : undefined
|
||||
const workflowDefinition =
|
||||
typeof evt.model.request.body.workflowDefinition === "string"
|
||||
? evt.model.request.body.workflowDefinition
|
||||
typeof evt.model.settings?.workflowDefinition === "string"
|
||||
? evt.model.settings.workflowDefinition
|
||||
: undefined
|
||||
const language = evt.sdk.workflowChat(
|
||||
gitlab.isWorkflowModel(evt.model.api.id) ? evt.model.api.id : "duo-workflow",
|
||||
{
|
||||
featureFlags,
|
||||
workflowDefinition,
|
||||
},
|
||||
)
|
||||
const language = evt.sdk.workflowChat(gitlab.isWorkflowModel(id) ? id : "duo-workflow", {
|
||||
featureFlags,
|
||||
workflowDefinition,
|
||||
})
|
||||
if (workflowRef) language.selectedModelRef = workflowRef
|
||||
evt.language = language
|
||||
return
|
||||
}
|
||||
evt.language = evt.sdk.agenticChat(evt.model.api.id, {
|
||||
evt.language = evt.sdk.agenticChat(id, {
|
||||
aiGatewayHeaders: evt.options.aiGatewayHeaders,
|
||||
featureFlags,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -59,25 +59,28 @@ export const GoogleVertexPlugin = define({
|
|||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (!ProviderV2.isAISDK(item.provider.package)) continue
|
||||
if (
|
||||
item.provider.api.package !== "@ai-sdk/google-vertex" &&
|
||||
ProviderV2.packageName(item.provider.package) !== "@ai-sdk/google-vertex" &&
|
||||
!(
|
||||
item.provider.id === ProviderV2.ID.googleVertex &&
|
||||
item.provider.api.package.includes("@ai-sdk/openai-compatible")
|
||||
ProviderV2.packageName(item.provider.package)?.includes("@ai-sdk/openai-compatible")
|
||||
)
|
||||
)
|
||||
continue
|
||||
const project = resolveProject(item.provider.request.body)
|
||||
const location = String(resolveLocation(item.provider.request.body))
|
||||
const project = resolveProject(item.provider.settings ?? {})
|
||||
const location = String(resolveLocation(item.provider.settings ?? {}))
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (project) provider.request.body.project = project
|
||||
provider.request.body.location = location
|
||||
if (provider.api.type === "aisdk" && provider.api.url) {
|
||||
provider.api.url = replaceVertexVars(provider.api.url, project, location)
|
||||
}
|
||||
if (provider.api.type === "aisdk" && provider.api.package.includes("@ai-sdk/openai-compatible")) {
|
||||
provider.request.body.fetch = authFetch(provider.request.body.fetch)
|
||||
provider.settings = {
|
||||
...provider.settings,
|
||||
...(project ? { project } : {}),
|
||||
location,
|
||||
...(typeof provider.settings?.baseURL === "string"
|
||||
? { baseURL: replaceVertexVars(provider.settings.baseURL, project, location) }
|
||||
: {}),
|
||||
...(ProviderV2.packageName(provider.package)?.includes("@ai-sdk/openai-compatible")
|
||||
? { fetch: authFetch(provider.settings?.fetch) }
|
||||
: {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -104,7 +107,7 @@ export const GoogleVertexPlugin = define({
|
|||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.googleVertex) return
|
||||
evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim())
|
||||
evt.language = evt.sdk.languageModel(String(evt.model.modelID ?? evt.model.id).trim())
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
|
@ -115,21 +118,20 @@ export const GoogleVertexAnthropicPlugin = define({
|
|||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/google-vertex/anthropic") continue
|
||||
if (!ProviderV2.isAISDK(item.provider.package)) continue
|
||||
if (ProviderV2.packageName(item.provider.package) !== "@ai-sdk/google-vertex/anthropic") continue
|
||||
const project =
|
||||
item.provider.request.body.project ??
|
||||
item.provider.settings?.project ??
|
||||
process.env.GOOGLE_CLOUD_PROJECT ??
|
||||
process.env.GCP_PROJECT ??
|
||||
process.env.GCLOUD_PROJECT
|
||||
const location =
|
||||
item.provider.request.body.location ??
|
||||
item.provider.settings?.location ??
|
||||
process.env.GOOGLE_CLOUD_LOCATION ??
|
||||
process.env.VERTEX_LOCATION ??
|
||||
"global"
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (project) provider.request.body.project = project
|
||||
provider.request.body.location = location
|
||||
provider.settings = { ...provider.settings, ...(project ? { project } : {}), location }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -162,7 +164,7 @@ export const GoogleVertexAnthropicPlugin = define({
|
|||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return
|
||||
evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim())
|
||||
evt.language = evt.sdk.languageModel(String(evt.model.modelID ?? evt.model.id).trim())
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const KiloPlugin = define({
|
||||
id: "opencode.provider.kilo",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.api.url !== "https://api.kilo.ai/api/gateway") continue
|
||||
if (!ProviderV2.isAISDK(item.provider.package)) continue
|
||||
if (ProviderV2.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.settings?.baseURL !== "https://api.kilo.ai/api/gateway") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
provider.request.headers["X-Title"] = "opencode"
|
||||
provider.headers = { ...provider.headers, "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Integration } from "../../integration"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const LLMGatewayPlugin = define({
|
||||
id: "opencode.provider.llmgateway",
|
||||
|
|
@ -10,14 +11,17 @@ export const LLMGatewayPlugin = define({
|
|||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.disabled) continue
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue
|
||||
if (!ProviderV2.isAISDK(item.provider.package)) continue
|
||||
if (ProviderV2.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.settings?.baseURL !== "https://api.llmgateway.io/v1") continue
|
||||
if (!configured.has(Integration.ID.make(item.provider.id))) continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
provider.request.headers["X-Title"] = "opencode"
|
||||
provider.request.headers["X-Source"] = "opencode"
|
||||
provider.headers = {
|
||||
...provider.headers,
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
"X-Source": "opencode",
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const NvidiaPlugin = define({
|
||||
id: "opencode.provider.nvidia",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.api.url !== "https://integrate.api.nvidia.com/v1") continue
|
||||
if (!ProviderV2.isAISDK(item.provider.package)) continue
|
||||
if (ProviderV2.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.settings?.baseURL !== "https://integrate.api.nvidia.com/v1") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
provider.request.headers["X-Title"] = "opencode"
|
||||
provider.request.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode"
|
||||
provider.headers = {
|
||||
...provider.headers,
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
"X-BILLING-INVOKE-ORIGIN": provider.headers?.["X-BILLING-INVOKE-ORIGIN"] ?? "OpenCode",
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -178,8 +178,8 @@ export const OpenAIPlugin = define({
|
|||
})
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai") continue
|
||||
if (!ProviderV2.isAISDK(item.provider.package)) continue
|
||||
if (ProviderV2.packageName(item.provider.package) !== "@ai-sdk/openai") continue
|
||||
if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue
|
||||
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
|
||||
// OpenAIPlugin sends OpenAI models through Responses; this alias is a
|
||||
|
|
@ -194,7 +194,7 @@ export const OpenAIPlugin = define({
|
|||
// ChatGPT-plan tokens only authorize codex-eligible models, and the
|
||||
// subscription covers usage, so hide the rest and zero the cost.
|
||||
evt.model.update(item.provider.id, model.id, (draft) => {
|
||||
if (!OpenAICodex.eligible(draft.api.id)) {
|
||||
if (!OpenAICodex.eligible(draft.modelID ?? draft.id)) {
|
||||
draft.enabled = false
|
||||
return
|
||||
}
|
||||
|
|
@ -220,7 +220,7 @@ export const OpenAIPlugin = define({
|
|||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.openai) return
|
||||
evt.language = evt.sdk.responses(evt.model.api.id)
|
||||
evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -112,45 +112,43 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
|
|||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.integrationID = Integration.ID.make("opencode")
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
provider.api = item.npm
|
||||
? { type: "aisdk", package: item.npm, url: item.api }
|
||||
: { type: "native", url: item.api, settings: {} }
|
||||
Object.assign(provider.request.headers, item.options?.headers)
|
||||
Object.assign(provider.request.body, withoutCredentials(item.options))
|
||||
provider.package = item.npm ? ProviderV2.aisdk(item.npm) : ""
|
||||
provider.settings = {
|
||||
...provider.settings,
|
||||
...withoutCredentials(item.options),
|
||||
...(item.api ? { baseURL: item.api } : {}),
|
||||
}
|
||||
provider.headers = { ...provider.headers, ...item.options?.headers }
|
||||
})
|
||||
|
||||
for (const [modelID, config] of Object.entries(item.models ?? {})) {
|
||||
catalog.model.update(providerID, modelID, (model) => {
|
||||
if (config.family !== undefined) model.family = config.family
|
||||
if (config.name !== undefined) model.name = config.name
|
||||
if (config.id !== undefined) model.api.id = config.id
|
||||
if (config.id !== undefined) model.modelID = config.id
|
||||
if (config.provider !== undefined) {
|
||||
model.api = config.provider.npm
|
||||
? {
|
||||
id: model.api.id,
|
||||
type: "aisdk",
|
||||
package: config.provider.npm,
|
||||
url: config.provider.api,
|
||||
}
|
||||
: { id: model.api.id, type: "native", url: config.provider.api, settings: {} }
|
||||
model.package = config.provider.npm ? ProviderV2.aisdk(config.provider.npm) : undefined
|
||||
if (config.provider.api) model.settings = { ...model.settings, baseURL: config.provider.api }
|
||||
}
|
||||
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)
|
||||
Object.assign(model.request.headers, config.headers)
|
||||
Object.assign(model.request.body, lowerer.request(withoutCredentials(config.options)))
|
||||
model.headers = { ...model.headers, ...config.headers }
|
||||
model.settings = { ...model.settings, ...ConfigProviderOptionsV1.model(withoutCredentials(config.options)) }
|
||||
if (config.variants !== undefined) {
|
||||
model.variants ??= []
|
||||
for (const [id, options] of Object.entries(config.variants)) {
|
||||
const variantID = ModelV2.VariantID.make(id)
|
||||
let existing = model.variants.find((item) => item.id === variantID)
|
||||
if (!existing) {
|
||||
existing = { id: variantID, settings: {}, headers: {}, body: {} }
|
||||
existing = { id: variantID }
|
||||
model.variants.push(existing)
|
||||
}
|
||||
Object.assign(existing.headers, options.headers)
|
||||
Object.assign(existing.body, lowerer.request(withoutCredentials(options)))
|
||||
existing.headers = { ...existing.headers, ...options.headers }
|
||||
existing.settings = {
|
||||
...existing.settings,
|
||||
...ConfigProviderOptionsV1.model(withoutCredentials(options)),
|
||||
}
|
||||
}
|
||||
}
|
||||
if (config.release_date !== undefined) {
|
||||
|
|
@ -169,9 +167,9 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
|
|||
|
||||
const item = catalog.provider.get(ProviderV2.ID.opencode)
|
||||
if (!item) return
|
||||
const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.request.body.apiKey)
|
||||
const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.settings?.apiKey)
|
||||
catalog.provider.update(item.provider.id, (provider) => {
|
||||
if (!hasKey) provider.request.body.apiKey = "public"
|
||||
if (!hasKey) provider.settings = { ...provider.settings, apiKey: "public" }
|
||||
})
|
||||
if (hasKey) return
|
||||
for (const model of item.models.values()) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
|
||||
export const OpenRouterPlugin = define({
|
||||
|
|
@ -7,11 +8,10 @@ export const OpenRouterPlugin = define({
|
|||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@openrouter/ai-sdk-provider") continue
|
||||
if (!ProviderV2.isAISDK(item.provider.package)) continue
|
||||
if (ProviderV2.packageName(item.provider.package) !== "@openrouter/ai-sdk-provider") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
provider.request.headers["X-Title"] = "opencode"
|
||||
provider.headers = { ...provider.headers, "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" }
|
||||
})
|
||||
for (const modelID of [ModelV2.ID.make("gpt-5-chat-latest"), ModelV2.ID.make("openai/gpt-5-chat")]) {
|
||||
if (!item.models.has(modelID)) continue
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export const SapAICorePlugin = define({
|
|||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
|
||||
evt.language = evt.sdk(evt.model.api.id)
|
||||
evt.language = evt.sdk(evt.model.modelID ?? evt.model.id)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const VercelPlugin = define({
|
||||
id: "opencode.provider.vercel",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/vercel") continue
|
||||
if (!ProviderV2.isAISDK(item.provider.package)) continue
|
||||
if (ProviderV2.packageName(item.provider.package) !== "@ai-sdk/vercel") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["http-referer"] = "https://opencode.ai/"
|
||||
provider.request.headers["x-title"] = "opencode"
|
||||
provider.headers = { ...provider.headers, "http-referer": "https://opencode.ai/", "x-title": "opencode" }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export const XAIPlugin = define({
|
|||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("xai")) return
|
||||
evt.language = evt.sdk.responses(evt.model.api.id)
|
||||
evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,17 +1,21 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const ZenmuxPlugin = define({
|
||||
id: "opencode.provider.zenmux",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.api.url !== "https://zenmux.ai/api/v1") continue
|
||||
if (!ProviderV2.isAISDK(item.provider.package)) continue
|
||||
if (ProviderV2.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.settings?.baseURL !== "https://zenmux.ai/api/v1") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["HTTP-Referer"] ??= "https://opencode.ai/"
|
||||
provider.request.headers["X-Title"] ??= "opencode"
|
||||
provider.headers = {
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
...provider.headers,
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
export * as VariantPlugin from "./variant"
|
||||
|
||||
import type { ModelV2Info } from "@opencode-ai/sdk/v2/types"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ModelV2 } from "../model"
|
||||
import { ProviderV2 } from "../provider"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.variant",
|
||||
|
|
@ -11,14 +12,15 @@ export const Plugin = define({
|
|||
for (const record of catalog.provider.list()) {
|
||||
for (const model of record.models.values()) {
|
||||
catalog.model.update(model.providerID, model.id, (draft) => {
|
||||
const generated = generate(draft)
|
||||
const generated = generate(draft, record.provider)
|
||||
if (generated.length === 0) return
|
||||
|
||||
const explicit = new Map(draft.variants.map((variant) => [variant.id, variant]))
|
||||
const generatedIDs = new Set(generated.map((variant) => variant.id))
|
||||
const variants = draft.variants ?? []
|
||||
const explicit = new Map(variants.map((variant) => [variant.id, variant]))
|
||||
const generatedIDs = new Set<string>(generated.map((variant) => variant.id))
|
||||
draft.variants = [
|
||||
...generated.map((variant) => explicit.get(variant.id) ?? variant),
|
||||
...draft.variants.filter((variant) => !generatedIDs.has(variant.id)),
|
||||
...variants.filter((variant) => !generatedIDs.has(variant.id)),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
|
@ -27,14 +29,16 @@ export const Plugin = define({
|
|||
}),
|
||||
})
|
||||
|
||||
export function generate(model: ModelV2Info): ModelV2Info["variants"] {
|
||||
if (model.api.type !== "aisdk" || model.api.package !== "@ai-sdk/openai-compatible") return []
|
||||
const ids = `${model.id} ${model.api.id}`.toLowerCase()
|
||||
export function generate(
|
||||
model: { readonly id: string; readonly modelID?: string; readonly package?: string },
|
||||
provider?: { readonly package: string },
|
||||
): NonNullable<ModelV2.Info["variants"]> {
|
||||
const packageName = model.package ?? provider?.package
|
||||
if (!ProviderV2.isAISDK(packageName) || ProviderV2.packageName(packageName) !== "@ai-sdk/openai-compatible") return []
|
||||
const ids = `${model.id} ${model.modelID ?? ""}`.toLowerCase()
|
||||
if (!["glm-5.2", "glm-5-2", "glm-5p2"].some((name) => ids.includes(name))) return []
|
||||
return ["high", "max"].map((id) => ({
|
||||
id,
|
||||
id: ModelV2.VariantID.make(id),
|
||||
settings: { reasoningEffort: id },
|
||||
headers: {},
|
||||
body: {},
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,106 @@
|
|||
export * as ProviderV2 from "./provider"
|
||||
|
||||
import { Types } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import type { ProviderPackageDefinition } from "@opencode-ai/llm"
|
||||
import { Npm } from "./npm"
|
||||
import type { DeepMutable } from "./schema"
|
||||
|
||||
export const ID = Provider.ID
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export const AISDK = Provider.AISDK
|
||||
export const AISDK_PREFIX = "aisdk:"
|
||||
export const isAISDK = (value: string | undefined) => value?.startsWith(AISDK_PREFIX) ?? false
|
||||
export const aisdk = (value: string) => (isAISDK(value) ? value : `${AISDK_PREFIX}${value}`)
|
||||
export const packageName = (value: string | undefined) => {
|
||||
if (value === undefined || !isAISDK(value)) return value
|
||||
return value.slice(AISDK_PREFIX.length)
|
||||
}
|
||||
|
||||
export const Native = Provider.Native
|
||||
type Json = Schema.Schema.Type<typeof Schema.Json>
|
||||
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
|
||||
const decodeJsonRecord = Schema.decodeUnknownSync(JsonRecord)
|
||||
|
||||
export const Api = Provider.Api
|
||||
export type Api = Provider.Api
|
||||
export type MutableApi<T extends Api = Api> = T extends Api
|
||||
? Omit<Types.DeepMutable<T>, "settings"> & (undefined extends T["settings"] ? { settings?: any } : { settings: any })
|
||||
: never
|
||||
export class LoadError extends Schema.TaggedErrorClass<LoadError>()("ProviderV2.LoadError", {
|
||||
package: Schema.String,
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
export type ProviderPackage = ProviderPackageDefinition
|
||||
|
||||
const packages = new Map<string, Promise<unknown>>()
|
||||
|
||||
export const loadPackage = Effect.fn("ProviderV2.loadPackage")(function* (specifier: string, npm?: Npm.Interface) {
|
||||
const resolved = yield* Effect.sync(() => {
|
||||
if (specifier.startsWith("file://") || specifier.startsWith("@opencode-ai/llm/")) return specifier
|
||||
try {
|
||||
return import.meta.resolve(specifier)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
if (resolved) return yield* importPackage(specifier, resolved)
|
||||
if (!npm) {
|
||||
return yield* new LoadError({
|
||||
package: specifier,
|
||||
cause: new Error(`Provider package ${specifier} is not installed`),
|
||||
})
|
||||
}
|
||||
const parts = specifier.split("/")
|
||||
const root = specifier.startsWith("@") ? parts.slice(0, 2).join("/") : (parts[0] ?? specifier)
|
||||
const installed = yield* npm.add(root).pipe(Effect.mapError((cause) => new LoadError({ package: specifier, cause })))
|
||||
const entrypoint = yield* Effect.try({
|
||||
try: () => import.meta.resolve(specifier, pathToFileURL(`${installed.directory}/`).href),
|
||||
catch: (cause) => new LoadError({ package: specifier, cause }),
|
||||
})
|
||||
return yield* importPackage(specifier, entrypoint)
|
||||
})
|
||||
|
||||
export function mergeOverlay(
|
||||
base: Readonly<Record<string, unknown>> | undefined,
|
||||
overlay: Readonly<Record<string, unknown>> | undefined,
|
||||
): Record<string, Json> | undefined {
|
||||
if (base === undefined) return overlay && decodeJsonRecord({ ...overlay })
|
||||
if (overlay === undefined) return decodeJsonRecord({ ...base })
|
||||
return decodeJsonRecord(
|
||||
Object.fromEntries(
|
||||
new Set([...Object.keys(base), ...Object.keys(overlay)]).values().map((key): [string, unknown] => {
|
||||
const left = base[key]
|
||||
const right = overlay[key]
|
||||
if (right === undefined) return [key, left]
|
||||
if (
|
||||
typeof left === "object" &&
|
||||
left !== null &&
|
||||
!Array.isArray(left) &&
|
||||
typeof right === "object" &&
|
||||
right !== null &&
|
||||
!Array.isArray(right)
|
||||
)
|
||||
return [
|
||||
key,
|
||||
mergeOverlay(left as Readonly<Record<string, unknown>>, right as Readonly<Record<string, unknown>>) ?? {},
|
||||
]
|
||||
return [key, right]
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
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)]
|
||||
.reduce((result, entry) => {
|
||||
result.set(entry[0].toLowerCase(), entry)
|
||||
return result
|
||||
}, new Map<string, [string, string]>())
|
||||
.values(),
|
||||
)
|
||||
}
|
||||
|
||||
export const Request = Provider.Request
|
||||
export type Request = Provider.Request
|
||||
|
|
@ -25,9 +111,24 @@ export type Settings = Provider.Settings
|
|||
export const Info = Provider.Info
|
||||
export type Info = Provider.Info
|
||||
|
||||
export type MutableRequest = Types.DeepMutable<Request>
|
||||
export type MutableInfo = DeepMutable<Info>
|
||||
|
||||
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api" | "request"> & {
|
||||
api: MutableApi
|
||||
request: MutableRequest
|
||||
}
|
||||
const importPackage = Effect.fn("ProviderV2.importPackage")(function* (specifier: string, entrypoint: string) {
|
||||
const module = yield* Effect.tryPromise({
|
||||
try: () => {
|
||||
const existing = packages.get(entrypoint)
|
||||
if (existing) return existing
|
||||
const loaded = import(entrypoint)
|
||||
packages.set(entrypoint, loaded)
|
||||
return loaded
|
||||
},
|
||||
catch: (cause) => new LoadError({ package: specifier, cause }),
|
||||
})
|
||||
if (typeof module !== "object" || module === null || typeof (module as { model?: unknown }).model !== "function") {
|
||||
return yield* new LoadError({
|
||||
package: specifier,
|
||||
cause: new Error(`Provider package ${specifier} does not export model(modelID, settings)`),
|
||||
})
|
||||
}
|
||||
return module as ProviderPackageDefinition
|
||||
})
|
||||
|
|
|
|||
|
|
@ -213,7 +213,10 @@ const layer = Layer.effect(
|
|||
]
|
||||
.filter((part): part is string => part !== undefined && part.length > 0)
|
||||
.map(SystemPart.make),
|
||||
messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
|
||||
messages: [
|
||||
...toLLMMessages(context, resolved.ref),
|
||||
...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : []),
|
||||
],
|
||||
tools: toolMaterialization?.definitions ?? [],
|
||||
toolChoice: isLastStep ? "none" : undefined,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export * as SessionRunnerModel from "./model"
|
||||
|
||||
import { makeLocationNode } from "../../effect/app-node"
|
||||
import { type Model } from "@opencode-ai/llm"
|
||||
import { Model } from "@opencode-ai/llm"
|
||||
// ast-grep-ignore: no-star-import
|
||||
import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages"
|
||||
// ast-grep-ignore: no-star-import
|
||||
|
|
@ -11,10 +11,12 @@ 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 { produce } from "immer"
|
||||
import { AISDK } from "../../aisdk"
|
||||
import { Catalog } from "../../catalog"
|
||||
import { Credential } from "../../credential"
|
||||
import { Integration } from "../../integration"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { Npm } from "../../npm"
|
||||
import { OpenAICodex } from "../../plugin/provider/openai-codex"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import { SessionSchema } from "../schema"
|
||||
|
|
@ -55,16 +57,16 @@ export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnav
|
|||
}
|
||||
}
|
||||
|
||||
export class UnsupportedApiError extends Schema.TaggedErrorClass<UnsupportedApiError>()(
|
||||
"SessionRunnerModel.UnsupportedApiError",
|
||||
export class UnsupportedPackageError extends Schema.TaggedErrorClass<UnsupportedPackageError>()(
|
||||
"SessionRunnerModel.UnsupportedPackageError",
|
||||
{
|
||||
providerID: ProviderV2.ID,
|
||||
modelID: ModelV2.ID,
|
||||
api: Schema.String,
|
||||
package: Schema.String,
|
||||
},
|
||||
) {
|
||||
override get message() {
|
||||
return `Unsupported API for ${this.providerID}/${this.modelID}: ${this.api}`
|
||||
return `Unsupported package for ${this.providerID}/${this.modelID}: ${this.package}`
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -72,7 +74,7 @@ export type Error =
|
|||
| ModelNotSelectedError
|
||||
| ModelUnavailableError
|
||||
| VariantUnavailableError
|
||||
| UnsupportedApiError
|
||||
| UnsupportedPackageError
|
||||
| Integration.AuthorizationError
|
||||
|
||||
export interface Resolved {
|
||||
|
|
@ -104,41 +106,50 @@ export const resolved = (model: Model, variant?: ModelV2.VariantID): Resolved =>
|
|||
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.request.body.apiKey ?? model.api.settings?.apiKey
|
||||
const value = model.settings?.apiKey
|
||||
if (typeof value === "string") return Auth.value(value)
|
||||
}
|
||||
|
||||
const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
|
||||
const body = model.request.body
|
||||
const httpBody = Object.hasOwn(body, "apiKey")
|
||||
? Object.fromEntries(Object.entries(body).filter(([key]) => key !== "apiKey"))
|
||||
: body
|
||||
return route.with({
|
||||
const withDefaults = (model: ModelV2.Info, route: AnyRoute) =>
|
||||
route.with({
|
||||
provider: model.providerID,
|
||||
endpoint: model.api.url === undefined ? undefined : { baseURL: model.api.url },
|
||||
headers: model.request.headers,
|
||||
endpoint: typeof model.settings?.baseURL === "string" ? { baseURL: model.settings.baseURL } : undefined,
|
||||
headers: providerHeaders(model),
|
||||
providerOptions: providerOptions(model),
|
||||
http: { body: httpBody },
|
||||
http: model.body === undefined ? undefined : { body: model.body },
|
||||
limits: { context: model.limit.context, output: model.limit.output },
|
||||
})
|
||||
|
||||
const providerHeaders = (model: ModelV2.Info) => {
|
||||
const packageName = ProviderV2.packageName(model.package)
|
||||
const generated = new Map<string, string>()
|
||||
if (packageName === "@ai-sdk/openai" && typeof model.settings?.organization === "string")
|
||||
generated.set("OpenAI-Organization", model.settings.organization)
|
||||
if (packageName === "@ai-sdk/openai" && typeof model.settings?.project === "string")
|
||||
generated.set("OpenAI-Project", model.settings.project)
|
||||
if (packageName === "@ai-sdk/anthropic" && typeof model.settings?.authToken === "string")
|
||||
generated.set("Authorization", `Bearer ${model.settings.authToken}`)
|
||||
return ProviderV2.mergeHeaders(generated.size === 0 ? undefined : Object.fromEntries(generated), model.headers)
|
||||
}
|
||||
|
||||
const providerOptions = (
|
||||
model: ModelV2.Info,
|
||||
): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
|
||||
if (Object.keys(model.request.settings).length === 0) return undefined
|
||||
if (model.api.type !== "aisdk") return undefined
|
||||
if (model.api.package === "@ai-sdk/openai") return { openai: model.request.settings }
|
||||
if (model.api.package === "@ai-sdk/anthropic") return { anthropic: model.request.settings }
|
||||
if (model.api.package === "@ai-sdk/openai-compatible") return { openai: model.request.settings }
|
||||
if (!ProviderV2.isAISDK(model.package) || model.settings === undefined) return undefined
|
||||
const { apiKey: _, baseURL: _baseURL, ...settings } = model.settings
|
||||
if (Object.keys(settings).length === 0) return undefined
|
||||
const packageName = ProviderV2.packageName(model.package)
|
||||
if (packageName === "@ai-sdk/openai") return { openai: settings }
|
||||
if (packageName === "@ai-sdk/anthropic") return { anthropic: settings }
|
||||
if (packageName === "@ai-sdk/openai-compatible") return { openai: settings }
|
||||
}
|
||||
|
||||
export const withVariant = (
|
||||
model: ModelV2.Info,
|
||||
variantID: ModelV2.VariantID | undefined,
|
||||
): Effect.Effect<ModelV2.Info, VariantUnavailableError> => {
|
||||
const id = variantID === "default" || variantID === undefined ? model.request.variant : variantID
|
||||
const variant = model.variants.find((item) => item.id === id)
|
||||
const id = variantID === "default" ? undefined : variantID
|
||||
const variant = model.variants?.find((item) => item.id === id)
|
||||
if (!variant && variantID !== undefined && variantID !== "default")
|
||||
return Effect.fail(
|
||||
new VariantUnavailableError({
|
||||
|
|
@ -150,81 +161,140 @@ export const withVariant = (
|
|||
return Effect.succeed(
|
||||
variant
|
||||
? produce(model, (draft) => {
|
||||
Object.assign(draft.request.settings, variant.settings)
|
||||
Object.assign(draft.request.headers, variant.headers)
|
||||
Object.assign(draft.request.body, variant.body)
|
||||
draft.settings = ProviderV2.mergeOverlay(draft.settings, variant.settings)
|
||||
draft.headers = ProviderV2.mergeHeaders(draft.headers, variant.headers)
|
||||
draft.body = ProviderV2.mergeOverlay(draft.body, variant.body)
|
||||
})
|
||||
: model,
|
||||
)
|
||||
}
|
||||
|
||||
const apiName = (model: ModelV2.Info) =>
|
||||
model.api.type === "aisdk" ? `${model.api.type}:${model.api.package}` : model.api.type
|
||||
export interface Dependencies {
|
||||
readonly loadPackage?: (specifier: string) => Effect.Effect<ProviderV2.ProviderPackage, ProviderV2.LoadError>
|
||||
readonly loadAISDK?: (model: ModelV2.Info) => Effect.Effect<Model, AISDK.InitError>
|
||||
}
|
||||
|
||||
export const fromCatalogModel = (
|
||||
model: ModelV2.Info,
|
||||
credential?: Credential.Value,
|
||||
): Effect.Effect<Model, UnsupportedApiError> => {
|
||||
dependencies: Dependencies = {},
|
||||
): Effect.Effect<Model, UnsupportedPackageError> => {
|
||||
const resolved =
|
||||
credential?.type !== "key" || credential.metadata === undefined
|
||||
? model
|
||||
: produce(model, (draft) => {
|
||||
Object.assign(draft.request.body, credential.metadata)
|
||||
draft.body = ProviderV2.mergeOverlay(draft.body, credential.metadata)
|
||||
})
|
||||
const packageName = ProviderV2.packageName(resolved.package)
|
||||
const key = apiKey(resolved, credential)
|
||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") {
|
||||
// ChatGPT-plan OAuth tokens are not API-key credentials: the public API rejects
|
||||
// them, so requests must target the codex backend with the account header.
|
||||
if (OpenAICodex.isChatGPT(credential)) {
|
||||
const account = OpenAICodex.accountID(credential)
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, OpenAIResponses.route)
|
||||
.with({
|
||||
endpoint: { baseURL: OpenAICodex.baseURL },
|
||||
auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen(
|
||||
account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }),
|
||||
),
|
||||
})
|
||||
.model({ id: resolved.api.id }),
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
OpenAICodex.isChatGPT(credential) &&
|
||||
!ProviderV2.isAISDK(resolved.package) &&
|
||||
isNativeOpenAI(resolved.package)
|
||||
) {
|
||||
return Effect.succeed(codexModel(resolved, credential, key))
|
||||
}
|
||||
|
||||
if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
|
||||
if (OpenAICodex.isChatGPT(credential)) return Effect.succeed(codexModel(resolved, credential, key))
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, OpenAIResponses.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: resolved.api.id }),
|
||||
.model({ id: resolved.modelID ?? resolved.id }),
|
||||
)
|
||||
}
|
||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/anthropic") {
|
||||
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.api.id }),
|
||||
.model({ id: resolved.modelID ?? resolved.id }),
|
||||
)
|
||||
}
|
||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai-compatible" && resolved.api.url) {
|
||||
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.api.id }),
|
||||
.model({ id: resolved.modelID ?? resolved.id }),
|
||||
)
|
||||
}
|
||||
return Effect.fail(
|
||||
new UnsupportedApiError({
|
||||
providerID: resolved.providerID,
|
||||
modelID: resolved.id,
|
||||
api: apiName(resolved),
|
||||
}),
|
||||
)
|
||||
if (ProviderV2.isAISDK(resolved.package)) {
|
||||
if (!dependencies.loadAISDK) return Effect.fail(unsupported(resolved))
|
||||
const runtime = produce(resolved, (draft) => {
|
||||
draft.settings = ProviderV2.mergeOverlay(draft.settings, {
|
||||
...(credential?.type === "key" ? { apiKey: credential.key } : {}),
|
||||
...(credential?.type === "oauth" ? { apiKey: credential.access } : {}),
|
||||
...credential?.metadata,
|
||||
})
|
||||
})
|
||||
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
|
||||
}
|
||||
if (!resolved.package) return Effect.fail(unsupported(resolved))
|
||||
|
||||
const specifier = resolved.package
|
||||
return Effect.gen(function* () {
|
||||
const module = yield* (dependencies.loadPackage ?? ProviderV2.loadPackage)(specifier).pipe(
|
||||
Effect.mapError(() => unsupported(resolved)),
|
||||
)
|
||||
const settings = {
|
||||
...resolved.settings,
|
||||
...(credential?.type === "key" ? { apiKey: credential.key } : {}),
|
||||
...(credential?.type === "oauth" ? { apiKey: credential.access } : {}),
|
||||
...credential?.metadata,
|
||||
headers: resolved.headers,
|
||||
body: resolved.body,
|
||||
limits: { context: resolved.limit.context, output: resolved.limit.output },
|
||||
}
|
||||
return yield* Effect.try({
|
||||
try: () =>
|
||||
Model.update(module.model(resolved.modelID ?? resolved.id, settings), { provider: resolved.providerID }),
|
||||
catch: () => unsupported(resolved),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, credential?: Credential.Value) =>
|
||||
withVariant(model, session.model?.variant).pipe(Effect.flatMap((model) => fromCatalogModel(model, credential)))
|
||||
const isNativeOpenAI = (packageName: string | undefined) =>
|
||||
packageName === "@opencode-ai/llm/providers/openai" ||
|
||||
packageName?.startsWith("@opencode-ai/llm/providers/openai/") === true
|
||||
|
||||
export const supported = (model: ModelV2.Info) =>
|
||||
model.api.type === "aisdk" &&
|
||||
(model.api.package === "@ai-sdk/openai" ||
|
||||
model.api.package === "@ai-sdk/anthropic" ||
|
||||
(model.api.package === "@ai-sdk/openai-compatible" && model.api.url !== undefined))
|
||||
const codexModel = (
|
||||
model: ModelV2.Info,
|
||||
credential: Credential.Value | undefined,
|
||||
key: ReturnType<typeof Auth.value> | undefined,
|
||||
) => {
|
||||
const account = OpenAICodex.accountID(credential)
|
||||
return withDefaults(model, OpenAIResponses.route)
|
||||
.with({
|
||||
endpoint: { baseURL: OpenAICodex.baseURL },
|
||||
auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen(
|
||||
account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }),
|
||||
),
|
||||
})
|
||||
.model({ id: model.modelID ?? model.id })
|
||||
}
|
||||
|
||||
const unsupported = (model: ModelV2.Info) =>
|
||||
new UnsupportedPackageError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
package: model.package ?? "unknown",
|
||||
})
|
||||
|
||||
export const resolve = (
|
||||
session: SessionSchema.Info,
|
||||
model: ModelV2.Info,
|
||||
credential?: Credential.Value,
|
||||
dependencies?: Dependencies,
|
||||
) =>
|
||||
withVariant(model, session.model?.variant).pipe(
|
||||
Effect.flatMap((model) => fromCatalogModel(model, credential, dependencies)),
|
||||
)
|
||||
|
||||
export const supported = (model: ModelV2.Info) => Boolean(model.package)
|
||||
|
||||
/** Resolves models from the catalog belonging to the current Location runtime. */
|
||||
const layer = Layer.effect(
|
||||
|
|
@ -232,6 +302,8 @@ const layer = Layer.effect(
|
|||
Effect.gen(function* () {
|
||||
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.
|
||||
|
|
@ -257,6 +329,10 @@ const layer = Layer.effect(
|
|||
session,
|
||||
selected,
|
||||
connection ? yield* integrations.connection.resolve(connection) : undefined,
|
||||
{
|
||||
loadPackage: (specifier) => ProviderV2.loadPackage(specifier, npm),
|
||||
loadAISDK: (model) => aisdk.model(model),
|
||||
},
|
||||
)
|
||||
return {
|
||||
model,
|
||||
|
|
@ -271,4 +347,8 @@ const layer = Layer.effect(
|
|||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Catalog.node, Integration.node] })
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Catalog.node, Integration.node, Npm.node, AISDK.node],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import {
|
|||
ToolOutput,
|
||||
ToolResultPart,
|
||||
type ContentPart,
|
||||
type Model,
|
||||
type ProviderMetadata,
|
||||
} from "@opencode-ai/llm"
|
||||
import { Option, Schema } from "effect"
|
||||
import type { ModelV2 } from "../../model"
|
||||
import { SessionMessage } from "../message"
|
||||
import type { FileAttachment } from "@opencode-ai/schema/prompt"
|
||||
|
||||
|
|
@ -88,9 +88,9 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
|
|||
}
|
||||
}
|
||||
|
||||
const assistant = (message: SessionMessage.Assistant, model: Model) => {
|
||||
const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => {
|
||||
const sameModel =
|
||||
String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id)
|
||||
String(message.model.providerID) === String(model.providerID) && String(message.model.id) === String(model.id)
|
||||
const reuseProviderMetadata = sameModel && message.error === undefined
|
||||
const content = message.content.flatMap((item): ContentPart[] => {
|
||||
if (item.type === "text") return [{ type: "text", text: item.text }]
|
||||
|
|
@ -133,7 +133,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
|
|||
]
|
||||
}
|
||||
|
||||
function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] {
|
||||
function toLLMMessage(message: SessionMessage.Message, model: ModelV2.Ref): Message[] {
|
||||
switch (message.type) {
|
||||
case "agent-switched":
|
||||
case "model-switched":
|
||||
|
|
@ -197,5 +197,5 @@ ${message.recent}
|
|||
}
|
||||
|
||||
/** Translate projected V2 Session history into canonical @opencode-ai/llm context. */
|
||||
export const toLLMMessages = (messages: readonly SessionMessage.Message[], model: Model) =>
|
||||
export const toLLMMessages = (messages: readonly SessionMessage.Message[], model: ModelV2.Ref) =>
|
||||
messages.flatMap((message) => toLLMMessage(message, model))
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@ export * as ConfigMigrateV1 from "./migrate"
|
|||
|
||||
import { ConfigV1 } from "./config"
|
||||
import { ConfigAgentV1 } from "./agent"
|
||||
import { ConfigCommandV1 } from "./command"
|
||||
import { ConfigMCPV1 } from "./mcp"
|
||||
import { ConfigPermissionV1 } from "./permission"
|
||||
import { ConfigProviderV1 } from "./provider"
|
||||
import { ConfigProviderOptionsV1 } from "./provider-options"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
const keys = new Set([
|
||||
"logLevel",
|
||||
|
|
@ -48,7 +50,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
|
|||
return {
|
||||
$schema: info.$schema,
|
||||
shell: info.shell,
|
||||
model: info.model,
|
||||
model: modelSelection(info.model),
|
||||
default_agent: info.default_agent,
|
||||
autoupdate: info.autoupdate,
|
||||
share: info.share ?? (info.autoshare ? "auto" : undefined),
|
||||
|
|
@ -72,7 +74,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
|
|||
buffer: info.compaction.reserved,
|
||||
},
|
||||
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
|
||||
commands: info.command,
|
||||
commands: commands(info.command),
|
||||
instructions: info.instructions,
|
||||
references: info.references ?? info.reference,
|
||||
plugins: info.plugin?.map((plugin) =>
|
||||
|
|
@ -126,8 +128,7 @@ export function migrateAgent(info: ConfigAgentV1.Info) {
|
|||
...(info.top_p === undefined ? {} : { top_p: info.top_p }),
|
||||
}
|
||||
return {
|
||||
model: info.model,
|
||||
variant: info.variant,
|
||||
model: modelSelection(info.model, info.variant),
|
||||
request: Object.keys(body).length ? { body } : undefined,
|
||||
system: info.prompt,
|
||||
description: info.description,
|
||||
|
|
@ -140,6 +141,32 @@ export function migrateAgent(info: ConfigAgentV1.Info) {
|
|||
}
|
||||
}
|
||||
|
||||
function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>) {
|
||||
if (!info) return undefined
|
||||
return Object.fromEntries(
|
||||
Object.entries(info).map(([id, command]) => [
|
||||
id,
|
||||
{
|
||||
template: command.template,
|
||||
description: command.description,
|
||||
agent: command.agent,
|
||||
model: modelSelection(command.model, command.variant),
|
||||
subtask: command.subtask,
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
function modelSelection(input?: string, variant?: string) {
|
||||
if (input === undefined || !/^[^/#]+\/[^#]+$/.test(input)) return undefined
|
||||
const separator = input.indexOf("/")
|
||||
return {
|
||||
providerID: input.slice(0, separator),
|
||||
model: input.slice(separator + 1),
|
||||
...(variant === undefined || variant.length === 0 || variant.includes("#") ? {} : { variant }),
|
||||
}
|
||||
}
|
||||
|
||||
function mcp(info: typeof ConfigV1.Info.Type) {
|
||||
const servers = Object.fromEntries(
|
||||
Object.entries(info.mcp ?? {}).flatMap(([name, server]) =>
|
||||
|
|
@ -184,31 +211,22 @@ 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 url = info.api ?? options.url
|
||||
const options = ConfigProviderOptionsV1.provider(info.options ?? {})
|
||||
return {
|
||||
name: info.name,
|
||||
env: info.env,
|
||||
api: info.npm
|
||||
? {
|
||||
type: "aisdk" as const,
|
||||
package: info.npm,
|
||||
...(url === undefined ? {} : { url }),
|
||||
settings: options.settings ?? {},
|
||||
}
|
||||
: undefined,
|
||||
request: info.options && { headers: options.headers, body: options.body },
|
||||
package: info.npm ? ProviderV2.aisdk(info.npm) : undefined,
|
||||
settings: info.api ? { ...options.settings, baseURL: info.api } : options.settings,
|
||||
headers: info.options && options.headers,
|
||||
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 request = info.options && lowerer.request(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,
|
||||
|
|
@ -231,29 +249,18 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: st
|
|||
? { tools: info.tool_call ?? false, input: info.modalities?.input ?? [], output: info.modalities?.output ?? [] }
|
||||
: undefined
|
||||
return {
|
||||
modelID: info.id,
|
||||
family: info.family,
|
||||
name: info.name,
|
||||
api: info.provider?.npm
|
||||
? {
|
||||
...(info.id === undefined ? {} : { id: info.id }),
|
||||
type: "aisdk" as const,
|
||||
package: info.provider.npm,
|
||||
...(info.provider.api === undefined ? {} : { url: info.provider.api }),
|
||||
settings: {},
|
||||
}
|
||||
: info.id === undefined
|
||||
? undefined
|
||||
: { id: info.id },
|
||||
package: info.provider?.npm ? ProviderV2.aisdk(info.provider.npm) : undefined,
|
||||
settings: info.provider?.api ? { ...settings, baseURL: info.provider.api } : settings,
|
||||
capabilities,
|
||||
request: (info.headers || request) && {
|
||||
headers: info.headers,
|
||||
body: request,
|
||||
},
|
||||
headers: info.headers,
|
||||
variants:
|
||||
info.variants &&
|
||||
Object.entries(info.variants).map(([id, options]) => ({
|
||||
id,
|
||||
body: lowerer.request(options),
|
||||
settings: ConfigProviderOptionsV1.model(options),
|
||||
})),
|
||||
cost: costs,
|
||||
disabled: info.status === "deprecated" ? true : undefined,
|
||||
|
|
|
|||
|
|
@ -3,225 +3,29 @@ export * as ConfigProviderOptionsV1 from "./provider-options"
|
|||
type Options = Readonly<Record<string, unknown>>
|
||||
|
||||
export interface ProviderResult {
|
||||
readonly settings: Record<string, unknown>
|
||||
readonly headers?: Record<string, string>
|
||||
readonly body?: Record<string, unknown>
|
||||
readonly url?: string
|
||||
readonly settings?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface Lowerer {
|
||||
readonly provider: (options: Options) => ProviderResult
|
||||
readonly request: (options: Options) => Record<string, unknown>
|
||||
}
|
||||
|
||||
export function get(packageName?: string): Lowerer {
|
||||
const key = packageName ?? ""
|
||||
return Object.hasOwn(lowerers, key) ? lowerers[key]! : raw
|
||||
}
|
||||
|
||||
const raw: Lowerer = {
|
||||
provider(options) {
|
||||
return { body: clone(options) }
|
||||
},
|
||||
request: clone,
|
||||
}
|
||||
|
||||
const openai: Lowerer = {
|
||||
provider(options) {
|
||||
return {
|
||||
url: string(options.baseURL),
|
||||
headers: compact({
|
||||
Authorization: bearer(options.apiKey),
|
||||
"OpenAI-Organization": string(options.organization),
|
||||
"OpenAI-Project": string(options.project),
|
||||
...headers(options.headers),
|
||||
}),
|
||||
body: body(options.body),
|
||||
settings: omit(options, ["apiKey", "baseURL", "organization", "project", "headers", "body"]),
|
||||
}
|
||||
},
|
||||
request(options) {
|
||||
const result = snake(options)
|
||||
if (options.reasoningEffort !== undefined || options.reasoningSummary !== undefined) {
|
||||
result.reasoning = {
|
||||
...(isRecord(result.reasoning) ? result.reasoning : {}),
|
||||
...(options.reasoningEffort !== undefined ? { effort: options.reasoningEffort } : {}),
|
||||
...(options.reasoningSummary !== undefined ? { summary: options.reasoningSummary } : {}),
|
||||
}
|
||||
delete result.reasoning_effort
|
||||
delete result.reasoning_summary
|
||||
}
|
||||
if (options.textVerbosity !== undefined) {
|
||||
result.text = { ...(isRecord(result.text) ? result.text : {}), verbosity: options.textVerbosity }
|
||||
delete result.text_verbosity
|
||||
}
|
||||
return result
|
||||
},
|
||||
}
|
||||
|
||||
const anthropic: Lowerer = {
|
||||
provider(options) {
|
||||
return {
|
||||
url: string(options.baseURL),
|
||||
headers: compact({
|
||||
"x-api-key": string(options.apiKey),
|
||||
Authorization: options.authToken ? bearer(options.authToken) : undefined,
|
||||
...headers(options.headers),
|
||||
}),
|
||||
body: body(options.body),
|
||||
settings: omit(options, ["apiKey", "authToken", "baseURL", "headers", "body"]),
|
||||
}
|
||||
},
|
||||
request(options) {
|
||||
const result = snake(options)
|
||||
if (options.effort !== undefined || options.taskBudget !== undefined) {
|
||||
result.output_config = compactUnknown({ effort: options.effort, task_budget: options.taskBudget })
|
||||
delete result.effort
|
||||
delete result.task_budget
|
||||
}
|
||||
if (isRecord(options.metadata) && options.metadata.userId !== undefined) {
|
||||
result.metadata = { ...(isRecord(result.metadata) ? result.metadata : {}), user_id: options.metadata.userId }
|
||||
}
|
||||
return result
|
||||
},
|
||||
}
|
||||
|
||||
const google: Lowerer = {
|
||||
provider(options) {
|
||||
return {
|
||||
url: string(options.baseURL),
|
||||
headers: compact({ "x-goog-api-key": string(options.apiKey), ...headers(options.headers) }),
|
||||
body: body(options.body),
|
||||
settings: omit(options, ["apiKey", "baseURL", "headers", "body"]),
|
||||
}
|
||||
},
|
||||
request(options) {
|
||||
const generationConfig = pick(options, ["thinkingConfig", "responseModalities", "mediaResolution", "imageConfig"])
|
||||
return {
|
||||
...omit(options, ["thinkingConfig", "responseModalities", "mediaResolution", "imageConfig"]),
|
||||
...(Object.keys(generationConfig).length ? { generationConfig } : {}),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const azure: Lowerer = {
|
||||
provider(options) {
|
||||
return {
|
||||
url: string(options.baseURL),
|
||||
headers: compact({ "api-key": string(options.apiKey), ...headers(options.headers) }),
|
||||
body: body(options.body),
|
||||
settings: omit(options, ["apiKey", "baseURL", "headers", "body"]),
|
||||
}
|
||||
},
|
||||
request: openai.request,
|
||||
}
|
||||
|
||||
const bedrock: Lowerer = {
|
||||
provider(options) {
|
||||
return direct(options)
|
||||
},
|
||||
request(options) {
|
||||
return { additionalModelRequestFields: clone(options) }
|
||||
},
|
||||
}
|
||||
|
||||
const openaiCompatible: Lowerer = {
|
||||
provider(options) {
|
||||
return { ...direct(options, ["baseURL"]), url: string(options.baseURL) }
|
||||
},
|
||||
request(options) {
|
||||
const result = clone(options)
|
||||
if (options.reasoningEffort !== undefined) {
|
||||
result.reasoning_effort = options.reasoningEffort
|
||||
delete result.reasoningEffort
|
||||
}
|
||||
return result
|
||||
},
|
||||
}
|
||||
|
||||
const lowerers: Readonly<Record<string, Lowerer>> = {
|
||||
"@ai-sdk/openai": openai,
|
||||
"@ai-sdk/anthropic": anthropic,
|
||||
"@ai-sdk/google-vertex/anthropic": anthropic,
|
||||
"@ai-sdk/google": google,
|
||||
"@ai-sdk/google-vertex": google,
|
||||
"@ai-sdk/azure": azure,
|
||||
"@ai-sdk/amazon-bedrock": bedrock,
|
||||
"@ai-sdk/openai-compatible": openaiCompatible,
|
||||
"@ai-sdk/cerebras": openaiCompatible,
|
||||
"@ai-sdk/deepinfra": openaiCompatible,
|
||||
"@ai-sdk/groq": openaiCompatible,
|
||||
"@ai-sdk/mistral": openaiCompatible,
|
||||
"@ai-sdk/togetherai": openaiCompatible,
|
||||
"@ai-sdk/xai": openaiCompatible,
|
||||
"@openrouter/ai-sdk-provider": openaiCompatible,
|
||||
"ai-gateway-provider": openaiCompatible,
|
||||
"venice-ai-sdk-provider": openaiCompatible,
|
||||
}
|
||||
|
||||
function direct(options: Options, extraKeys: ReadonlyArray<string> = []): ProviderResult {
|
||||
export function provider(options: Options): ProviderResult {
|
||||
const headers = options.headers
|
||||
const body = options.body
|
||||
const settings = Object.fromEntries(Object.entries(options).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 {
|
||||
headers: headers(options.headers),
|
||||
body: body(options.body),
|
||||
settings: omit(options, ["headers", "body", ...extraKeys]),
|
||||
settings,
|
||||
headers: headerOverlay,
|
||||
body: bodyOverlay,
|
||||
}
|
||||
}
|
||||
|
||||
function body(input: unknown) {
|
||||
if (!isRecord(input)) return undefined
|
||||
return { ...input }
|
||||
}
|
||||
|
||||
function snake(options: Options) {
|
||||
return Object.fromEntries(Object.entries(options).map(([key, value]) => [snakeKey(key), snakeValue(value)]))
|
||||
}
|
||||
|
||||
function snakeValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(snakeValue)
|
||||
if (!isRecord(value)) return value
|
||||
return Object.fromEntries(Object.entries(value).map(([key, value]) => [snakeKey(key), snakeValue(value)]))
|
||||
}
|
||||
|
||||
function snakeKey(key: string) {
|
||||
return key.replace(/[A-Z]/g, (match) => "_" + match.toLowerCase())
|
||||
}
|
||||
|
||||
function clone(options: Options) {
|
||||
export function model(options: Options) {
|
||||
return { ...options }
|
||||
}
|
||||
|
||||
function omit(options: Options, keys: ReadonlyArray<string>) {
|
||||
return Object.fromEntries(Object.entries(options).filter(([key]) => !keys.includes(key)))
|
||||
}
|
||||
|
||||
function pick(options: Options, keys: ReadonlyArray<string>) {
|
||||
return Object.fromEntries(Object.entries(options).filter(([key]) => keys.includes(key)))
|
||||
}
|
||||
|
||||
function headers(input: unknown) {
|
||||
if (!isRecord(input)) return undefined
|
||||
return Object.fromEntries(
|
||||
Object.entries(input).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||
)
|
||||
}
|
||||
|
||||
function compact(input: Record<string, string | undefined>) {
|
||||
const entries = Object.entries(input).filter((entry): entry is [string, string] => entry[1] !== undefined)
|
||||
return entries.length ? Object.fromEntries(entries) : undefined
|
||||
}
|
||||
|
||||
function compactUnknown(input: Record<string, unknown>) {
|
||||
return Object.fromEntries(Object.entries(input).filter((entry) => entry[1] !== undefined))
|
||||
}
|
||||
|
||||
function string(input: unknown) {
|
||||
return typeof input === "string" && input ? input : undefined
|
||||
}
|
||||
|
||||
function bearer(input: unknown) {
|
||||
return typeof input === "string" && input ? `Bearer ${input}` : undefined
|
||||
}
|
||||
|
||||
function isRecord(input: unknown): input is Record<string, unknown> {
|
||||
return typeof input === "object" && input !== null && !Array.isArray(input)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue