feat(core): load native provider packages

This commit is contained in:
Shoubhit Dash 2026-06-25 20:24:53 +05:30
commit cd3885910d
66 changed files with 588 additions and 693 deletions

View file

@ -198,23 +198,23 @@ export const locationLayer = Layer.effect(
const key = `${model.providerID}/${model.id}/${model.modelID ?? model.id}/${JSON.stringify(model.settings)}`
const existing = languages.get(key)
if (existing) return existing
if (!model.aisdk)
if (!ProviderV2.isAISDK(model.package))
return yield* new InitError({
providerID: model.providerID,
cause: new Error(`Unsupported package ${model.package}`),
})
const options = prepareOptions(model, model.package ?? "")
const packageName = ProviderV2.packageName(model.package) ?? ""
const options = prepareOptions(model, packageName)
const sdkKey = JSON.stringify({
providerID: model.providerID,
package: model.package,
package: packageName,
settings: model.settings,
options,
})
const sdk =
sdks.get(sdkKey) ??
(yield* service.runSDK({ model, package: model.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,

View file

@ -7,6 +7,7 @@ import { EventV2 } from "./event"
import { Policy } from "./policy"
import { State } from "./state"
import { Integration } from "./integration"
import { ProviderOverlay } from "./provider-overlay"
export type ProviderRecord = {
provider: ProviderV2.MutableInfo
@ -79,10 +80,9 @@ export const layer = Layer.effect(
return ModelV2.Info.make({
...model,
package: model.package ?? provider.package,
aisdk: model.package === undefined ? (model.aisdk ?? provider.aisdk) : model.aisdk,
settings: merge(provider.settings, model.settings),
headers: headers(provider.headers, model.headers),
body: merge(provider.body, model.body),
settings: ProviderOverlay.merge(provider.settings, model.settings),
headers: ProviderOverlay.headers(provider.headers, model.headers),
body: ProviderOverlay.merge(provider.body, model.body),
})
}
@ -270,43 +270,6 @@ export const layer = Layer.effect(
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
function merge(
base: Readonly<Record<string, unknown>> | undefined,
overlay: Readonly<Record<string, unknown>> | undefined,
): Record<string, unknown> | undefined {
if (base === undefined) return overlay && { ...overlay }
if (overlay === undefined) return { ...base }
return Object.fromEntries(
new Set([...Object.keys(base), ...Object.keys(overlay)]).values().map((key) => {
const left = base[key]
const right = overlay[key]
if (right === undefined) return [key, left]
if (plain(left) && plain(right)) return [key, merge(left, right)]
return [key, right]
}),
)
}
function plain(input: unknown): input is Readonly<Record<string, unknown>> {
if (typeof input !== "object" || input === null || Array.isArray(input)) return false
const prototype = Object.getPrototypeOf(input)
return prototype === Object.prototype || prototype === null
}
function headers(
base: Readonly<Record<string, string>> | undefined,
overlay: Readonly<Record<string, string>> | undefined,
) {
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 locationLayer = layer.pipe(
Layer.provideMerge(Integration.locationLayer),
Layer.provideMerge(Policy.locationLayer),

View file

@ -4,6 +4,7 @@ import { define } from "../../plugin/internal"
import { Effect } from "effect"
import { Config } from "../../config"
import { ModelV2 } from "../../model"
import { ProviderOverlay } from "../../provider-overlay"
import { ProviderV2 } from "../../provider"
export const Plugin = define({
@ -53,10 +54,10 @@ export const Plugin = define({
catalog.provider.update(providerID, (provider) => {
if (item.name !== undefined) provider.name = item.name
if (item.package !== undefined) provider.package = item.package
if (item.aisdk !== undefined) provider.aisdk = item.aisdk
if (item.settings !== undefined) provider.settings = { ...provider.settings, ...item.settings }
if (item.headers !== undefined) provider.headers = { ...provider.headers, ...item.headers }
if (item.body !== undefined) provider.body = { ...provider.body, ...item.body }
if (item.settings !== undefined)
provider.settings = ProviderOverlay.merge(provider.settings, item.settings)
if (item.headers !== undefined) provider.headers = ProviderOverlay.headers(provider.headers, item.headers)
if (item.body !== undefined) provider.body = ProviderOverlay.merge(provider.body, item.body)
})
for (const [id, config] of Object.entries(item.models ?? {})) {
catalog.model.update(providerID, id, (model) => {
@ -64,10 +65,10 @@ export const Plugin = define({
if (config.name !== undefined) model.name = config.name
if (config.modelID !== undefined) model.modelID = config.modelID
if (config.package !== undefined) model.package = config.package
if (config.aisdk !== undefined) model.aisdk = config.aisdk
if (config.settings !== undefined) model.settings = { ...model.settings, ...config.settings }
if (config.headers !== undefined) model.headers = { ...model.headers, ...config.headers }
if (config.body !== undefined) model.body = { ...model.body, ...config.body }
if (config.settings !== undefined)
model.settings = ProviderOverlay.merge(model.settings, config.settings)
if (config.headers !== undefined) model.headers = ProviderOverlay.headers(model.headers, config.headers)
if (config.body !== undefined) model.body = ProviderOverlay.merge(model.body, config.body)
if (config.capabilities !== undefined) {
model.capabilities = {
tools: config.capabilities.tools,
@ -86,9 +87,10 @@ export const Plugin = define({
model.variants.push(existing)
}
if (variant.settings !== undefined)
existing.settings = { ...existing.settings, ...variant.settings }
if (variant.headers !== undefined) existing.headers = { ...existing.headers, ...variant.headers }
if (variant.body !== undefined) existing.body = { ...existing.body, ...variant.body }
existing.settings = ProviderOverlay.merge(existing.settings, variant.settings)
if (variant.headers !== undefined)
existing.headers = ProviderOverlay.headers(existing.headers, variant.headers)
if (variant.body !== undefined) existing.body = ProviderOverlay.merge(existing.body, variant.body)
}
}
if (config.cost !== undefined) {

View file

@ -40,7 +40,6 @@ class Model extends Schema.Class<Model>("ConfigV2.Model")({
family: ModelV2.Family.pipe(Schema.optional),
name: Schema.String.pipe(Schema.optional),
package: Schema.String.pipe(Schema.optional),
aisdk: Schema.Literal(true).pipe(Schema.optional),
...Overlays,
capabilities: ModelV2.Capabilities.pipe(Schema.optional),
variants: Schema.Struct({
@ -56,7 +55,6 @@ export class Info extends Schema.Class<Info>("ConfigV2.Provider")({
name: Schema.String.pipe(Schema.optional),
env: Schema.String.pipe(Schema.Array, Schema.optional),
package: Schema.String.pipe(Schema.optional),
aisdk: Schema.Literal(true).pipe(Schema.optional),
...Overlays,
models: Schema.Record(Schema.String, Model).pipe(Schema.optional),
}) {}

View file

@ -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
@ -23,7 +23,7 @@ export type Ref = typeof Ref.Type
export const Info = Model.Info
export type Info = Model.Info
export type MutableInfo = Types.DeepMutable<Info>
export type MutableInfo = DeepMutable<Info>
export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } {
const [providerID, ...modelID] = input.split("/")

View file

@ -75,8 +75,7 @@ export const ModelsDevPlugin = define({
const providerID = ProviderV2.ID.make(item.id)
catalog.provider.update(providerID, (provider) => {
provider.name = item.name
provider.package = item.npm ?? ""
provider.aisdk = item.npm ? true : undefined
provider.package = item.npm ? ProviderV2.aisdk(item.npm) : ""
provider.settings = item.api ? { ...provider.settings, baseURL: item.api } : provider.settings
})
@ -85,8 +84,7 @@ export const ModelsDevPlugin = define({
catalog.model.update(providerID, modelID, (draft) => {
draft.name = model.name
draft.family = model.family ? ModelV2.Family.make(model.family) : undefined
draft.package = model.provider?.npm
draft.aisdk = model.provider?.npm ? true : undefined
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,

View file

@ -65,8 +65,8 @@ export const AmazonBedrockPlugin = define({
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (!item.provider.aisdk) continue
if (item.provider.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 (typeof provider.settings?.endpoint !== "string") return
// The AI SDK expects a base URL, but users configure Bedrock private/VPC
@ -113,7 +113,10 @@ export const AmazonBedrockPlugin = define({
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return
if (evt.model.aisdk && evt.model.package === "@ai-sdk/amazon-bedrock/mantle") {
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
}

View file

@ -1,5 +1,6 @@
import { Effect } from "effect"
import { define } from "../internal"
import { ProviderV2 } from "../../provider"
export const AnthropicPlugin = define({
id: "anthropic",
@ -7,8 +8,8 @@ export const AnthropicPlugin = define({
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (!item.provider.aisdk) continue
if (item.provider.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.headers = {
...provider.headers,

View file

@ -16,8 +16,8 @@ export const AzurePlugin = define({
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (!item.provider.aisdk) continue
if (item.provider.package !== "@ai-sdk/azure") continue
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
@ -35,7 +35,7 @@ export const AzurePlugin = define({
if (
!evt.options.resourceName &&
!evt.options.baseURL &&
(!evt.model.aisdk || typeof evt.model.settings?.baseURL !== "string")
(!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",
@ -67,8 +67,8 @@ 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.aisdk) continue
if (item.provider.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.settings = {

View file

@ -1,5 +1,6 @@
import { Effect } from "effect"
import { define } from "../internal"
import { ProviderV2 } from "../../provider"
export const CerebrasPlugin = define({
id: "cerebras",
@ -7,8 +8,8 @@ export const CerebrasPlugin = define({
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (!item.provider.aisdk) continue
if (item.provider.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.headers = { ...provider.headers, "X-Cerebras-3rd-Party-Integration": "opencode" }
})

View file

@ -14,7 +14,7 @@ export const CloudflareWorkersAIPlugin = define({
const item = evt.provider.get(providerID)
if (!item) return
evt.provider.update(item.provider.id, (provider) => {
if (!provider.aisdk) return
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) }
@ -54,8 +54,11 @@ function workersEndpoint(accountId: string) {
return `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`
}
function hasWorkersEndpoint(model: { readonly aisdk?: true; readonly settings?: Readonly<Record<string, unknown>> }) {
return model.aisdk && typeof model.settings?.baseURL === "string"
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>) {

View file

@ -60,12 +60,12 @@ export const GoogleVertexPlugin = define({
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (!item.provider.aisdk) continue
if (!ProviderV2.isAISDK(item.provider.package)) continue
if (
item.provider.package !== "@ai-sdk/google-vertex" &&
ProviderV2.packageName(item.provider.package) !== "@ai-sdk/google-vertex" &&
!(
item.provider.id === ProviderV2.ID.googleVertex &&
item.provider.package.includes("@ai-sdk/openai-compatible")
ProviderV2.packageName(item.provider.package)?.includes("@ai-sdk/openai-compatible")
)
)
continue
@ -79,7 +79,7 @@ export const GoogleVertexPlugin = define({
...(typeof provider.settings?.baseURL === "string"
? { baseURL: replaceVertexVars(provider.settings.baseURL, project, location) }
: {}),
...(provider.package.includes("@ai-sdk/openai-compatible")
...(ProviderV2.packageName(provider.package)?.includes("@ai-sdk/openai-compatible")
? { fetch: authFetch(provider.settings?.fetch) }
: {}),
}
@ -121,8 +121,8 @@ export const GoogleVertexAnthropicPlugin = define({
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (!item.provider.aisdk) continue
if (item.provider.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.settings?.project ??
process.env.GOOGLE_CLOUD_PROJECT ??

View file

@ -1,5 +1,6 @@
import { Effect } from "effect"
import { define } from "../internal"
import { ProviderV2 } from "../../provider"
export const KiloPlugin = define({
id: "kilo",
@ -7,8 +8,8 @@ export const KiloPlugin = define({
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (!item.provider.aisdk) continue
if (item.provider.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.settings?.baseURL !== "https://api.kilo.ai/api/gateway") continue
evt.provider.update(item.provider.id, (provider) => {
provider.headers = { ...provider.headers, "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" }

View file

@ -1,6 +1,7 @@
import { Effect } from "effect"
import { define } from "../internal"
import { Integration } from "../../integration"
import { ProviderV2 } from "../../provider"
export const LLMGatewayPlugin = define({
id: "llmgateway",
@ -10,8 +11,8 @@ export const LLMGatewayPlugin = define({
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.disabled) continue
if (!item.provider.aisdk) continue
if (item.provider.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.settings?.baseURL !== "https://api.llmgateway.io/v1") continue
if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue
evt.provider.update(item.provider.id, (provider) => {

View file

@ -1,5 +1,6 @@
import { Effect } from "effect"
import { define } from "../internal"
import { ProviderV2 } from "../../provider"
export const NvidiaPlugin = define({
id: "nvidia",
@ -7,8 +8,8 @@ export const NvidiaPlugin = define({
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (!item.provider.aisdk) continue
if (item.provider.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.settings?.baseURL !== "https://integrate.api.nvidia.com/v1") continue
evt.provider.update(item.provider.id, (provider) => {
provider.headers = {

View file

@ -156,8 +156,8 @@ export const OpenAIPlugin = define({
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (!item.provider.aisdk) continue
if (item.provider.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

View file

@ -111,8 +111,7 @@ 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.package = item.npm ?? ""
provider.aisdk = item.npm ? true : undefined
provider.package = item.npm ? ProviderV2.aisdk(item.npm) : ""
provider.settings = {
...provider.settings,
...withoutCredentials(item.options),
@ -132,8 +131,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
if (config.name !== undefined) model.name = config.name
if (config.id !== undefined) model.modelID = config.id
if (config.provider !== undefined) {
model.package = config.provider.npm
model.aisdk = config.provider.npm ? true : undefined
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

View file

@ -1,5 +1,6 @@
import { Effect } from "effect"
import { ModelV2 } from "../../model"
import { ProviderV2 } from "../../provider"
import { define } from "../internal"
export const OpenRouterPlugin = define({
@ -8,8 +9,8 @@ export const OpenRouterPlugin = define({
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (!item.provider.aisdk) continue
if (item.provider.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.headers = { ...provider.headers, "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" }
})

View file

@ -1,5 +1,6 @@
import { Effect } from "effect"
import { define } from "../internal"
import { ProviderV2 } from "../../provider"
export const VercelPlugin = define({
id: "vercel",
@ -7,8 +8,8 @@ export const VercelPlugin = define({
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (!item.provider.aisdk) continue
if (item.provider.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.headers = { ...provider.headers, "http-referer": "https://opencode.ai/", "x-title": "opencode" }
})

View file

@ -1,5 +1,6 @@
import { Effect } from "effect"
import { define } from "../internal"
import { ProviderV2 } from "../../provider"
export const ZenmuxPlugin = define({
id: "zenmux",
@ -7,8 +8,8 @@ export const ZenmuxPlugin = define({
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (!item.provider.aisdk) continue
if (item.provider.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.settings?.baseURL !== "https://zenmux.ai/api/v1") continue
evt.provider.update(item.provider.id, (provider) => {
provider.headers = {

View file

@ -2,6 +2,7 @@ export * as VariantPlugin from "./variant"
import { Effect } from "effect"
import { ModelV2 } from "../model"
import { ProviderV2 } from "../provider"
import { define } from "./internal"
export const Plugin = define({
@ -29,9 +30,10 @@ export const Plugin = define({
export function generate(
model: ModelV2.Info,
provider?: { readonly package: string; readonly aisdk?: true },
provider?: { readonly package: string },
): NonNullable<ModelV2.Info["variants"]> {
if (!(model.aisdk ?? provider?.aisdk) || (model.package ?? provider?.package) !== "@ai-sdk/openai-compatible") return []
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) => ({

View file

@ -0,0 +1,38 @@
export * as ProviderOverlay from "./provider-overlay"
export function merge(
base: Readonly<Record<string, unknown>> | undefined,
overlay: Readonly<Record<string, unknown>> | undefined,
): Record<string, unknown> | undefined {
if (base === undefined) return overlay && { ...overlay }
if (overlay === undefined) return { ...base }
return Object.fromEntries(
new Set([...Object.keys(base), ...Object.keys(overlay)]).values().map((key) => {
const left = base[key]
const right = overlay[key]
if (right === undefined) return [key, left]
if (plain(left) && plain(right)) return [key, merge(left, right)]
return [key, right]
}),
)
}
export function headers(
base: Readonly<Record<string, string>> | undefined,
overlay: Readonly<Record<string, string>> | undefined,
) {
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(),
)
}
function plain(input: unknown): input is Readonly<Record<string, unknown>> {
if (typeof input !== "object" || input === null || Array.isArray(input)) return false
const prototype = Object.getPrototypeOf(input)
return prototype === Object.prototype || prototype === null
}

View file

@ -0,0 +1,75 @@
export * as ProviderPackage from "./provider-package"
import { Effect, Option, Schema } from "effect"
import { pathToFileURL } from "url"
import type { Model, ProviderPackageDefinition, ProviderPackageSettings } from "@opencode-ai/llm"
import { Npm } from "./npm"
const cache = new Map<string, Promise<unknown>>()
export class LoadError extends Schema.TaggedErrorClass<LoadError>()("ProviderPackage.LoadError", {
package: Schema.String,
cause: Schema.Defect(),
}) {}
export const load = Effect.fn("ProviderPackage.load")(function* (specifier: string) {
const npm = Option.getOrUndefined(yield* Effect.serviceOption(Npm.Service))
const resolved =
specifier.startsWith("file://") || specifier.startsWith("@opencode-ai/llm/")
? specifier
: yield* Effect.sync(() => {
try {
return import.meta.resolve(specifier)
} catch {
return undefined
}
})
if (resolved) return yield* importProviderPackage(specifier, resolved)
if (!npm) {
return yield* new LoadError({
package: specifier,
cause: new Error(`Provider package ${specifier} is not installed`),
})
}
const installed = yield* npm
.add(packageName(specifier))
.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* importProviderPackage(specifier, entrypoint)
})
const importProviderPackage = Effect.fn("ProviderPackage.import")(function* (specifier: string, entrypoint: string) {
const module = yield* Effect.tryPromise({
try: () => {
const existing = cache.get(entrypoint)
if (existing) return existing
const loaded = import(entrypoint)
cache.set(entrypoint, loaded)
return loaded
},
catch: (cause) => new LoadError({ package: specifier, cause }),
})
if (!isProviderPackage(module)) {
return yield* new LoadError({
package: specifier,
cause: new Error(`Provider package ${specifier} does not export model(id, settings)`),
})
}
return module
})
export const make = (module: ProviderPackageDefinition, modelID: string, settings: ProviderPackageSettings): Model =>
module.model(modelID, settings)
function isProviderPackage(input: unknown): input is ProviderPackageDefinition {
return typeof input === "object" && input !== null && "model" in input && typeof input.model === "function"
}
function packageName(specifier: string) {
const parts = specifier.split("/")
if (specifier.startsWith("@")) return parts.slice(0, 2).join("/")
return parts[0]
}

View file

@ -1,15 +1,21 @@
export * as ProviderV2 from "./provider"
import { Types } from "effect"
import { Provider } from "@opencode-ai/schema/provider"
import type { DeepMutable } from "./schema"
export const ID = Provider.ID
export type ID = typeof ID.Type
export const AISDK_PREFIX = "aisdk:"
export const isAISDK = (packageName: string | undefined) => packageName?.startsWith(AISDK_PREFIX) ?? false
export const aisdk = (packageName: string) => (isAISDK(packageName) ? packageName : `${AISDK_PREFIX}${packageName}`)
export const packageName = (packageName: string | undefined) =>
isAISDK(packageName) ? packageName!.slice(AISDK_PREFIX.length) : packageName
export const Request = Provider.Request
export type Request = Provider.Request
export const Info = Provider.Info
export type Info = Provider.Info
export type MutableInfo = Types.DeepMutable<Info>
export type MutableInfo = DeepMutable<Info>

View file

@ -12,6 +12,8 @@ import { Credential } from "../../credential"
import { Integration } from "../../integration"
import { ModelV2 } from "../../model"
import { ProviderV2 } from "../../provider"
import { ProviderPackage } from "../../provider-package"
import { ProviderOverlay } from "../../provider-overlay"
import { SessionSchema } from "../schema"
export class ModelNotSelectedError extends Schema.TaggedErrorClass<ModelNotSelectedError>()(
@ -117,9 +119,9 @@ const withVariant = (
return Effect.succeed(
variant
? produce(model, (draft) => {
draft.settings = merge(draft.settings, variant.settings)
draft.headers = { ...draft.headers, ...variant.headers }
draft.body = merge(draft.body, variant.body)
draft.settings = ProviderOverlay.merge(draft.settings, variant.settings)
draft.headers = ProviderOverlay.headers(draft.headers, variant.headers)
draft.body = ProviderOverlay.merge(draft.body, variant.body)
})
: model,
)
@ -133,17 +135,18 @@ export const fromCatalogModel = (
credential?.metadata === undefined
? model
: produce(model, (draft) => {
draft.settings = { ...draft.settings, ...credential.metadata }
draft.settings = ProviderOverlay.merge(draft.settings, credential.metadata)
})
const key = apiKey(resolved, credential)
if (resolved.aisdk && resolved.package === "@ai-sdk/openai") {
const packageName = ProviderV2.packageName(resolved.package)
if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
return Effect.succeed(
withDefaults(resolved, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: resolved.modelID ?? resolved.id }),
)
}
if (resolved.aisdk && resolved.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) })
@ -151,8 +154,8 @@ export const fromCatalogModel = (
)
}
if (
resolved.aisdk &&
resolved.package === "@ai-sdk/openai-compatible" &&
ProviderV2.isAISDK(resolved.package) &&
packageName === "@ai-sdk/openai-compatible" &&
typeof resolved.settings?.baseURL === "string"
) {
return Effect.succeed(
@ -161,11 +164,44 @@ export const fromCatalogModel = (
.model({ id: resolved.modelID ?? resolved.id }),
)
}
if (!ProviderV2.isAISDK(resolved.package) && resolved.package) {
const specifier = resolved.package
return Effect.gen(function* () {
const module = yield* ProviderPackage.load(specifier).pipe(
Effect.mapError(
() =>
new UnsupportedPackageError({
providerID: resolved.providerID,
modelID: resolved.id,
package: specifier,
}),
),
)
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: () => ProviderPackage.make(module, resolved.modelID ?? resolved.id, settings),
catch: () =>
new UnsupportedPackageError({
providerID: resolved.providerID,
modelID: resolved.id,
package: specifier,
}),
})
})
}
return Effect.fail(
new UnsupportedPackageError({
providerID: resolved.providerID,
modelID: resolved.id,
package: resolved.aisdk ? `aisdk:${resolved.package}` : (resolved.package ?? "unknown"),
package: resolved.package ?? "unknown",
}),
)
}
@ -174,10 +210,12 @@ export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, creden
withVariant(model, session.model?.variant).pipe(Effect.flatMap((model) => fromCatalogModel(model, credential)))
export const supported = (model: ModelV2.Info) =>
model.aisdk === true &&
(model.package === "@ai-sdk/openai" ||
model.package === "@ai-sdk/anthropic" ||
(model.package === "@ai-sdk/openai-compatible" && typeof model.settings?.baseURL === "string"))
Boolean(model.package) &&
(!ProviderV2.isAISDK(model.package) ||
ProviderV2.packageName(model.package) === "@ai-sdk/openai" ||
ProviderV2.packageName(model.package) === "@ai-sdk/anthropic" ||
(ProviderV2.packageName(model.package) === "@ai-sdk/openai-compatible" &&
typeof model.settings?.baseURL === "string"))
/** Resolves models from the catalog belonging to the current Location runtime. */
export const locationLayer = Layer.effect(
@ -215,26 +253,3 @@ export const locationLayer = Layer.effect(
})
}),
)
function merge(
base: Readonly<Record<string, unknown>> | undefined,
overlay: Readonly<Record<string, unknown>> | undefined,
): Record<string, unknown> | undefined {
if (base === undefined) return overlay && { ...overlay }
if (overlay === undefined) return { ...base }
return Object.fromEntries(
Array.from(new Set([...Object.keys(base), ...Object.keys(overlay)])).map((key) => {
const left = base[key]
const right = overlay[key]
if (right === undefined) return [key, left]
if (plain(left) && plain(right)) return [key, merge(left, right)]
return [key, right]
}),
)
}
function plain(input: unknown): input is Readonly<Record<string, unknown>> {
if (typeof input !== "object" || input === null || Array.isArray(input)) return false
const prototype = Object.getPrototypeOf(input)
return prototype === Object.prototype || prototype === null
}

View file

@ -6,6 +6,7 @@ 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",
@ -173,8 +174,7 @@ function migrateProvider(info: ConfigProviderV1.Info) {
return {
name: info.name,
env: info.env,
package: info.npm,
aisdk: info.npm ? (true as const) : undefined,
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,
@ -213,8 +213,7 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: st
modelID: info.id,
family: info.family,
name: info.name,
package: info.provider?.npm,
aisdk: info.provider?.npm ? (true as const) : undefined,
package: info.provider?.npm ? ProviderV2.aisdk(info.provider.npm) : undefined,
settings: info.provider?.api ? { ...settings, baseURL: info.provider.api } : settings,
capabilities,
headers: info.headers,