Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 119 additions & 67 deletions packages/core/src/aisdk-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,87 @@ export * as AISDKNative from "./aisdk-native.js"
import { Effect, Option, Schema, Struct } from "effect"
import { Provider } from "./provider.js"

export interface Mapping {
readonly package: string
readonly settings: Provider.Settings
/** A provider, model, or variant overlay written in legacy AI SDK vocabulary. */
export interface Overlay {
readonly settings?: Provider.Settings
readonly headers?: Readonly<Record<string, string>>
readonly body?: Readonly<Record<string, unknown>>
}

export interface MapInput {
readonly packageName: string | undefined
readonly settings: Provider.Settings
readonly modelID: string
readonly providerID: string
/** Fields the rewrite touches; catalog records and migrated config entries both fit. */
export interface Target {
package?: string
settings?: Provider.Settings
headers?: Readonly<Record<string, string>>
body?: Readonly<Record<string, unknown>>
}

export interface ModelTarget extends Target {
readonly id: string
readonly modelID?: string
variants?: ReadonlyArray<{ readonly id: string } & Overlay>
}

/** Rewrites a provider and its models in place so they name native packages and native settings wherever one exists. */
export function rewrite(provider: Target, models: Iterable<ModelTarget>) {
const inherited = Provider.isAISDK(provider.package)
? nativePackage(Provider.packageName(provider.package), undefined, provider.settings)
: undefined
// Converse request settings depend on the model, so provider-level ones move onto each model first.
const converse = inherited === BEDROCK ? Struct.pick(provider.settings ?? {}, CONVERSE_KEYS) : {}
if (inherited === BEDROCK && provider.settings) provider.settings = Struct.omit(provider.settings, CONVERSE_KEYS)
for (const model of models) {
const legacy = model.package ?? provider.package
if (!Provider.isAISDK(legacy)) continue
if (model.package === undefined) model.settings = Provider.mergeOverlay(converse, model.settings)
const modelID = model.modelID ?? model.id
const native = nativePackage(
Provider.packageName(legacy),
modelID,
Provider.mergeOverlay(provider.settings, model.settings),
)
if (!native) continue
assign(model, translate(native, model, modelID))
model.variants = model.variants?.map((variant) => ({ id: variant.id, ...translate(native, variant, modelID) }))
model.package = model.package !== undefined || native !== inherited ? native : undefined
}
if (!inherited) return
assign(provider, translate(inherited, provider))
provider.package = inherited
}

function assign(target: Target, overlay: Overlay) {
target.settings = overlay.settings
target.headers = overlay.headers
target.body = overlay.body
}

/** Native package replacing a legacy AI SDK package, or undefined when the AI SDK runtime must run it. */
export function nativePackage(npm: string | undefined, modelID = "", settings?: Provider.Settings) {
switch (npm) {
case "@ai-sdk/anthropic":
case "@ai-sdk/cerebras":
case "@ai-sdk/deepinfra":
case "@ai-sdk/google":
case "@ai-sdk/google-vertex":
case "@ai-sdk/groq":
case "@ai-sdk/mistral":
case "@ai-sdk/openai":
case "@ai-sdk/togetherai":
case "@ai-sdk/xai":
case "@ai-sdk/amazon-bedrock":
return `@opencode/ai/providers/${npm.slice("@ai-sdk/".length)}`
case "@ai-sdk/amazon-bedrock/mantle":
return `@opencode/ai/providers/amazon-bedrock/mantle/${modelID.includes("gpt-oss") ? "chat" : "responses"}`
case "@ai-sdk/azure":
return `@opencode/ai/providers/azure/${settings?.useCompletionUrls === true ? "chat" : "responses"}`
case "@ai-sdk/google-vertex/anthropic":
return "@opencode/ai/providers/google-vertex/messages"
case "@ai-sdk/openai-compatible":
return "@opencode/ai/providers/openai-compatible"
case "@openrouter/ai-sdk-provider":
return "@opencode/ai/providers/openrouter"
}
}

// A wrongly typed legacy value is dropped rather than failing the whole decode.
Expand Down Expand Up @@ -75,51 +144,36 @@ const Legacy = Schema.StructWithRest(
type Legacy = typeof Legacy.Type
const decode = Schema.decodeUnknownSync(Legacy)

/** Maps a legacy AI SDK package onto the native package that replaces it. */
export function map(input: MapInput): Mapping | undefined {
const settings = decode(input.settings)
const native = mapPackage(input.packageName, input.modelID, settings)
if (!native) return
const converse = native === "@opencode/ai/providers/amazon-bedrock"
const mapped = {
...Struct.omit(settings, ["headers", "extraBody", "useCompletionUrls", ...OPENROUTER_KEYS]),
...(native === "@opencode/ai/providers/openai-compatible" ? { provider: input.providerID } : {}),
}
return {
package: native,
settings: native.startsWith("@opencode/ai/providers/amazon-bedrock") ? bedrockSettings(mapped, converse) : mapped,
...(settings.headers === undefined ? {} : { headers: settings.headers }),
...(settings.extraBody === undefined ? {} : { body: settings.extraBody }),
...(converse ? bedrockRequest(input.modelID, settings) : {}),
...(native === "@opencode/ai/providers/openrouter" ? openRouterRequest(settings) : {}),
}
const BEDROCK = "@opencode/ai/providers/amazon-bedrock"

/** Translates one legacy overlay into the spelling `native` reads. Explicit headers and body win over translated ones. */
export function translate(native: string, overlay: Overlay, modelID = ""): Overlay {
const settings = decode(overlay.settings ?? {})
const converse = native === BEDROCK
// Wrongly typed legacy values decode to undefined; drop them rather than forward them.
const mapped = Object.fromEntries(
Object.entries(Struct.omit(settings, ["headers", "extraBody", "useCompletionUrls", ...OPENROUTER_KEYS])).filter(
([, value]) => value !== undefined,
),
)
return defined({
settings: native.startsWith(BEDROCK) ? bedrockSettings(mapped, converse) : mapped,
headers: Provider.mergeHeaders(
native === "@opencode/ai/providers/openrouter" ? openRouterHeaders(settings) : settings.headers,
overlay.headers,
),
body: Provider.mergeOverlay(
Provider.mergeOverlay(settings.extraBody, converse ? bedrockBody(modelID, settings) : undefined),
overlay.body,
),
})
}

function mapPackage(packageName: string | undefined, modelID: string, settings: Legacy) {
switch (packageName) {
case "@ai-sdk/anthropic":
case "@ai-sdk/cerebras":
case "@ai-sdk/deepinfra":
case "@ai-sdk/google":
case "@ai-sdk/google-vertex":
case "@ai-sdk/groq":
case "@ai-sdk/mistral":
case "@ai-sdk/openai":
case "@ai-sdk/togetherai":
case "@ai-sdk/xai":
case "@ai-sdk/amazon-bedrock":
return `@opencode/ai/providers/${packageName.slice("@ai-sdk/".length)}`
case "@ai-sdk/amazon-bedrock/mantle":
return `@opencode/ai/providers/amazon-bedrock/mantle/${modelID.includes("gpt-oss") ? "chat" : "responses"}`
case "@ai-sdk/azure":
return `@opencode/ai/providers/azure/${settings.useCompletionUrls === true ? "chat" : "responses"}`
case "@ai-sdk/google-vertex/anthropic":
return "@opencode/ai/providers/google-vertex/messages"
case "@ai-sdk/openai-compatible":
return settings.baseURL === undefined ? undefined : "@opencode/ai/providers/openai-compatible"
case "@openrouter/ai-sdk-provider":
return "@opencode/ai/providers/openrouter"
}
// Keeps catalog records free of empty overlays.
function defined(overlay: Overlay): Overlay {
return Object.fromEntries(
Object.entries(overlay).filter(([, value]) => value !== undefined && Object.keys(value).length > 0),
)
}

// AI SDK spellings the native Bedrock packages do not read.
Expand All @@ -132,7 +186,7 @@ const BEDROCK_KEYS = [
"secretAccessKey",
"sessionToken",
]
// Request settings Converse takes in the body; translated by `bedrockRequest`.
// Request settings Converse takes in the body; translated by `bedrockBody`.
const CONVERSE_KEYS = ["additionalModelRequestFields", "reasoningConfig", "anthropicBeta", "serviceTier"]

function bedrockSettings(settings: Legacy, converse: boolean) {
Expand All @@ -158,7 +212,7 @@ function bedrockSettings(settings: Legacy, converse: boolean) {
}
}

function bedrockRequest(modelID: string, settings: Legacy): Pick<Mapping, "body"> {
function bedrockBody(modelID: string, settings: Legacy) {
const additional = settings.additionalModelRequestFields ?? {}
const reasoning = settings.reasoningConfig
const anthropic = modelID.includes("anthropic")
Expand Down Expand Up @@ -197,24 +251,22 @@ function bedrockRequest(modelID: string, settings: Legacy): Pick<Mapping, "body"
...(fields && Object.keys(fields).length > 0 ? { additionalModelRequestFields: fields } : {}),
...(settings.serviceTier === undefined ? {} : { serviceTier: { type: settings.serviceTier } }),
}
return Object.keys(body).length === 0 ? {} : { body }
return Object.keys(body).length === 0 ? undefined : body
}

// Constructor options the native OpenRouter package takes as headers, plus `compatibility`, which the
// native package would otherwise forward to the request body.
const OPENROUTER_KEYS = ["appName", "appUrl", "api_keys", "compatibility"] as const

function openRouterRequest(settings: Legacy): Pick<Mapping, "headers"> {
const headers =
Provider.mergeHeaders(
{
...(settings.appName === undefined ? {} : { "X-OpenRouter-Title": settings.appName }),
...(settings.appUrl === undefined ? {} : { "HTTP-Referer": settings.appUrl }),
...(settings.api_keys === undefined || Object.keys(settings.api_keys).length === 0
? {}
: { "X-Provider-API-Keys": JSON.stringify(settings.api_keys) }),
},
settings.headers,
) ?? {}
return Object.keys(headers).length === 0 ? {} : { headers }
function openRouterHeaders(settings: Legacy) {
return Provider.mergeHeaders(
{
...(settings.appName === undefined ? {} : { "X-OpenRouter-Title": settings.appName }),
...(settings.appUrl === undefined ? {} : { "HTTP-Referer": settings.appUrl }),
...(settings.api_keys === undefined || Object.keys(settings.api_keys).length === 0
? {}
: { "X-Provider-API-Keys": JSON.stringify(settings.api_keys) }),
},
settings.headers,
)
}
2 changes: 1 addition & 1 deletion packages/core/src/github-copilot/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ function build(id: Model.ID, remote: UsableModel, baseURL: string, previous?: Mo
providerID: Provider.ID.githubCopilot,
family: previous?.family ?? Model.Family.make(remote.capabilities.family),
name: previous?.name ?? remote.name,
package: Provider.aisdk(messages ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot"),
package: messages ? "@opencode/ai/providers/anthropic" : Provider.aisdk("@ai-sdk/github-copilot"),
settings: Provider.mergeOverlay(previous?.settings, {
baseURL: messages ? `${baseURL}/v1` : baseURL,
...(endpoint ? { endpoint } : {}),
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/modal/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ function build(id: Model.ID, remote: RemoteModel, baseURL: string, previous?: Mo
remote.interleaved === undefined
? previous?.compatibility
: (Model.compatibility(remote.interleaved) ?? previous?.compatibility),
package: Provider.aisdk("@ai-sdk/openai-compatible"),
package: "@opencode/ai/providers/openai-compatible",
settings: Provider.mergeOverlay(previous?.settings, { baseURL }),
headers: previous?.headers,
body: previous?.body,
Expand Down
42 changes: 11 additions & 31 deletions packages/core/src/model-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { LanguageModel, ProviderConfigurationError } from "@opencode/ai"
import { Auth } from "@opencode/ai/route"
import { Context, Effect, Layer, Schema, Struct } from "effect"
import { AISDK } from "./aisdk.js"
import { AISDKNative } from "./aisdk-native.js"
import { Catalog } from "./catalog.js"
import { Credential } from "./credential.js"
import { Integration } from "./integration.js"
Expand Down Expand Up @@ -195,25 +194,15 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
dependencies?: Dependencies,
) {
const resolved = prepareRuntimeModel(model, credential)
const packageName = Provider.packageName(resolved.package)
const configuration = credential?.type === "key" ? credential.configuration : undefined
const configured = { ...resolved.settings, ...credential?.metadata, ...configuration }
const mapping = Provider.isAISDK(resolved.package)
? AISDKNative.map({
packageName,
settings: configured,
modelID: resolved.modelID ?? resolved.id,
providerID: resolved.canonical ?? resolved.providerID,
})
: undefined
const native = mapping?.package ?? packageName
if (Provider.isAISDK(resolved.package) && !mapping) {
if (Provider.isAISDK(resolved.package)) {
const loadAISDK = dependencies?.loadAISDK
if (!loadAISDK) return yield* unsupported(resolved)
const settings = yield* prepareProviderSettings(
resolved,
Provider.mergeOverlay(resolved.settings, {
...nativeCredentialSettings(resolved.package ?? "", credential),
...nativeCredentialSettings(resolved.package, credential),
...credential?.metadata,
...configuration,
}) ?? {},
Expand All @@ -222,19 +211,22 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
Effect.mapError((error) => initialization(resolved, "init", error.cause)),
)
}
if (!native) return yield* unsupported(resolved)
const specifier = Provider.packageName(resolved.package)
if (!specifier) return yield* unsupported(resolved)

const specifier = native
const mapped = yield* prepareProviderSettings(resolved, Provider.nativeSettings(mapping?.settings ?? configured))
const mapped = yield* prepareProviderSettings(resolved, Provider.nativeSettings(configured))
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
Effect.mapError((error) => initialization(resolved, "load", error.cause)),
)
// The generic OpenAI-compatible package takes the catalog provider as its identity; others carry their own.
const provider =
resolved.canonical ?? (specifier === "@opencode/ai/providers/openai-compatible" ? resolved.providerID : undefined)
const settings = {
...(credential ? Struct.omit(mapped, ["accessToken", "apiKey", "authToken"]) : mapped),
...(resolved.canonical === undefined ? {} : { provider: resolved.canonical }),
...(provider === undefined ? {} : { provider }),
...nativeCredentialSettings(specifier, credential),
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
body: Provider.mergeOverlay(mapping?.body, resolved.body),
headers: resolved.headers,
body: resolved.body,
}
return yield* Effect.try({
try: () => {
Expand Down Expand Up @@ -424,18 +416,6 @@ function hasConfiguredAuth(model: Info) {
function usesAPIKeyAuth(packageName: string | undefined) {
const name = Provider.packageName(packageName)
return (
name === "@ai-sdk/openai" ||
name === "@ai-sdk/anthropic" ||
name === "@ai-sdk/cerebras" ||
name === "@ai-sdk/deepinfra" ||
name === "@ai-sdk/openai-compatible" ||
name === "@ai-sdk/google" ||
name === "@ai-sdk/groq" ||
name === "@ai-sdk/mistral" ||
name === "@ai-sdk/togetherai" ||
name === "@ai-sdk/xai" ||
name === "@openrouter/ai-sdk-provider" ||
name === "@ai-sdk/azure" ||
name === "@opencode/ai/providers/openai" ||
name?.startsWith("@opencode/ai/providers/openai/") === true ||
name === "@opencode/ai/providers/anthropic" ||
Expand Down
Loading
Loading