fix(core): centralize native provider mapping (#43468)

This commit is contained in:
Aiden Cline
2026-08-19 12:32:53 -05:00
committed by GitHub
parent 9292bfbe49
commit fbf45f2384
7 changed files with 263 additions and 116 deletions
@@ -19,6 +19,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL: string
readonly provider?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
@@ -75,6 +76,7 @@ export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsIn
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID)
export const baseten = define(profiles.baseten)
+55
View File
@@ -14,11 +14,22 @@ export interface MapInput {
readonly packageName: string | undefined
readonly settings: Readonly<Record<string, unknown>>
readonly modelID: string
readonly providerID: string
}
export function map(input: MapInput): Mapping | undefined {
const baseSettings = mapBaseSettings(input.settings)
switch (input.packageName) {
case "@ai-sdk/anthropic":
return {
package: "@opencode-ai/ai/providers/anthropic",
settings: {
...baseSettings,
...mapAPIKey(input.settings),
...(typeof input.settings.authToken === "string" ? { authToken: input.settings.authToken } : {}),
...mapAnthropicOptions(input.settings),
},
}
case "@ai-sdk/amazon-bedrock":
return {
package: "@opencode-ai/ai/providers/amazon-bedrock",
@@ -88,6 +99,35 @@ export function map(input: MapInput): Mapping | undefined {
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
case "@ai-sdk/openai":
return {
package: "@opencode-ai/ai/providers/openai",
settings: {
...baseSettings,
...mapAPIKey(input.settings),
...(typeof input.settings.organization === "string" ? { organization: input.settings.organization } : {}),
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
...(isStringRecord(input.settings.queryParams) ? { queryParams: input.settings.queryParams } : {}),
...mapProviderOptions(input.settings, "openai", [
"apiKey",
"baseURL",
"organization",
"project",
"queryParams",
]),
},
}
case "@ai-sdk/openai-compatible":
if (typeof input.settings.baseURL !== "string") return
return {
package: "@opencode-ai/ai/providers/openai-compatible",
settings: {
...baseSettings,
...mapAPIKey(input.settings),
provider: input.providerID,
...mapProviderOptions(input.settings, "openai", ["apiKey", "baseURL"]),
},
}
case "@openrouter/ai-sdk-provider":
return mapOpenRouter(input.settings, baseSettings)
case "@ai-sdk/xai":
@@ -102,6 +142,20 @@ export function map(input: MapInput): Mapping | undefined {
}
}
function mapAnthropicOptions(settings: Readonly<Record<string, unknown>>) {
return mapProviderOptions(settings, "anthropic", ["apiKey", "authToken", "baseURL"])
}
function mapProviderOptions(
settings: Readonly<Record<string, unknown>>,
key: string,
excluded: ReadonlyArray<string>,
) {
const options = Object.fromEntries(Object.entries(settings).filter(([name]) => !excluded.includes(name)))
if (Object.keys(options).length === 0) return {}
return { providerOptions: { [key]: options } }
}
function mapBedrockMantle(input: MapInput, baseSettings: Readonly<Record<string, unknown>>): Mapping | undefined {
const settings = input.settings
const chat = input.modelID === "openai.gpt-oss-safeguard-20b" || input.modelID === "openai.gpt-oss-safeguard-120b"
@@ -256,6 +310,7 @@ function mapGoogleOptions(settings: Readonly<Record<string, unknown>>, extra: Re
}
const options = {
...(typeof settings.cachedContent === "string" ? { cachedContent: settings.cachedContent } : {}),
...(isStringRecord(settings.labels) ? { labels: settings.labels } : {}),
...(Array.isArray(settings.safetySettings) ? { safetySettings: settings.safetySettings } : {}),
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
...(Object.keys(thinkingConfig).length > 0 ? { thinkingConfig } : {}),
+2 -85
View File
@@ -2,13 +2,7 @@ export * as ModelResolver from "./model-resolver.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { LanguageModel } from "@opencode-ai/ai"
// ast-grep-ignore: no-star-import
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
// ast-grep-ignore: no-star-import
import * as OpenAICompatibleChat from "@opencode-ai/ai/protocols/openai-compatible-chat"
// ast-grep-ignore: no-star-import
import * as OpenAIResponses from "@opencode-ai/ai/protocols/openai-responses"
import { Auth, type AnyRoute } from "@opencode-ai/ai/route"
import { Auth } from "@opencode-ai/ai/route"
import { Context, Effect, Layer, Schema } from "effect"
import { produce } from "immer"
import { AISDK } from "./aisdk.js"
@@ -83,47 +77,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelResolver") {}
const apiKey = (model: Info, credential?: Credential.Value) => {
if (credential?.type === "key") return Auth.value(credential.key)
if (credential?.type === "oauth") return Auth.value(credential.access)
const value = model.settings?.apiKey
if (typeof value === "string") return Auth.value(value)
return undefined
}
const withDefaults = (model: Info, route: AnyRoute) =>
route.with({
provider: model.providerID,
endpoint: typeof model.settings?.baseURL === "string" ? { baseURL: model.settings.baseURL } : undefined,
headers: providerHeaders(model),
providerOptions: providerOptions(model),
http: model.body === undefined ? undefined : { body: model.body },
limits: { context: model.limit.context, input: model.limit.input, output: model.limit.output },
})
const providerHeaders = (model: Info) => {
const packageName = Provider.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 Provider.mergeHeaders(generated.size === 0 ? undefined : Object.fromEntries(generated), model.headers)
}
const providerOptions = (model: Info): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
if (!Provider.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 = Provider.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 }
return undefined
}
export const withVariant = (
model: Info,
variantID: VariantID | undefined,
@@ -170,37 +123,14 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
) {
const resolved = prepareRuntimeModel(model, credential)
const packageName = Provider.packageName(resolved.package)
const key = apiKey(resolved, credential)
const configuration = credential?.type === "key" ? credential.configuration : undefined
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
const runtime = yield* prepareProviderModel(resolved)
return withDefaults(runtime, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
}
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") {
const runtime = yield* prepareProviderModel(resolved)
return withDefaults(runtime, AnthropicMessages.route)
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
}
if (
Provider.isAISDK(resolved.package) &&
packageName === "@ai-sdk/openai-compatible" &&
typeof resolved.settings?.baseURL === "string"
) {
const runtime = yield* prepareProviderModel(resolved)
return withDefaults(runtime, OpenAICompatibleChat.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
}
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.providerID,
})
: undefined
const native = mapping?.package ?? resolved.package
@@ -267,19 +197,6 @@ function validateProviderVariables(
return failure ? Effect.fail(failure) : Effect.succeed(resolved)
}
function prepareProviderModel(model: Info): Effect.Effect<Info, UnresolvedProviderVariablesError> {
if (!model.settings) return Effect.succeed(model)
return prepareProviderSettings(model, model.settings).pipe(
Effect.map((settings) =>
settings === model.settings
? model
: produce(model, (draft) => {
draft.settings = settings
}),
),
)
}
function prepareProviderSettings(
model: Info,
settings: Readonly<Record<string, unknown>>,
@@ -190,13 +190,6 @@ export const OpenAIPlugin = define({
})
yield* load()
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (!Provider.isAISDK(item.provider.package)) continue
if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai") continue
evt.provider.update(item.provider.id, (provider) => {
provider.package = "@opencode-ai/ai/providers/openai"
})
}
if (!chatgpt) return
const item = evt.provider.get(Provider.ID.openai)
if (!item) return
+83 -1
View File
@@ -2,9 +2,91 @@ import { describe, expect, test } from "bun:test"
import { AISDKNative } from "@opencode-ai/core/aisdk-native"
const map = (packageName: string, settings: Readonly<Record<string, unknown>>, modelID = "test-model") =>
AISDKNative.map({ packageName, settings, modelID })
AISDKNative.map({ packageName, settings, modelID, providerID: "test-provider" })
describe("AISDKNative", () => {
test("maps OpenAI-family packages and request options to native providers", () => {
expect(
map("@ai-sdk/openai", {
apiKey: "secret",
baseURL: "https://api.meta.ai/v1",
organization: "org",
reasoningEffort: "xhigh",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
instructions: "Follow the repository instructions.",
truncation: "auto",
}),
).toEqual({
package: "@opencode-ai/ai/providers/openai",
settings: {
apiKey: "secret",
baseURL: "https://api.meta.ai/v1",
organization: "org",
providerOptions: {
openai: {
reasoningEffort: "xhigh",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
instructions: "Follow the repository instructions.",
truncation: "auto",
},
},
},
})
expect(map("@ai-sdk/openai-compatible", { baseURL: "https://example.com/v1", reasoningEffort: "high" })).toEqual({
package: "@opencode-ai/ai/providers/openai-compatible",
settings: {
baseURL: "https://example.com/v1",
provider: "test-provider",
providerOptions: { openai: { reasoningEffort: "high" } },
},
})
})
test("maps Anthropic settings and request options to the native provider", () => {
expect(
map("@ai-sdk/anthropic", {
authToken: "token",
baseURL: "https://anthropic.example/v1",
thinking: { type: "adaptive", display: "summarized" },
effort: "high",
}),
).toEqual({
package: "@opencode-ai/ai/providers/anthropic",
settings: {
authToken: "token",
baseURL: "https://anthropic.example/v1",
providerOptions: {
anthropic: {
thinking: { type: "adaptive", display: "summarized" },
effort: "high",
},
},
},
})
})
test("maps Google Vertex settings to the native provider", () => {
expect(
map("@ai-sdk/google-vertex", {
project: "project",
location: "us-central1",
labels: { environment: "test" },
thinkingConfig: { thinkingLevel: "high" },
}),
).toEqual({
package: "@opencode-ai/ai/providers/google-vertex",
settings: {
project: "project",
location: "us-central1",
providerOptions: {
gemini: { labels: { environment: "test" }, thinkingConfig: { thinkingLevel: "high" } },
},
},
})
})
test("maps both models.dev Bedrock packages to native providers", () => {
expect(map("@ai-sdk/amazon-bedrock", { region: "us-east-1" })).toEqual({
package: "@opencode-ai/ai/providers/amazon-bedrock",
+118 -20
View File
@@ -219,7 +219,10 @@ describe("ModelResolver", () => {
settings: { baseURL: "https://openai.example/v1" },
limit: { context: 100, input: 80, output: 20 },
})
const resolved = yield* ModelResolver.fromCatalogModel(catalog)
const resolved = yield* ModelResolver.fromCatalogModel(
catalog,
Credential.Key.make({ type: "key", key: "secret" }),
)
expect(catalog.id).toBe(ID.make("test-model"))
expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
@@ -254,22 +257,24 @@ describe("ModelResolver", () => {
)
it.effect("treats an empty configured API key as omitted", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { apiKey: "", baseURL: "https://openai.example/v1" },
}),
)
const headers = yield* resolved.route.auth.apply({
request: LLM.request({ model: resolved, prompt: "Hello" }),
method: "POST",
url: "https://openai.example/v1/responses",
body: "{}",
headers: Headers.empty,
})
withEnv({ OPENAI_API_KEY: "environment-key" }, () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { apiKey: "", baseURL: "https://openai.example/v1" },
}),
)
const headers = yield* resolved.route.auth.apply({
request: LLM.request({ model: resolved, prompt: "Hello" }),
method: "POST",
url: "https://openai.example/v1/responses",
body: "{}",
headers: Headers.empty,
})
expect(headers.authorization).toBeUndefined()
}),
expect(headers.authorization).toBe("Bearer environment-key")
}),
),
)
it.effect("uses no native API-key auth for an explicitly enabled provider without credentials", () => {
@@ -457,8 +462,12 @@ describe("ModelResolver", () => {
settings: { baseURL: "https://openai.example/v1" },
variants: [
{
id: VariantID.make("high"),
settings: { reasoningEffort: "high" },
id: VariantID.make("xhigh"),
settings: {
reasoningEffort: "xhigh",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
headers: { "x-variant": "high" },
body: {
store: false,
@@ -468,7 +477,7 @@ describe("ModelResolver", () => {
},
],
})
const resolved = yield* ModelResolver.resolveModel(catalog, VariantID.make("high"))
const resolved = yield* ModelResolver.resolveModel(catalog, VariantID.make("xhigh"))
expect(resolved.route.defaults.headers).toMatchObject({ "x-test": "header", "x-variant": "high" })
expect(resolved.route.defaults.http?.body).toEqual({
@@ -478,7 +487,17 @@ describe("ModelResolver", () => {
temperature: 0.2,
})
expect(resolved.route.defaults.providerOptions).toEqual({
openai: { store: false, reasoningEffort: "high" },
openai: {
store: false,
reasoningEffort: "xhigh",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
})
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
expect(prepared.body).toMatchObject({
include: ["reasoning.encrypted_content"],
reasoning: { effort: "xhigh", summary: "auto" },
})
}),
)
@@ -815,12 +834,46 @@ describe("ModelResolver", () => {
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/openai")))
const packages = [
[
"@ai-sdk/openai",
"@opencode-ai/ai/providers/openai",
{
reasoningEffort: "xhigh",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
{
openai: {
reasoningEffort: "xhigh",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
},
],
[
"@ai-sdk/anthropic",
"@opencode-ai/ai/providers/anthropic",
{ thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
{ anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" } },
],
[
"@ai-sdk/openai-compatible",
"@opencode-ai/ai/providers/openai-compatible",
{ reasoningEffort: "high" },
{ openai: { reasoningEffort: "high" } },
],
[
"@ai-sdk/google",
"@opencode-ai/ai/providers/google",
{ thinkingConfig: { thinkingLevel: "high" } },
{ gemini: { thinkingConfig: { thinkingLevel: "high" } } },
],
[
"@ai-sdk/google-vertex",
"@opencode-ai/ai/providers/google-vertex",
{ thinkingConfig: { thinkingLevel: "high" } },
{ gemini: { thinkingConfig: { thinkingLevel: "high" } } },
],
[
"@openrouter/ai-sdk-provider",
"@opencode-ai/ai/providers/openrouter",
@@ -869,6 +922,51 @@ describe("ModelResolver", () => {
}),
)
it.effect("never loads the AI SDK for packages with native implementations", () =>
Effect.gen(function* () {
const packages = [
["@ai-sdk/anthropic", "@opencode-ai/ai/providers/anthropic", "api-model"],
["@ai-sdk/amazon-bedrock", "@opencode-ai/ai/providers/amazon-bedrock", "api-model"],
[
"@ai-sdk/amazon-bedrock/mantle",
"@opencode-ai/ai/providers/amazon-bedrock/mantle/responses",
"openai.gpt-oss-120b",
],
["@ai-sdk/azure", "@opencode-ai/ai/providers/azure/responses", "api-model"],
["@ai-sdk/google", "@opencode-ai/ai/providers/google", "api-model"],
["@ai-sdk/google-vertex", "@opencode-ai/ai/providers/google-vertex", "api-model"],
[
"@ai-sdk/google-vertex/anthropic",
"@opencode-ai/ai/providers/google-vertex/messages",
"claude-sonnet-4-6",
],
["@ai-sdk/openai", "@opencode-ai/ai/providers/openai", "api-model"],
["@ai-sdk/openai-compatible", "@opencode-ai/ai/providers/openai-compatible", "api-model"],
["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "api-model"],
["@ai-sdk/xai", "@opencode-ai/ai/providers/xai", "api-model"],
] as const
yield* Effect.forEach(packages, ([catalogPackage, nativePackage, modelID]) =>
ModelResolver.fromCatalogModel(
model(Provider.aisdk(catalogPackage), {
modelID,
settings: { baseURL: "https://provider.example/v1", region: "us-east-1" },
}),
undefined,
{
loadPackage: (specifier) => {
expect(specifier).toBe(nativePackage)
return Effect.succeed({
model: (id) => LanguageModel.make({ id, provider: "native-provider", route: OpenAIChat.route }),
})
},
loadAISDK: () => Effect.die(`AI SDK loader called for ${catalogPackage}`),
},
),
)
}),
)
it.effect("routes Vertex Anthropic catalog models through native Messages", () =>
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/openai")))
@@ -137,7 +137,7 @@ describe("OpenAIPlugin", () => {
const proxy = yield* request(Provider.ID.openai, "https://proxy.example/v1?region=us")
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
expect(provider.package).toBe(Provider.aisdk("@ai-sdk/openai"))
expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" })
expect(provider.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" })
expect(direct.baseURL).toBe("https://chatgpt.com/backend-api/codex")
@@ -147,7 +147,7 @@ describe("OpenAIPlugin", () => {
expect(proxy.baseURL).toBe("https://proxy.example/v1?region=us")
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
expect(eligible.package).toBe(Provider.aisdk("@ai-sdk/openai"))
expect(eligible.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" })
expect(eligible.cost).toEqual([])
expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
@@ -194,7 +194,7 @@ describe("OpenAIPlugin", () => {
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(model.package).toBe("@opencode-ai/ai/providers/openai")
expect(model.package).toBe(Provider.aisdk("@ai-sdk/openai"))
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(direct.headers).not.toHaveProperty("originator")