From fbf45f2384254897e65407ce2ea0e55e4e6d1284 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:32:53 -0500 Subject: [PATCH] fix(core): centralize native provider mapping (#43468) --- .../ai/src/providers/openai-compatible.ts | 2 + packages/core/src/aisdk-native.ts | 55 +++++++ packages/core/src/model-resolver.ts | 87 +---------- packages/core/src/plugin/provider/openai.ts | 7 - packages/core/test/aisdk-native.test.ts | 84 ++++++++++- packages/core/test/model-resolver.test.ts | 138 +++++++++++++++--- .../core/test/plugin/provider-openai.test.ts | 6 +- 7 files changed, 263 insertions(+), 116 deletions(-) diff --git a/packages/ai/src/providers/openai-compatible.ts b/packages/ai/src/providers/openai-compatible.ts index cf0b5a8eba..4da44b8c3e 100644 --- a/packages/ai/src/providers/openai-compatible.ts +++ b/packages/ai/src/providers/openai-compatible.ts @@ -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 & @@ -75,6 +76,7 @@ export const model: ProviderPackage.Definition> 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>) { + return mapProviderOptions(settings, "anthropic", ["apiKey", "authToken", "baseURL"]) +} + +function mapProviderOptions( + settings: Readonly>, + key: string, + excluded: ReadonlyArray, +) { + 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>): 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>, 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 } : {}), diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index d8c6c063bf..a8840a35fc 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -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()("@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() - 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 { - 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>, diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 8d492350d6..ecffea693e 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -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 diff --git a/packages/core/test/aisdk-native.test.ts b/packages/core/test/aisdk-native.test.ts index 3dc475924d..25539557c9 100644 --- a/packages/core/test/aisdk-native.test.ts +++ b/packages/core/test/aisdk-native.test.ts @@ -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>, 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", diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index e29e29195e..cb2a943ae0 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -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"))) diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 18c0c8e36f..687c026177 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -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")