From 48ea84109ebf79c57e12eb0e8c65262891bc6e70 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 19 Aug 2026 16:59:51 -0400 Subject: [PATCH] refactor(ai): move credential lowering into providers --- packages/ai/AGENTS.md | 15 +- packages/ai/README.md | 68 ++- packages/ai/example/tutorial.ts | 13 +- packages/ai/src/index.ts | 3 + packages/ai/src/provider-package.ts | 47 +- .../ai/src/providers/amazon-bedrock-mantle.ts | 38 +- packages/ai/src/providers/amazon-bedrock.ts | 28 +- .../ai/src/providers/anthropic-compatible.ts | 31 +- packages/ai/src/providers/anthropic.ts | 33 +- packages/ai/src/providers/azure.ts | 33 +- .../ai/src/providers/google-vertex-chat.ts | 23 +- .../src/providers/google-vertex-messages.ts | 26 +- .../src/providers/google-vertex-responses.ts | 26 +- .../ai/src/providers/google-vertex-shared.ts | 5 +- packages/ai/src/providers/google-vertex.ts | 33 +- packages/ai/src/providers/google.ts | 26 +- .../providers/openai-compatible-responses.ts | 21 +- .../ai/src/providers/openai-compatible.ts | 18 +- packages/ai/src/providers/openai-options.ts | 27 +- packages/ai/src/providers/openai.ts | 70 ++- packages/ai/src/providers/openrouter.ts | 19 +- packages/ai/src/providers/xai.ts | 16 +- packages/ai/test/auth-options.types.ts | 72 ++- packages/ai/test/provider-package.test.ts | 418 +++++++++++++----- packages/core/src/aisdk-native.ts | 88 ++-- packages/core/src/model-resolver.ts | 50 +-- packages/core/src/provider.ts | 2 +- packages/core/test/aisdk-native.test.ts | 12 +- packages/core/test/model-resolver.test.ts | 230 +++++++--- 29 files changed, 967 insertions(+), 524 deletions(-) diff --git a/packages/ai/AGENTS.md b/packages/ai/AGENTS.md index 95963a2b597b..a2328e87be61 100644 --- a/packages/ai/AGENTS.md +++ b/packages/ai/AGENTS.md @@ -71,7 +71,7 @@ export const route = Route.make({ }) ``` -Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `LanguageModel` values carry only model id, provider id, and the configured route value. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `AIError`s. +Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `LanguageModel` values carry model identity and the configured route; low-level callers may also attach model-specific defaults and compatibility metadata. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `AIError`s. The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit. @@ -88,7 +88,7 @@ For providers where the URL is derived from typed inputs (Azure resource name, B Provider-facing APIs are configured facades over route values. Endpoint/auth/resource/API-version setup happens before model selection, and model selectors accept only a model or deployment id: ```ts -const openai = OpenAI.configure({ apiKey, baseURL }) +const openai = OpenAI.configure({ apiKey, baseURL, store: false }) const model = openai.responses("gpt-4o-mini") const azure = Azure.configure({ resourceName, apiKey, apiVersion: "v1" }) @@ -108,17 +108,22 @@ Keep provider facades small and explicit: - Resolve `apiKey` → `Auth` with `AuthOptions.bearer(options, "_API_KEY")` (it honors an explicit `auth` override and falls back to `Auth.config(envVar)` so missing keys surface a typed `Authentication` error rather than a runtime crash). - Use separate top-level facades for products with different required setup, such as `CloudflareAIGateway` and `CloudflareWorkersAI`. +Provider facades and model-derived `LLMRequest.providerOptions` are provider-specific, so expose typed native options flat at those boundaries. Provider package settings keep deployment configuration separate from their typed `providerOptions` field, except facades such as OpenAI whose settings are already unambiguous when flat. The selected `LanguageModel` carries request-option typing; the route decodes the flat runtime record. Keep provider metadata namespaced because replay may contain metadata from multiple layers. + `Provider.make(...)` remains available for simple static provider definitions, but new built-in providers should prefer plain configured facades unless a helper removes real duplication without adding runtime behavior. ### Provider Package Entrypoints -Catalog-selected native providers use package-like export paths from `@opencode-ai/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are serializable provider configuration plus common `headers`, `body`, and `limits` overlays. +Catalog-selected native providers use package-like export paths from `@opencode-ai/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model({ id, settings, credential, defaults })`. Core selects and refreshes the optional `key | oauth` credential; the provider package interprets it as route authentication. Serializable provider settings remain separate from common `headers`, `body`, and `limits` defaults. ```ts import { model } from "@opencode-ai/ai/providers/openai/responses" -const selected = model("gpt-5", { - apiKey, +const selected = model({ + id: "gpt-5", + settings: {}, + credential: { type: "key", value: apiKey }, + defaults: {}, }) ``` diff --git a/packages/ai/README.md b/packages/ai/README.md index abc049889a84..72307b407932 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -305,19 +305,26 @@ const gateway = CloudflareAIGateway.configure({ }).model("workers-ai/@cf/meta/llama-3.1-8b-instruct") ``` -Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, Z.ai, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint. +Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, OpenRouter, xAI, Z.ai, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint. GitHub Copilot remains a Core-owned AI SDK integration rather than an AI-package provider. ### Package-like entrypoints -Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers`, `body`, and `limits` overlays. +Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model({ id, settings, credential, defaults })` contract. Core selects and refreshes the optional `key | oauth` credential, while the provider package interprets it as route authentication. Serializable provider settings remain separate from common `headers`, `body`, and `limits` defaults. ```ts import { model } from "@opencode-ai/ai/providers/openai/responses" -const selected = model("gpt-5", { - apiKey: process.env.OPENAI_API_KEY, - headers: { "x-application": "opencode" }, - limits: { context: 200_000, output: 64_000 }, +const apiKey = process.env.OPENAI_API_KEY +if (!apiKey) throw new Error("OPENAI_API_KEY is required") + +const selected = model({ + id: "gpt-5", + settings: {}, + credential: { type: "key", value: apiKey }, + defaults: { + headers: { "x-application": "opencode" }, + limits: { context: 200_000, output: 64_000 }, + }, }) ``` @@ -341,30 +348,57 @@ Tuned Vertex Gemini deployments use model ids shaped like `endpoints/1234567890` ```ts import { model } from "@opencode-ai/ai/providers/google-vertex/gemini" -model("gemini-3.5-flash", { project: "my-project", location: "global" }) +model({ + id: "gemini-3.5-flash", + settings: { project: "my-project", location: "global" }, + defaults: {}, +}) ``` ```ts import { model } from "@opencode-ai/ai/providers/google-vertex/chat" -model("deepseek-ai/deepseek-v3.2-maas", { project: "my-project", location: "global" }) +model({ + id: "deepseek-ai/deepseek-v3.2-maas", + settings: { project: "my-project", location: "global" }, + defaults: {}, +}) ``` ```ts import { model } from "@opencode-ai/ai/providers/google-vertex/responses" -model("xai/grok-4.20-reasoning", { project: "my-project", location: "global" }) +model({ + id: "xai/grok-4.20-reasoning", + settings: { project: "my-project", location: "global" }, + defaults: {}, +}) ``` ```ts import { model } from "@opencode-ai/ai/providers/google-vertex/messages" -model("claude-sonnet-4-6", { project: "my-project", location: "global" }) +model({ + id: "claude-sonnet-4-6", + settings: { project: "my-project", location: "global" }, + defaults: {}, +}) ``` -Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path. +Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path. The entrypoints listed above implement that contract and are covered by `test/provider-package.test.ts`. + +## How OpenCode uses this package -Other provider exports listed above remain direct facades until they explicitly implement the package-like contract. Exporting a provider facade does not implicitly make it a catalog-loadable provider package. +OpenCode does not call provider facades directly from the CLI or server. Core owns the integration: + +1. `packages/core/src/model-resolver.ts` resolves catalog metadata and an active integration credential into a `LanguageModel`. Native package entrypoints expose `model({ id, settings, credential, defaults })`; catalog packages without a native mapping fall back through Core's AI SDK adapter. +2. `packages/core/src/session/model-request.ts` lowers Session state, instructions, tools, and plugin hooks into one canonical `LLMRequest`. +3. `packages/core/src/session/runner/llm.ts` calls the yielded `LLMClient.Service` once per physical attempt and persists provider-neutral `LLMEvent`s. +4. Core owns retries, continuation, compaction, permissions, durable tool execution, and Session history. None of that orchestration belongs in this package. + +Title generation, compaction, standalone generation, and transient Session generation also build `LLMRequest`s and use the same `LLMClient.Service`. Core's `AISDK` adapter wraps remaining Vercel AI SDK models in executable routes so native and fallback providers present the same request and event model to callers. + +This separation is intentional: `@opencode-ai/ai` owns one model call, provider protocols, and transport; Core owns the durable agent runtime. ## Provider options & HTTP overlays @@ -377,6 +411,16 @@ Request options in order of stability: Route/provider defaults are overridden by request-level values for each axis. +Provider-specific facades accept their own options directly because the provider is already known: + +```ts +const model = OpenAI.configure({ + apiKey, + store: false, + reasoningEffort: "high", +}).responses("gpt-5") +``` + The selected model supplies the provider-specific option type, so per-request overrides stay flat while the canonical runtime request remains provider-neutral: ```ts diff --git a/packages/ai/example/tutorial.ts b/packages/ai/example/tutorial.ts index 4589785145dc..a8bce6aaed05 100644 --- a/packages/ai/example/tutorial.ts +++ b/packages/ai/example/tutorial.ts @@ -17,13 +17,12 @@ import { OpenAI } from "@opencode-ai/ai/providers" const apiKey = Config.redacted("OPENAI_API_KEY") // 1. Pick a model. The provider helper records provider identity, protocol -// choice, capabilities, deployment options, authentication, and defaults. +// choice, deployment options, authentication, and defaults. Catalog capabilities +// remain application-owned and are not part of LanguageModel. const model = OpenAI.configure({ apiKey, generation: { maxTokens: 160 }, - providerOptions: { - store: false, - }, + store: false, }).model("gpt-4o-mini") // 2. Build a provider-neutral request. This is useful when reusing one request @@ -74,8 +73,8 @@ const streamText = LLM.stream(request).pipe( Stream.runDrain, ) -// 5. Tools are typed with Effect Schema. Provider turns remain explicit: -// advertise definitions on the request, stream one turn, dispatch local calls, +// 5. Tools are typed with Effect Schema. Model calls remain explicit: +// advertise definitions on the request, stream one call, dispatch local calls, // then persist/build follow-up history in the enclosing product flow. const tools = { get_weather: Tool.make({ @@ -102,7 +101,7 @@ const streamWithTools = Effect.gen(function* () { console.log("tool result", event.name, dispatched.result) // A durable agent would persist these messages before starting another - // raw model turn. This tutorial keeps the boundary visible instead. + // model call. This tutorial keeps the boundary visible instead. const followUp = LLMRequest.update(request, { messages: [ ...request.messages, diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 24907f1e825e..91e250d86a52 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -37,6 +37,9 @@ export type { LanguageModelOptions as ProviderLanguageModelOptions, } from "./provider.js" export type { + Credential as ProviderPackageCredential, + Defaults as ProviderPackageDefaults, Definition as ProviderPackageDefinition, + ModelInput as ProviderPackageModelInput, Settings as ProviderPackageSettings, } from "./provider-package.js" diff --git a/packages/ai/src/provider-package.ts b/packages/ai/src/provider-package.ts index 359d6ca48046..8bced789a6ee 100644 --- a/packages/ai/src/provider-package.ts +++ b/packages/ai/src/provider-package.ts @@ -1,7 +1,21 @@ +import { Auth } from "./route/auth.js" +import type { AuthOverride, RequiredApiKeyAuth } from "./route/auth-options.js" import type { LanguageModel, ProviderOptions } from "./schema/index.js" -export interface Settings extends Readonly> { - readonly baseURL?: string +export interface Settings {} + +export type Credential = + | { + readonly type: "key" + readonly value: string + readonly configuration?: Readonly> + } + | { + readonly type: "oauth" + readonly accessToken: string + } + +export interface Defaults { readonly headers?: Readonly> readonly body?: Readonly> readonly limits?: { @@ -11,11 +25,38 @@ export interface Settings extends Readonly> { } } +export interface ModelInput { + readonly id: string + readonly settings: ProviderSettings + readonly credential?: Credential + readonly defaults: Defaults +} + +export const routeDefaults = (input: Defaults) => ({ + headers: input.headers, + http: input.body === undefined ? undefined : { body: input.body }, + limits: input.limits, +}) + +export const bearerCredentialValue = (input: Credential) => (input.type === "key" ? input.value : input.accessToken) + +export const bearerAuthOption = (input: Credential): AuthOverride => ({ + auth: Auth.bearer(bearerCredentialValue(input)), +}) + +export const apiKeyOrBearerAuthOption = ( + input: Credential, + competingKeyHeader: string, +): RequiredApiKeyAuth | AuthOverride => + input.type === "key" + ? { apiKey: input.value } + : { auth: Auth.remove(competingKeyHeader).andThen(Auth.bearer(input.accessToken)) } + export interface Definition< ProviderSettings extends Settings = Settings, Options extends ProviderOptions = ProviderOptions, > { - readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel + readonly model: (input: ModelInput) => LanguageModel } export * as ProviderPackage from "./provider-package.js" diff --git a/packages/ai/src/providers/amazon-bedrock-mantle.ts b/packages/ai/src/providers/amazon-bedrock-mantle.ts index 30738c269849..f84bf3b82b2f 100644 --- a/packages/ai/src/providers/amazon-bedrock-mantle.ts +++ b/packages/ai/src/providers/amazon-bedrock-mantle.ts @@ -1,6 +1,6 @@ import { Auth } from "../route/auth.js" import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js" -import type { ProviderPackage } from "../provider-package.js" +import { ProviderPackage } from "../provider-package.js" import { OpenAIChat } from "../protocols/openai-chat.js" import { OpenAIResponses } from "../protocols/openai-responses.js" import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js" @@ -79,29 +79,27 @@ export const configure = (input: Config = {}) => { export const provider = configure() -const config = (settings: Settings): Config => { - if (settings.auth === "bearer" && settings.apiKey === undefined) +const config = (input: ProviderPackage.ModelInput): Config => { + if (!input.credential && input.settings.auth === "bearer" && input.settings.apiKey === undefined) throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey") - if (settings.auth === "sigv4" && settings.apiKey !== undefined) + if (!input.credential && input.settings.auth === "sigv4" && input.settings.apiKey !== undefined) throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey") return { - apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey, - baseURL: settings.baseURL, - credentials: settings.credentials, - headers: settings.headers === undefined ? undefined : { ...settings.headers }, - http: settings.body === undefined ? undefined : { body: { ...settings.body } }, - limits: settings.limits, - providerOptions: settings.providerOptions, - region: settings.region, + ...ProviderPackage.routeDefaults(input.defaults), + apiKey: input.credential + ? ProviderPackage.bearerCredentialValue(input.credential) + : input.settings.auth === "sigv4" + ? undefined + : input.settings.apiKey, + baseURL: input.settings.baseURL, + credentials: input.settings.credentials, + providerOptions: input.settings.providerOptions, + region: input.settings.region, } } -export const chatModel: ProviderPackage.Definition["model"] = ( - modelID, - settings, -) => configure(config(settings)).chat(modelID) -export const responsesModel: ProviderPackage.Definition["model"] = ( - modelID, - settings, -) => configure(config(settings)).responses(modelID) +export const chatModel: ProviderPackage.Definition["model"] = (input) => + configure(config(input)).chat(input.id) +export const responsesModel: ProviderPackage.Definition["model"] = (input) => + configure(config(input)).responses(input.id) export const model = chatModel diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index 7cbd4d765629..95d6635e8689 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -1,6 +1,6 @@ import type { RouteDefaultsInput } from "../route/client.js" import { Auth } from "../route/auth.js" -import type { ProviderPackage } from "../provider-package.js" +import { ProviderPackage } from "../provider-package.js" import { ProviderID, type ModelID } from "../schema/index.js" import * as BedrockConverse from "../protocols/bedrock-converse.js" import type { BedrockCredentials } from "../protocols/bedrock-converse.js" @@ -50,19 +50,21 @@ export const configure = (input: Config = {}) => { } export const provider = configure() -export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { - if (settings.auth === "bearer" && settings.apiKey === undefined) +export const model: ProviderPackage.Definition["model"] = (input) => { + if (!input.credential && input.settings.auth === "bearer" && input.settings.apiKey === undefined) throw new Error("Amazon Bedrock bearer auth requires apiKey") - if (settings.auth === "sigv4" && settings.apiKey !== undefined) + if (!input.credential && input.settings.auth === "sigv4" && input.settings.apiKey !== undefined) throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey") return configure({ - apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey, - baseURL: settings.baseURL, - credentials: settings.credentials, - generation: settings.topP === undefined ? undefined : { topP: settings.topP }, - headers: settings.headers === undefined ? undefined : { ...settings.headers }, - http: settings.body === undefined ? undefined : { body: { ...settings.body } }, - limits: settings.limits, - region: settings.region, - }).model(modelID) + ...ProviderPackage.routeDefaults(input.defaults), + apiKey: input.credential + ? ProviderPackage.bearerCredentialValue(input.credential) + : input.settings.auth === "sigv4" + ? undefined + : input.settings.apiKey, + baseURL: input.settings.baseURL, + credentials: input.settings.credentials, + generation: input.settings.topP === undefined ? undefined : { topP: input.settings.topP }, + region: input.settings.region, + }).model(input.id) } diff --git a/packages/ai/src/providers/anthropic-compatible.ts b/packages/ai/src/providers/anthropic-compatible.ts index 28743b1af5fb..b34383256e04 100644 --- a/packages/ai/src/providers/anthropic-compatible.ts +++ b/packages/ai/src/providers/anthropic-compatible.ts @@ -1,4 +1,4 @@ -import type { ProviderPackage } from "../provider-package.js" +import { ProviderPackage } from "../provider-package.js" import { AnthropicMessages } from "../protocols/anthropic-messages.js" import { Auth } from "../route/auth.js" import type { ProviderAuthOption } from "../route/auth-options.js" @@ -32,7 +32,9 @@ export const routes = [AnthropicMessages.route] const auth = (input: ProviderAuthOption<"optional">) => { if ("auth" in input && input.auth) return input.auth - return Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey").pipe(Auth.header("x-api-key")) + return Auth.remove("authorization").andThen( + Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey").pipe(Auth.header("x-api-key")), + ) } export const configure = (input: Config) => { @@ -57,21 +59,20 @@ export const provider = { configure, } -export const model: ProviderPackage.Definition["model"] = ( - modelID, - settings, -) => { - if (settings.apiKey !== undefined && settings.authToken !== undefined) +export const model: ProviderPackage.Definition["model"] = (input) => { + if (!input.credential && input.settings.apiKey !== undefined && input.settings.authToken !== undefined) throw new Error("Anthropic-compatible apiKey cannot be combined with authToken") return configure({ - ...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }), - baseURL: settings.baseURL, - headers: settings.headers === undefined ? undefined : { ...settings.headers }, - http: settings.body === undefined ? undefined : { body: { ...settings.body } }, - limits: settings.limits, - provider: settings.provider, - providerOptions: settings.providerOptions, - }).model(modelID) + ...ProviderPackage.routeDefaults(input.defaults), + ...(input.credential + ? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "x-api-key") + : input.settings.authToken === undefined + ? { apiKey: input.settings.apiKey } + : { auth: Auth.bearer(input.settings.authToken) }), + baseURL: input.settings.baseURL, + provider: input.settings.provider, + providerOptions: input.settings.providerOptions, + }).model(input.id) } export * as AnthropicCompatible from "./anthropic-compatible.js" diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index cb5e22f82a2e..8e3de6599a49 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -1,7 +1,7 @@ import type { RouteDefaultsInput } from "../route/client.js" import { Auth } from "../route/auth.js" import type { ProviderAuthOption } from "../route/auth-options.js" -import type { ProviderPackage } from "../provider-package.js" +import { ProviderPackage } from "../provider-package.js" import { ProviderID, type ModelID } from "../schema/index.js" import { AnthropicMessages } from "../protocols/anthropic-messages.js" import { AnthropicCompatible } from "./anthropic-compatible.js" @@ -31,9 +31,11 @@ export type Settings = ProviderPackage.Settings & const auth = (options: ProviderAuthOption<"optional">) => { if ("auth" in options && options.auth) return options.auth - return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey") - .orElse(Auth.config("ANTHROPIC_API_KEY")) - .pipe(Auth.header("x-api-key")) + return Auth.remove("authorization").andThen( + Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey") + .orElse(Auth.config("ANTHROPIC_API_KEY")) + .pipe(Auth.header("x-api-key")), + ) } export const configure = (input: Config = {}) => { @@ -52,18 +54,17 @@ export const configure = (input: Config = {}) => { } export const provider = configure() -export const model: ProviderPackage.Definition["model"] = ( - modelID, - settings, -) => { - if (settings.apiKey !== undefined && settings.authToken !== undefined) +export const model: ProviderPackage.Definition["model"] = (input) => { + if (!input.credential && input.settings.apiKey !== undefined && input.settings.authToken !== undefined) throw new Error("Anthropic apiKey cannot be combined with authToken") return configure({ - ...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }), - baseURL: settings.baseURL, - headers: settings.headers === undefined ? undefined : { ...settings.headers }, - http: settings.body === undefined ? undefined : { body: { ...settings.body } }, - limits: settings.limits, - providerOptions: settings.providerOptions, - }).model(modelID) + ...ProviderPackage.routeDefaults(input.defaults), + ...(input.credential + ? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "x-api-key") + : input.settings.authToken === undefined + ? { apiKey: input.settings.apiKey } + : { auth: Auth.bearer(input.settings.authToken) }), + baseURL: input.settings.baseURL, + providerOptions: input.settings.providerOptions, + }).model(input.id) } diff --git a/packages/ai/src/providers/azure.ts b/packages/ai/src/providers/azure.ts index 94d5edb4c851..87e8268376f4 100644 --- a/packages/ai/src/providers/azure.ts +++ b/packages/ai/src/providers/azure.ts @@ -1,7 +1,7 @@ import { Auth } from "../route/auth.js" import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js" import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js" -import type { ProviderPackage } from "../provider-package.js" +import { ProviderPackage } from "../provider-package.js" import { ProviderID, type ModelID } from "../schema/index.js" import * as OpenAIChat from "../protocols/openai-chat.js" import * as OpenAIResponses from "../protocols/openai-responses.js" @@ -120,28 +120,29 @@ export const provider = { configure, } -const config = (settings: Settings): Config => { +const config = (input: ProviderPackage.ModelInput): Config => { + const settings = input.settings + const configuration = input.credential?.type === "key" ? input.credential.configuration : undefined + const baseURL = settings.baseURL ?? (typeof configuration?.baseURL === "string" ? configuration.baseURL : undefined) + const resourceName = + settings.resourceName ?? (typeof configuration?.resourceName === "string" ? configuration.resourceName : undefined) const common = { - apiKey: settings.apiKey, + ...ProviderPackage.routeDefaults(input.defaults), + ...(input.credential + ? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "api-key") + : { apiKey: settings.apiKey }), apiVersion: settings.apiVersion, - headers: settings.headers === undefined ? undefined : { ...settings.headers }, - http: settings.body === undefined ? undefined : { body: { ...settings.body } }, - limits: settings.limits, providerOptions: settings.providerOptions, queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, useDeploymentBasedUrls: settings.useDeploymentBasedUrls, } - if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL } - if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName } + if (baseURL !== undefined) return { ...common, baseURL } + if (resourceName !== undefined) return { ...common, resourceName } throw new Error("Azure requires resourceName or baseURL") } -export const responsesModel: ProviderPackage.Definition["model"] = ( - modelID, - settings, -) => configure(config(settings)).responses(modelID) -export const chatModel: ProviderPackage.Definition["model"] = ( - modelID, - settings, -) => configure(config(settings)).chat(modelID) +export const responsesModel: ProviderPackage.Definition["model"] = (input) => + configure(config(input)).responses(input.id) +export const chatModel: ProviderPackage.Definition["model"] = (input) => + configure(config(input)).chat(input.id) export const model = responsesModel diff --git a/packages/ai/src/providers/google-vertex-chat.ts b/packages/ai/src/providers/google-vertex-chat.ts index f634cf5107d1..027606802860 100644 --- a/packages/ai/src/providers/google-vertex-chat.ts +++ b/packages/ai/src/providers/google-vertex-chat.ts @@ -1,4 +1,4 @@ -import type { ProviderPackage } from "../provider-package.js" +import { ProviderPackage } from "../provider-package.js" import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js" import type { RouteDefaultsInput } from "../route/client.js" import { ProviderID, type ModelID } from "../schema/index.js" @@ -68,16 +68,15 @@ export const provider = { configure, } -export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { - if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys") +export const model: ProviderPackage.Definition["model"] = (input) => { + if (input.credential?.type === "key" || (!input.credential && input.settings.apiKey !== undefined)) + throw new Error("Google Vertex Chat does not support API keys") return configure({ - accessToken: settings.accessToken, - baseURL: settings.baseURL, - headers: settings.headers === undefined ? undefined : { ...settings.headers }, - http: settings.body === undefined ? undefined : { body: { ...settings.body } }, - limits: settings.limits, - location: settings.location, - project: settings.project, - providerOptions: settings.providerOptions, - }).model(modelID) + ...ProviderPackage.routeDefaults(input.defaults), + accessToken: input.credential?.type === "oauth" ? input.credential.accessToken : input.settings.accessToken, + baseURL: input.settings.baseURL, + location: input.settings.location, + project: input.settings.project, + providerOptions: input.settings.providerOptions, + }).model(input.id) } diff --git a/packages/ai/src/providers/google-vertex-messages.ts b/packages/ai/src/providers/google-vertex-messages.ts index b8f878d02774..e493e1cbff09 100644 --- a/packages/ai/src/providers/google-vertex-messages.ts +++ b/packages/ai/src/providers/google-vertex-messages.ts @@ -1,5 +1,5 @@ import { Effect, Schema, Struct } from "effect" -import type { ProviderPackage } from "../provider-package.js" +import { ProviderPackage } from "../provider-package.js" import { AnthropicMessages } from "../protocols/anthropic-messages.js" import { Auth } from "../route/auth.js" import { Route, type RouteDefaultsInput } from "../route/client.js" @@ -100,19 +100,15 @@ export const provider = { configure, } -export const model: ProviderPackage.Definition["model"] = ( - modelID, - settings, -) => { - if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys") +export const model: ProviderPackage.Definition["model"] = (input) => { + if (input.credential?.type === "key" || (!input.credential && input.settings.apiKey !== undefined)) + throw new Error("Google Vertex Messages does not support API keys") return configure({ - accessToken: settings.accessToken, - baseURL: settings.baseURL, - headers: settings.headers === undefined ? undefined : { ...settings.headers }, - http: settings.body === undefined ? undefined : { body: { ...settings.body } }, - limits: settings.limits, - location: settings.location, - project: settings.project, - providerOptions: settings.providerOptions, - }).model(modelID) + ...ProviderPackage.routeDefaults(input.defaults), + accessToken: input.credential?.type === "oauth" ? input.credential.accessToken : input.settings.accessToken, + baseURL: input.settings.baseURL, + location: input.settings.location, + project: input.settings.project, + providerOptions: input.settings.providerOptions, + }).model(input.id) } diff --git a/packages/ai/src/providers/google-vertex-responses.ts b/packages/ai/src/providers/google-vertex-responses.ts index 7b44542a4752..bb82ecd06983 100644 --- a/packages/ai/src/providers/google-vertex-responses.ts +++ b/packages/ai/src/providers/google-vertex-responses.ts @@ -1,4 +1,4 @@ -import type { ProviderPackage } from "../provider-package.js" +import { ProviderPackage } from "../provider-package.js" import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses.js" import type { RouteDefaultsInput } from "../route/client.js" import { ProviderID, type ModelID } from "../schema/index.js" @@ -70,19 +70,15 @@ export const provider = { configure, } -export const model: ProviderPackage.Definition["model"] = ( - modelID, - settings, -) => { - if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys") +export const model: ProviderPackage.Definition["model"] = (input) => { + if (input.credential?.type === "key" || (!input.credential && input.settings.apiKey !== undefined)) + throw new Error("Google Vertex Responses does not support API keys") return configure({ - accessToken: settings.accessToken, - baseURL: settings.baseURL, - headers: settings.headers === undefined ? undefined : { ...settings.headers }, - http: settings.body === undefined ? undefined : { body: { ...settings.body } }, - limits: settings.limits, - location: settings.location, - project: settings.project, - providerOptions: settings.providerOptions, - }).model(modelID) + ...ProviderPackage.routeDefaults(input.defaults), + accessToken: input.credential?.type === "oauth" ? input.credential.accessToken : input.settings.accessToken, + baseURL: input.settings.baseURL, + location: input.settings.location, + project: input.settings.project, + providerOptions: input.settings.providerOptions, + }).model(input.id) } diff --git a/packages/ai/src/providers/google-vertex-shared.ts b/packages/ai/src/providers/google-vertex-shared.ts index 9267228af15e..c1496c55ab84 100644 --- a/packages/ai/src/providers/google-vertex-shared.ts +++ b/packages/ai/src/providers/google-vertex-shared.ts @@ -69,9 +69,8 @@ const adc = (project?: string) => { export const oauth = (input: OAuthOptions, project?: string) => { if (input.accessToken !== undefined && input.auth !== undefined) throw new Error("Google Vertex accessToken cannot be combined with auth") - if (input.auth) return input.auth - if (input.accessToken !== undefined) return Auth.bearer(input.accessToken) - return adc(project) + const auth = input.auth ?? (input.accessToken !== undefined ? Auth.bearer(input.accessToken) : adc(project)) + return Auth.remove("x-goog-api-key").andThen(auth) } export * as GoogleVertexShared from "./google-vertex-shared.js" diff --git a/packages/ai/src/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts index 353194d768c0..866bd335ca2f 100644 --- a/packages/ai/src/providers/google-vertex.ts +++ b/packages/ai/src/providers/google-vertex.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import type { ProviderPackage } from "../provider-package.js" +import { ProviderPackage } from "../provider-package.js" import { Gemini } from "../protocols/gemini.js" import { ProviderShared } from "../protocols/shared.js" import { Auth } from "../route/auth.js" @@ -94,7 +94,10 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => { return route.with({ ...rest, endpoint: { baseURL: endpoint }, - auth: apiKey === undefined ? GoogleVertexShared.oauth(input, project) : Auth.header("x-goog-api-key", apiKey), + auth: + apiKey === undefined + ? GoogleVertexShared.oauth(input, project) + : Auth.remove("authorization").andThen(Auth.header("x-goog-api-key", apiKey)), }) } @@ -111,17 +114,21 @@ export const provider = { id, configure, } -export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { - if (settings.apiKey !== undefined && settings.accessToken !== undefined) +export const model: ProviderPackage.Definition["model"] = (input) => { + if (!input.credential && input.settings.apiKey !== undefined && input.settings.accessToken !== undefined) throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth") return configure({ - ...(settings.apiKey === undefined ? { accessToken: settings.accessToken } : { apiKey: settings.apiKey }), - baseURL: settings.baseURL, - headers: settings.headers === undefined ? undefined : { ...settings.headers }, - http: settings.body === undefined ? undefined : { body: { ...settings.body } }, - limits: settings.limits, - location: settings.location, - project: settings.project, - providerOptions: settings.providerOptions, - }).model(modelID) + ...ProviderPackage.routeDefaults(input.defaults), + ...(input.credential + ? input.credential.type === "key" + ? { apiKey: input.credential.value } + : { accessToken: input.credential.accessToken } + : input.settings.apiKey === undefined + ? { accessToken: input.settings.accessToken } + : { apiKey: input.settings.apiKey }), + baseURL: input.settings.baseURL, + location: input.settings.location, + project: input.settings.project, + providerOptions: input.settings.providerOptions, + }).model(input.id) } diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index 3482ee6370b3..4103b766b629 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -1,7 +1,7 @@ import type { RouteDefaultsInput } from "../route/client.js" import { Auth } from "../route/auth.js" import type { ProviderAuthOption } from "../route/auth-options.js" -import type { ProviderPackage } from "../provider-package.js" +import { ProviderPackage } from "../provider-package.js" import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID } from "../schema/index.js" import { Gemini } from "../protocols/gemini.js" import { GoogleImages } from "../protocols/google-images.js" @@ -28,9 +28,11 @@ export interface Settings extends ProviderPackage.Settings { const auth = (options: ProviderAuthOption<"optional">) => { if ("auth" in options && options.auth) return options.auth - return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey") - .orElse(Auth.config("GOOGLE_GENERATIVE_AI_API_KEY")) - .pipe(Auth.header("x-goog-api-key")) + return Auth.remove("authorization").andThen( + Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey") + .orElse(Auth.config("GOOGLE_GENERATIVE_AI_API_KEY")) + .pipe(Auth.header("x-goog-api-key")), + ) } const configuredRoute = (input: Config) => { @@ -57,14 +59,14 @@ export const configure = (input: Config = {}) => { } export const provider = configure() -export const model: ProviderPackage.Definition["model"] = (modelID, settings) => +export const model: ProviderPackage.Definition["model"] = (input) => configure({ - apiKey: settings.apiKey, - baseURL: settings.baseURL, - headers: settings.headers === undefined ? undefined : { ...settings.headers }, - http: settings.body === undefined ? undefined : { body: { ...settings.body } }, - limits: settings.limits, - providerOptions: settings.providerOptions, - }).model(modelID) + ...ProviderPackage.routeDefaults(input.defaults), + ...(input.credential + ? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "x-goog-api-key") + : { apiKey: input.settings.apiKey }), + baseURL: input.settings.baseURL, + providerOptions: input.settings.providerOptions, + }).model(input.id) export const image = provider.image diff --git a/packages/ai/src/providers/openai-compatible-responses.ts b/packages/ai/src/providers/openai-compatible-responses.ts index 16b12908f3e3..5bc7cb576918 100644 --- a/packages/ai/src/providers/openai-compatible-responses.ts +++ b/packages/ai/src/providers/openai-compatible-responses.ts @@ -1,4 +1,4 @@ -import type { ProviderPackage } from "../provider-package.js" +import { ProviderPackage } from "../provider-package.js" import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses.js" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js" import type { RouteDefaultsInput } from "../route/client.js" @@ -46,16 +46,11 @@ export const provider = { configure, } -export const model: ProviderPackage.Definition["model"] = ( - modelID, - settings, -) => +export const model: ProviderPackage.Definition["model"] = (input) => configure({ - apiKey: settings.apiKey, - baseURL: settings.baseURL, - headers: settings.headers === undefined ? undefined : { ...settings.headers }, - http: settings.body === undefined ? undefined : { body: { ...settings.body } }, - limits: settings.limits, - provider: settings.provider, - providerOptions: settings.providerOptions, - }).model(modelID) + ...ProviderPackage.routeDefaults(input.defaults), + ...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: input.settings.apiKey }), + baseURL: input.settings.baseURL, + provider: input.settings.provider, + providerOptions: input.settings.providerOptions, + }).model(input.id) diff --git a/packages/ai/src/providers/openai-compatible.ts b/packages/ai/src/providers/openai-compatible.ts index 4da44b8c3e3b..9cc757c8ac5a 100644 --- a/packages/ai/src/providers/openai-compatible.ts +++ b/packages/ai/src/providers/openai-compatible.ts @@ -2,7 +2,7 @@ import { ProviderID, type ModelID } from "../schema/index.js" import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js" import type { RouteDefaultsInput } from "../route/client.js" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js" -import type { ProviderPackage } from "../provider-package.js" +import { ProviderPackage } from "../provider-package.js" import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile.js" import type { OpenAIProviderOptionsInput } from "./openai-options.js" @@ -68,16 +68,14 @@ export const provider = { configure, } -export const model: ProviderPackage.Definition["model"] = (modelID, settings) => +export const model: ProviderPackage.Definition["model"] = (input) => configure({ - apiKey: settings.apiKey, - baseURL: settings.baseURL, - headers: settings.headers === undefined ? undefined : { ...settings.headers }, - http: settings.body === undefined ? undefined : { body: { ...settings.body } }, - limits: settings.limits, - provider: settings.provider, - providerOptions: settings.providerOptions, - }).model(modelID) + ...ProviderPackage.routeDefaults(input.defaults), + ...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: input.settings.apiKey }), + baseURL: input.settings.baseURL, + provider: input.settings.provider, + providerOptions: input.settings.providerOptions, + }).model(input.id) export const baseten = define(profiles.baseten) export const cerebras = define(profiles.cerebras) diff --git a/packages/ai/src/providers/openai-options.ts b/packages/ai/src/providers/openai-options.ts index ca85642ef3ac..16fed02f0ab9 100644 --- a/packages/ai/src/providers/openai-options.ts +++ b/packages/ai/src/providers/openai-options.ts @@ -1,37 +1,21 @@ import { mergeProviderOptions, type ProviderOptions } from "../schema/index.js" import type { OpenResponsesOptionsInput } from "./open-responses-options.js" +import type { Options } from "../protocols/utils/open-responses-options.js" export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js" export type OpenAIOptionsInput = OpenResponsesOptionsInput +export type OpenAIConfigOptions = Options export type OpenAIProviderOptionsInput = OpenAIOptionsInput -const definedEntries = (input: Record) => - Object.entries(input).filter((entry) => entry[1] !== undefined) - -const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): ProviderOptions | undefined => { - const result = Object.fromEntries( - definedEntries({ - store: options?.store, - reasoningEffort: options?.reasoningEffort, - reasoningSummary: options?.reasoningSummary, - include: options?.include, - textVerbosity: options?.textVerbosity, - serviceTier: options?.serviceTier, - }), - ) - if (Object.keys(result).length === 0) return undefined - return result -} - export const gpt5DefaultOptions = ( modelID: string, options: { readonly textVerbosity?: boolean } = {}, ): ProviderOptions | undefined => { const id = modelID.toLowerCase() if (!id.includes("gpt-5") || id.includes("gpt-5-chat") || id.includes("gpt-5-pro")) return undefined - return openAIProviderOptions({ + return { reasoningEffort: "medium", reasoningSummary: "auto", // GPT-5 reasoning models are configured stateless (`store: false`) by @@ -44,14 +28,13 @@ export const gpt5DefaultOptions = ( options.textVerbosity === true && id.includes("gpt-5.") && !id.includes("codex") && !id.includes("-chat") ? "low" : undefined, - }) + } } export const openAIDefaultOptions = ( modelID: string, options: { readonly textVerbosity?: boolean } = {}, -): ProviderOptions | undefined => - mergeProviderOptions(openAIProviderOptions({ store: false }), gpt5DefaultOptions(modelID, options)) +): ProviderOptions | undefined => mergeProviderOptions({ store: false }, gpt5DefaultOptions(modelID, options)) export const withOpenAIOptions = ( modelID: string, diff --git a/packages/ai/src/providers/openai.ts b/packages/ai/src/providers/openai.ts index ae65c8650acd..ccee65f0e897 100644 --- a/packages/ai/src/providers/openai.ts +++ b/packages/ai/src/providers/openai.ts @@ -1,10 +1,10 @@ import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js" import type { Route, RouteDefaultsInput } from "../route/client.js" -import type { ProviderPackage } from "../provider-package.js" +import { ProviderPackage } from "../provider-package.js" import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema/index.js" import * as OpenAIChat from "../protocols/openai-chat.js" import * as OpenAIResponses from "../protocols/openai-responses.js" -import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js" +import { withOpenAIOptions, type OpenAIConfigOptions, type OpenAIProviderOptionsInput } from "./openai-options.js" import { OpenAIImages, type OpenAIImageString } from "../protocols/openai-images.js" export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options.js" @@ -17,11 +17,11 @@ export const routes = [OpenAIResponses.route, OpenAIChat.route] // This provider facade wraps the lower-level Responses and Chat model factories // with OpenAI-specific conveniences: typed options, API-key sugar, env fallback, // and default option normalization. -export type Config = RouteDefaultsInput & +export type Config = Omit & + OpenAIConfigOptions & ProviderAuthOption<"optional"> & { readonly baseURL?: string readonly queryParams?: Record - readonly providerOptions?: OpenAIProviderOptionsInput } export interface ImageGenerationOptions { @@ -57,13 +57,12 @@ export const imageGeneration = (options: ImageGenerationOptions = {}) => }, }) -export interface Settings extends ProviderPackage.Settings { +export interface Settings extends ProviderPackage.Settings, OpenAIConfigOptions { readonly apiKey?: string readonly baseURL?: string readonly organization?: string readonly project?: string readonly queryParams?: Readonly> - readonly providerOptions?: OpenAIProviderOptionsInput } const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY") @@ -73,6 +72,39 @@ const defaults = (input: Config) => { return rest } +const splitConfigOptions = (input: Input) => { + const { + instructions, + store, + reasoningEffort, + reasoningSummary, + include, + textVerbosity, + serviceTier, + truncation, + allowedTools, + maxToolCalls, + parallelToolCalls, + ...rest + } = input + return { + options: { + instructions, + store, + reasoningEffort, + reasoningSummary, + include, + textVerbosity, + serviceTier, + truncation, + allowedTools, + maxToolCalls, + parallelToolCalls, + }, + rest, + } +} + const configuredRoute = (route: Route, input: Config) => route.with({ auth: auth(input), @@ -82,7 +114,8 @@ const configuredRoute = (route: Route, input: Co export const configure = (input: Config = {}) => { const responsesRoute = configuredRoute(OpenAIResponses.route, input) const chatRoute = configuredRoute(OpenAIChat.route, input) - const modelDefaults = defaults(input) + const split = splitConfigOptions(defaults(input)) + const modelDefaults = { ...split.rest, providerOptions: split.options } const responses = (id: string | ModelID) => responsesRoute .with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })) @@ -113,31 +146,30 @@ export const configure = (input: Config = {}) => { export const provider = configure() -const config = (settings: Settings): Config => { +const config = (input: ProviderPackage.ModelInput): Config => { + const settings = input.settings + const options = splitConfigOptions(settings).options const headers = { ...(settings.organization === undefined ? {} : { "OpenAI-Organization": settings.organization }), ...(settings.project === undefined ? {} : { "OpenAI-Project": settings.project }), - ...settings.headers, + ...input.defaults.headers, } return { - apiKey: settings.apiKey, + ...ProviderPackage.routeDefaults(input.defaults), + ...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: settings.apiKey }), baseURL: settings.baseURL, headers: Object.keys(headers).length === 0 ? undefined : headers, - http: settings.body === undefined ? undefined : { body: { ...settings.body } }, - limits: settings.limits, - providerOptions: settings.providerOptions, queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, + ...options, } } -export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { - return configure(config(settings)).responses(modelID) +export const model: ProviderPackage.Definition["model"] = (input) => { + return configure(config(input)).responses(input.id) } -export const chatModel: ProviderPackage.Definition["model"] = ( - modelID, - settings, -) => configure(config(settings)).chat(modelID) +export const chatModel: ProviderPackage.Definition["model"] = (input) => + configure(config(input)).chat(input.id) export const responses = provider.responses export const chat = provider.chat export const image = provider.image diff --git a/packages/ai/src/providers/openrouter.ts b/packages/ai/src/providers/openrouter.ts index a222586645dd..b6437d74028c 100644 --- a/packages/ai/src/providers/openrouter.ts +++ b/packages/ai/src/providers/openrouter.ts @@ -5,7 +5,7 @@ import { Framing } from "../route/framing.js" import { Protocol } from "../route/protocol.js" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js" import { ProviderID, type CacheHint, type ModelID } from "../schema/index.js" -import type { ProviderPackage } from "../provider-package.js" +import { ProviderPackage } from "../provider-package.js" import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js" import * as OpenAIChat from "../protocols/openai-chat.js" import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js" @@ -191,15 +191,10 @@ export const configure = (input: LanguageModelOptions = {}) => { } export const provider = configure() -export const model: ProviderPackage.Definition["model"] = ( - modelID, - settings, -) => +export const model: ProviderPackage.Definition["model"] = (input) => configure({ - apiKey: settings.apiKey, - baseURL: settings.baseURL, - headers: settings.headers, - http: settings.body === undefined ? undefined : { body: { ...settings.body } }, - limits: settings.limits, - providerOptions: settings.providerOptions, - }).model(modelID) + ...ProviderPackage.routeDefaults(input.defaults), + ...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: input.settings.apiKey }), + baseURL: input.settings.baseURL, + providerOptions: input.settings.providerOptions, + }).model(input.id) diff --git a/packages/ai/src/providers/xai.ts b/packages/ai/src/providers/xai.ts index f9ee0d927299..24b4bee5298b 100644 --- a/packages/ai/src/providers/xai.ts +++ b/packages/ai/src/providers/xai.ts @@ -8,7 +8,7 @@ import * as OpenAIChat from "../protocols/openai-chat.js" import * as OpenAIResponses from "../protocols/openai-responses.js" import { XAIImages } from "../protocols/xai-images.js" import type { OpenAIOptionsInput } from "./openai-options.js" -import type { ProviderPackage } from "../provider-package.js" +import { ProviderPackage } from "../provider-package.js" export const id = ProviderID.make("xai") @@ -95,15 +95,13 @@ export const configure = (input: LanguageModelOptions = {}) => { } export const provider = configure() -export const model: ProviderPackage.Definition["model"] = (modelID, settings) => +export const model: ProviderPackage.Definition["model"] = (input) => configure({ - apiKey: settings.apiKey, - baseURL: settings.baseURL, - headers: settings.headers, - http: settings.body === undefined ? undefined : { body: { ...settings.body } }, - limits: settings.limits, - providerOptions: settings.providerOptions, - }).model(modelID) + ...ProviderPackage.routeDefaults(input.defaults), + ...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: input.settings.apiKey }), + baseURL: input.settings.baseURL, + providerOptions: input.settings.providerOptions, + }).model(input.id) export const responses = provider.responses export const chat = provider.chat export const image = provider.image diff --git a/packages/ai/test/auth-options.types.ts b/packages/ai/test/auth-options.types.ts index a9e7c2e67b27..4db5a3d975f0 100644 --- a/packages/ai/test/auth-options.types.ts +++ b/packages/ai/test/auth-options.types.ts @@ -81,8 +81,22 @@ OpenAI.configure({ }).responses("gpt-4.1-mini") OpenAI.configure({ generation: { maxTokens: 100 }, - providerOptions: { store: false }, + store: false, }).responses("gpt-4.1-mini") +OpenAI.model({ + id: "gpt-5", + settings: {}, + credential: { type: "key", value: "sk-test" }, + defaults: { headers: { "x-test": "value" } }, +}) +OpenAI.model({ + id: "gpt-5", + settings: { + // @ts-expect-error Common request defaults belong under input.defaults. + headers: { "x-test": "value" }, + }, + defaults: {}, +}) // @ts-expect-error OpenAI model selectors only accept model ids. OpenAI.configure({ apiKey: "sk-test" }).responses("gpt-4.1-mini", {}) @@ -97,7 +111,7 @@ OpenAI.configure({ bogus: true }) OpenAI.configure({ generation: { maxTokens: "many" } }) // @ts-expect-error provider-native options remain typed. -OpenAI.configure({ providerOptions: { store: "false" } }) +OpenAI.configure({ store: "false" }) // @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth. OpenAI.configure({ apiKey: "sk-test", auth: Auth.bearer("oauth-token") }) @@ -145,8 +159,12 @@ Anthropic.configure({ }).model("claude-haiku") // @ts-expect-error Anthropic model selectors only accept model ids. Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku", {}) -// @ts-expect-error Anthropic package settings accept only one auth source. -Anthropic.model("claude-sonnet-4-6", { apiKey: "anthropic-key", authToken: "anthropic-token" }) +Anthropic.model({ + id: "claude-sonnet-4-6", + // @ts-expect-error Anthropic package settings accept only one auth source. + settings: { apiKey: "anthropic-key", authToken: "anthropic-token" }, + defaults: {}, +}) // @ts-expect-error Enabled Anthropic thinking requires a token budget. Anthropic.configure({ providerOptions: { thinking: { type: "enabled" } } }) // @ts-expect-error Anthropic thinking budgets must be numbers. @@ -162,11 +180,15 @@ AnthropicCompatible.configure({ AnthropicCompatible.configure({ apiKey: "messages-key" }) // @ts-expect-error Anthropic-compatible model selectors only accept model ids. AnthropicCompatible.configure({ baseURL: "https://messages.example.com/v1" }).model("compatible-model", {}) -// @ts-expect-error Anthropic-compatible package settings accept only one auth source. -AnthropicCompatible.model("compatible-model", { - apiKey: "messages-key", - authToken: "messages-token", - baseURL: "https://messages.example.com/v1", +AnthropicCompatible.model({ + id: "compatible-model", + // @ts-expect-error Anthropic-compatible package settings accept only one auth source. + settings: { + apiKey: "messages-key", + authToken: "messages-token", + baseURL: "https://messages.example.com/v1", + }, + defaults: {}, }) Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash") @@ -189,15 +211,23 @@ GoogleVertex.configure({ auth: Auth.bearer("vertex-token"), project: "project" } GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash", {}) // @ts-expect-error Vertex Gemini config accepts only one auth source. GoogleVertex.configure({ accessToken: "vertex-token", apiKey: "vertex-key", project: "project" }) -// @ts-expect-error Vertex Gemini package settings accept only one auth source. -GoogleVertex.model("gemini-3.5-flash", { accessToken: "vertex-token", apiKey: "vertex-key", project: "project" }) +GoogleVertex.model({ + id: "gemini-3.5-flash", + // @ts-expect-error Vertex Gemini package settings accept only one auth source. + settings: { accessToken: "vertex-token", apiKey: "vertex-key", project: "project" }, + defaults: {}, +}) GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model("deepseek-ai/deepseek-v3.2-maas") GoogleVertexChat.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model( "deepseek-ai/deepseek-v3.2-maas", ) -// @ts-expect-error Vertex Chat package settings do not accept API keys. -GoogleVertexChat.model("deepseek-ai/deepseek-v3.2-maas", { apiKey: "vertex-key", project: "project" }) +GoogleVertexChat.model({ + id: "deepseek-ai/deepseek-v3.2-maas", + // @ts-expect-error Vertex Chat package settings do not accept API keys. + settings: { apiKey: "vertex-key", project: "project" }, + defaults: {}, +}) GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model( "deepseek-ai/deepseek-v3.2-maas", // @ts-expect-error Vertex Chat model selectors only accept model ids. @@ -214,8 +244,12 @@ GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project GoogleVertexResponses.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model( "xai/grok-4.20-reasoning", ) -// @ts-expect-error Vertex Responses package settings do not accept API keys. -GoogleVertexResponses.model("xai/grok-4.20-reasoning", { apiKey: "vertex-key", project: "project" }) +GoogleVertexResponses.model({ + id: "xai/grok-4.20-reasoning", + // @ts-expect-error Vertex Responses package settings do not accept API keys. + settings: { apiKey: "vertex-key", project: "project" }, + defaults: {}, +}) GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project" }).model( "xai/grok-4.20-reasoning", // @ts-expect-error Vertex Responses model selectors only accept model ids. @@ -233,8 +267,12 @@ GoogleVertexMessages.configure({ project: "project", providerOptions: { thinking: { type: "adaptive", display: "omitted" }, effort: "low" }, }).model("claude-sonnet-4-6") -// @ts-expect-error Vertex Messages package settings do not accept API keys. -GoogleVertexMessages.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" }) +GoogleVertexMessages.model({ + id: "claude-sonnet-4-6", + // @ts-expect-error Vertex Messages package settings do not accept API keys. + settings: { apiKey: "vertex-key", project: "project" }, + defaults: {}, +}) GoogleVertexMessages.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("claude-sonnet-4-6") GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model( "claude-sonnet-4-6", diff --git a/packages/ai/test/provider-package.test.ts b/packages/ai/test/provider-package.test.ts index fef3d8a9037b..f88c70f2caec 100644 --- a/packages/ai/test/provider-package.test.ts +++ b/packages/ai/test/provider-package.test.ts @@ -1,6 +1,72 @@ import { describe, expect, test } from "bun:test" +import { ConfigProvider, Effect } from "effect" +import { Headers } from "effect/unstable/http" +import { LLM, ProviderPackage } from "@opencode-ai/ai" import { model } from "@opencode-ai/ai/providers/openai" +const packageInput = >(id: string, input: Input) => { + const { headers, body, limits, ...settings } = input + return { id, settings, defaults: { headers, body, limits } } +} + +const authHeaders = ( + selected: ReturnType, + headers: Record = {}, + env: Record = {}, +) => + Effect.runPromise( + selected.route.auth + .apply({ + request: LLM.request({ model: selected, prompt: "hello" }), + method: "POST", + url: "https://example.test/v1", + body: "{}", + headers: Headers.fromInput(headers), + }) + .pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))), + ) + +const applyAuth = ( + option: ReturnType, + headers: Record = {}, +) => { + const selected = model(packageInput("gpt-5", { apiKey: "fixture" })) + return Effect.runPromise( + option.auth.apply({ + request: LLM.request({ model: selected, prompt: "hello" }), + method: "POST", + url: "https://example.test/v1", + body: "{}", + headers: Headers.fromInput(headers), + }), + ) +} + +describe("provider package credential lowering", () => { + test("intentionally renders keys and OAuth credentials as bearer auth", async () => { + const key = await applyAuth(ProviderPackage.bearerAuthOption({ type: "key", value: "provider-key" })) + const oauth = await applyAuth(ProviderPackage.bearerAuthOption({ type: "oauth", accessToken: "provider-token" })) + + expect(key.authorization).toBe("Bearer provider-key") + expect(oauth.authorization).toBe("Bearer provider-token") + }) + + test("keeps key-header credentials configurable and removes stale keys for OAuth", async () => { + expect(ProviderPackage.apiKeyOrBearerAuthOption({ type: "key", value: "provider-key" }, "x-api-key")).toEqual({ + apiKey: "provider-key", + }) + const oauth = ProviderPackage.apiKeyOrBearerAuthOption( + { type: "oauth", accessToken: "provider-token" }, + "x-api-key", + ) + if (!("auth" in oauth)) throw new Error("Expected OAuth credential to lower to auth") + const headers = await applyAuth(oauth, { "x-api-key": "stale" }) + + expect(headers.authorization).toBe("Bearer provider-token") + expect(headers["x-api-key"]).toBeUndefined() + }) +}) + describe("provider package entrypoints", () => { test("semantic API aliases expose the same contract", async () => { const modules = await Promise.all([ @@ -45,14 +111,18 @@ describe("provider package entrypoints", () => { body: { service_tier: "priority" }, limits: { context: 200_000, output: 64_000 }, } - const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", { - ...settings, - providerOptions: { usage: true }, - }) - const xai = XAI.model("grok-4", { - ...settings, - providerOptions: { reasoningEffort: "high" }, - }) + const openrouter = OpenRouter.model( + packageInput("anthropic/claude-sonnet-4", { + ...settings, + providerOptions: { usage: true }, + }), + ) + const xai = XAI.model( + packageInput("grok-4", { + ...settings, + providerOptions: { reasoningEffort: "high" }, + }), + ) for (const selected of [openrouter, xai]) { expect(selected.route.endpoint.baseURL).toBe(settings.baseURL) @@ -65,32 +135,151 @@ describe("provider package entrypoints", () => { }) test("maps package settings onto the executable model", () => { - const selected = model("gpt-5", { - apiKey: "fixture", - baseURL: "https://api.openai.test/v1", - headers: { "x-application": "opencode" }, - body: { service_tier: "priority" }, - limits: { context: 200_000, output: 64_000 }, - unrelatedInheritedSetting: true, - }) + const selected = model( + packageInput("gpt-5", { + apiKey: "fixture", + baseURL: "https://api.openai.test/v1", + headers: { "x-application": "opencode" }, + body: { service_tier: "priority" }, + limits: { context: 200_000, output: 64_000 }, + reasoningEffort: "high", + unrelatedInheritedSetting: true, + }), + ) expect(selected.route.id).toBe("openai-responses") expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" }) expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 }) + expect(selected.route.defaults.providerOptions).toEqual({ + store: false, + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }) + }) + + test("lets provider packages interpret resolved credentials", async () => { + const Anthropic = await import("@opencode-ai/ai/providers/anthropic") + const Azure = await import("@opencode-ai/ai/providers/azure") + const Google = await import("@opencode-ai/ai/providers/google") + const GoogleVertex = await import("@opencode-ai/ai/providers/google-vertex") + const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat") + const openai = model({ + id: "gpt-5", + settings: {}, + credential: { type: "oauth", accessToken: "openai-token" }, + defaults: {}, + }) + const anthropicKey = Anthropic.model({ + id: "claude-sonnet-4-6", + settings: {}, + credential: { type: "key", value: "anthropic-key" }, + defaults: {}, + }) + const anthropicOAuth = Anthropic.model({ + id: "claude-sonnet-4-6", + settings: {}, + credential: { type: "oauth", accessToken: "anthropic-token" }, + defaults: {}, + }) + const anthropicEmptyKey = Anthropic.model({ + id: "claude-sonnet-4-6", + settings: {}, + credential: { type: "key", value: "" }, + defaults: {}, + }) + const azureKey = Azure.model({ + id: "deployment", + settings: { resourceName: "opencode-test" }, + credential: { type: "key", value: "azure-key" }, + defaults: {}, + }) + const azureOAuth = Azure.model({ + id: "deployment", + settings: { resourceName: "opencode-test" }, + credential: { type: "oauth", accessToken: "azure-token" }, + defaults: {}, + }) + const googleKey = Google.model({ + id: "gemini-2.5-flash", + settings: {}, + credential: { type: "key", value: "google-key" }, + defaults: {}, + }) + const googleOAuth = Google.model({ + id: "gemini-2.5-flash", + settings: {}, + credential: { type: "oauth", accessToken: "google-token" }, + defaults: {}, + }) + const vertexKey = GoogleVertex.model({ + id: "gemini-3.5-flash", + settings: {}, + credential: { type: "key", value: "vertex-key" }, + defaults: {}, + }) + const vertexOAuth = GoogleVertex.model({ + id: "gemini-3.5-flash", + settings: { project: "vertex-project" }, + credential: { type: "oauth", accessToken: "vertex-token" }, + defaults: {}, + }) + const vertexChatOAuth = GoogleVertexChat.model({ + id: "deepseek-ai/deepseek-v3.2-maas", + settings: { apiKey: "configured-key", project: "vertex-project" }, + credential: { type: "oauth", accessToken: "vertex-chat-token" }, + defaults: {}, + }) + + expect((await authHeaders(openai)).authorization).toBe("Bearer openai-token") + const anthropicKeyHeaders = await authHeaders(anthropicKey, { authorization: "Bearer stale" }) + const anthropicOAuthHeaders = await authHeaders(anthropicOAuth, { "x-api-key": "stale" }) + const anthropicEmptyKeyHeaders = await authHeaders( + anthropicEmptyKey, + { authorization: "Bearer stale" }, + { ANTHROPIC_API_KEY: "environment-key" }, + ) + const azureKeyHeaders = await authHeaders(azureKey, { authorization: "Bearer stale" }) + const azureOAuthHeaders = await authHeaders(azureOAuth, { "api-key": "stale" }) + const googleKeyHeaders = await authHeaders(googleKey, { authorization: "Bearer stale" }) + const googleOAuthHeaders = await authHeaders(googleOAuth, { "x-goog-api-key": "stale" }) + const vertexKeyHeaders = await authHeaders(vertexKey, { authorization: "Bearer stale" }) + const vertexOAuthHeaders = await authHeaders(vertexOAuth, { "x-goog-api-key": "stale" }) + expect(anthropicKeyHeaders["x-api-key"]).toBe("anthropic-key") + expect(anthropicKeyHeaders.authorization).toBeUndefined() + expect(anthropicOAuthHeaders.authorization).toBe("Bearer anthropic-token") + expect(anthropicOAuthHeaders["x-api-key"]).toBeUndefined() + expect(anthropicEmptyKeyHeaders["x-api-key"]).toBe("environment-key") + expect(anthropicEmptyKeyHeaders.authorization).toBeUndefined() + expect(azureKeyHeaders["api-key"]).toBe("azure-key") + expect(azureKeyHeaders.authorization).toBeUndefined() + expect(azureOAuthHeaders.authorization).toBe("Bearer azure-token") + expect(azureOAuthHeaders["api-key"]).toBeUndefined() + expect(googleKeyHeaders["x-goog-api-key"]).toBe("google-key") + expect(googleKeyHeaders.authorization).toBeUndefined() + expect(googleOAuthHeaders.authorization).toBe("Bearer google-token") + expect(googleOAuthHeaders["x-goog-api-key"]).toBeUndefined() + expect(vertexKeyHeaders["x-goog-api-key"]).toBe("vertex-key") + expect(vertexKeyHeaders.authorization).toBeUndefined() + expect(vertexOAuthHeaders.authorization).toBe("Bearer vertex-token") + expect(vertexOAuthHeaders["x-goog-api-key"]).toBeUndefined() + expect((await authHeaders(vertexChatOAuth)).authorization).toBe("Bearer vertex-chat-token") }) test("maps OpenAI-compatible Responses settings onto the executable model", async () => { const OpenAICompatibleResponses = await import("@opencode-ai/ai/providers/openai-compatible/responses") - const selected = OpenAICompatibleResponses.model("custom-model", { - apiKey: "fixture", - baseURL: "https://responses.example.test/v1", - provider: "example", - headers: { "x-application": "opencode" }, - body: { service_tier: "priority" }, - limits: { context: 200_000, output: 64_000 }, - providerOptions: { reasoningEffort: "low", store: true }, - }) + const selected = OpenAICompatibleResponses.model( + packageInput("custom-model", { + apiKey: "fixture", + baseURL: "https://responses.example.test/v1", + provider: "example", + headers: { "x-application": "opencode" }, + body: { service_tier: "priority" }, + limits: { context: 200_000, output: 64_000 }, + providerOptions: { reasoningEffort: "low", store: true }, + }), + ) expect(String(selected.provider)).toBe("example") expect(selected.route.id).toBe("openai-compatible-responses") @@ -106,15 +295,17 @@ describe("provider package entrypoints", () => { test("maps Anthropic-compatible settings onto the executable model", async () => { const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible") - const selected = AnthropicCompatible.model("compatible-model", { - apiKey: "fixture", - baseURL: "https://messages.example.test/v1", - provider: "example", - headers: { "x-application": "opencode" }, - body: { metadata: { user_id: "user_1" } }, - limits: { context: 200_000, output: 64_000 }, - providerOptions: { effort: "low" }, - }) + const selected = AnthropicCompatible.model( + packageInput("compatible-model", { + apiKey: "fixture", + baseURL: "https://messages.example.test/v1", + provider: "example", + headers: { "x-application": "opencode" }, + body: { metadata: { user_id: "user_1" } }, + limits: { context: 200_000, output: 64_000 }, + providerOptions: { effort: "low" }, + }), + ) expect(String(selected.provider)).toBe("example") expect(selected.route.id).toBe("anthropic-messages") @@ -130,10 +321,12 @@ describe("provider package entrypoints", () => { test("maps Anthropic provider options onto the executable model", async () => { const Anthropic = await import("@opencode-ai/ai/providers/anthropic") - const selected = Anthropic.model("claude-sonnet-4-6", { - apiKey: "fixture", - providerOptions: { thinking: { type: "adaptive" } }, - }) + const selected = Anthropic.model( + packageInput("claude-sonnet-4-6", { + apiKey: "fixture", + providerOptions: { thinking: { type: "adaptive" } }, + }), + ) expect(selected.route.defaults.providerOptions).toEqual({ thinking: { type: "adaptive" } }) }) @@ -141,7 +334,7 @@ describe("provider package entrypoints", () => { test("requires an Anthropic-compatible base URL at runtime", async () => { const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible") expect(() => - Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]), + Reflect.apply(AnthropicCompatible.model, undefined, [packageInput("compatible-model", { apiKey: "fixture" })]), ).toThrow("Anthropic-compatible providers require a baseURL") }) @@ -150,25 +343,28 @@ describe("provider package entrypoints", () => { const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible") expect(() => Reflect.apply(AnthropicCompatible.model, undefined, [ - "compatible-model", - { + packageInput("compatible-model", { apiKey: "fixture", authToken: "token", baseURL: "https://messages.example.test/v1", - }, + }), ]), ).toThrow("Anthropic-compatible apiKey cannot be combined with authToken") expect(() => - Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]), + Reflect.apply(Anthropic.model, undefined, [ + packageInput("claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }), + ]), ).toThrow("Anthropic apiKey cannot be combined with authToken") }) test("maps legacy OpenAI organization and project settings to headers", () => { - const selected = model("gpt-5", { - apiKey: "fixture", - organization: "org_123", - project: "proj_123", - }) + const selected = model( + packageInput("gpt-5", { + apiKey: "fixture", + organization: "org_123", + project: "proj_123", + }), + ) expect(selected.route.defaults.headers).toMatchObject({ "OpenAI-Organization": "org_123", @@ -188,10 +384,10 @@ describe("provider package entrypoints", () => { limits: { context: 200_000, output: 64_000 }, } - const responses = AzureResponses.model("deployment", settings) - const chat = AzureChat.model("deployment", settings) + const responses = AzureResponses.model(packageInput("deployment", settings)) + const chat = AzureChat.model(packageInput("deployment", settings)) - expect(Azure.model("deployment", settings).route.id).toBe("azure-openai-responses") + expect(Azure.model(packageInput("deployment", settings)).route.id).toBe("azure-openai-responses") expect(responses.route.id).toBe("azure-openai-responses") expect(responses.route.endpoint.baseURL).toBe("https://opencode-test.openai.azure.com/openai/v1") expect(responses.route.defaults.headers).toEqual({ "x-application": "opencode" }) @@ -202,16 +398,20 @@ describe("provider package entrypoints", () => { test("constructs Azure deployment URLs and preserves custom gateway URLs", async () => { const Azure = await import("@opencode-ai/ai/providers/azure") - const deployment = Azure.model("custom-deployment", { - apiKey: "fixture", - resourceName: "opencode-test", - apiVersion: "2025-01-01-preview", - useDeploymentBasedUrls: true, - }) - const gateway = Azure.model("gateway-model", { - apiKey: "fixture", - baseURL: "https://gateway.example/azure/", - }) + const deployment = Azure.model( + packageInput("custom-deployment", { + apiKey: "fixture", + resourceName: "opencode-test", + apiVersion: "2025-01-01-preview", + useDeploymentBasedUrls: true, + }), + ) + const gateway = Azure.model( + packageInput("gateway-model", { + apiKey: "fixture", + baseURL: "https://gateway.example/azure/", + }), + ) expect(deployment.route.endpoint).toMatchObject({ baseURL: "https://opencode-test.openai.azure.com/openai/deployments/custom-deployment", @@ -223,14 +423,16 @@ describe("provider package entrypoints", () => { test("maps Google package settings onto the Gemini model", async () => { const Google = await import("@opencode-ai/ai/providers/google") - const selected = Google.model("gemini-2.5-flash", { - apiKey: "fixture", - baseURL: "https://generativelanguage.test/v1beta", - headers: { "x-application": "opencode" }, - body: { safetySettings: [] }, - limits: { context: 1_000_000, output: 65_536 }, - providerOptions: { thinkingConfig: { thinkingBudget: 1_024 } }, - }) + const selected = Google.model( + packageInput("gemini-2.5-flash", { + apiKey: "fixture", + baseURL: "https://generativelanguage.test/v1beta", + headers: { "x-application": "opencode" }, + body: { safetySettings: [] }, + limits: { context: 1_000_000, output: 65_536 }, + providerOptions: { thinkingConfig: { thinkingBudget: 1_024 } }, + }), + ) expect(selected.route.id).toBe("gemini") expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta") @@ -246,27 +448,35 @@ describe("provider package entrypoints", () => { const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat") const GoogleVertexResponses = await import("@opencode-ai/ai/providers/google-vertex/responses") const GoogleVertexMessages = await import("@opencode-ai/ai/providers/google-vertex/messages") - const gemini = GoogleVertex.model("gemini-3.5-flash", { - apiKey: "fixture", - headers: { "x-application": "opencode" }, - body: { safetySettings: [] }, - limits: { context: 1_000_000, output: 65_536 }, - }) - const messages = GoogleVertexMessages.model("claude-sonnet-4-6", { - accessToken: "fixture", - location: "global", - project: "vertex-project", - }) - const chat = GoogleVertexChat.model("deepseek-ai/deepseek-v3.2-maas", { - accessToken: "fixture", - location: "global", - project: "vertex-project", - }) - const responses = GoogleVertexResponses.model("xai/grok-4.20-reasoning", { - accessToken: "fixture", - location: "global", - project: "vertex-project", - }) + const gemini = GoogleVertex.model( + packageInput("gemini-3.5-flash", { + apiKey: "fixture", + headers: { "x-application": "opencode" }, + body: { safetySettings: [] }, + limits: { context: 1_000_000, output: 65_536 }, + }), + ) + const messages = GoogleVertexMessages.model( + packageInput("claude-sonnet-4-6", { + accessToken: "fixture", + location: "global", + project: "vertex-project", + }), + ) + const chat = GoogleVertexChat.model( + packageInput("deepseek-ai/deepseek-v3.2-maas", { + accessToken: "fixture", + location: "global", + project: "vertex-project", + }), + ) + const responses = GoogleVertexResponses.model( + packageInput("xai/grok-4.20-reasoning", { + accessToken: "fixture", + location: "global", + project: "vertex-project", + }), + ) expect(GoogleVertexGemini.model).toBe(GoogleVertex.model) expect(gemini.route.id).toBe("google-vertex-gemini") @@ -276,11 +486,13 @@ describe("provider package entrypoints", () => { expect(gemini.route.defaults.http?.body).toEqual({ safetySettings: [] }) expect(gemini.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 }) expect( - GoogleVertex.model("gemini-3.5-flash", { - accessToken: "fixture", - location: "eu", - project: "vertex-project", - }).route.endpoint.baseURL, + GoogleVertex.model( + packageInput("gemini-3.5-flash", { + accessToken: "fixture", + location: "eu", + project: "vertex-project", + }), + ).route.endpoint.baseURL, ).toBe("https://aiplatform.eu.rep.googleapis.com/v1beta1/projects/vertex-project/locations/eu/publishers/google") expect(messages.route.id).toBe("google-vertex-messages") expect(messages.route.protocol).toBe("anthropic-messages") @@ -310,8 +522,11 @@ describe("provider package entrypoints", () => { const Providers = await import("@opencode-ai/ai/providers") expect(() => Reflect.apply(GoogleVertex.model, undefined, [ - "gemini-3.5-flash", - { accessToken: "token", apiKey: "fixture", project: "vertex-project" }, + packageInput("gemini-3.5-flash", { + accessToken: "token", + apiKey: "fixture", + project: "vertex-project", + }), ]), ).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth") const configured = Reflect.apply(GoogleVertex.configure, undefined, [ @@ -320,8 +535,7 @@ describe("provider package entrypoints", () => { expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth") expect(() => Reflect.apply(GoogleVertexMessages.model, undefined, [ - "claude-sonnet-4-6", - { apiKey: "fixture", project: "vertex-project" }, + packageInput("claude-sonnet-4-6", { apiKey: "fixture", project: "vertex-project" }), ]), ).toThrow("Google Vertex Messages does not support API keys") expect(() => @@ -331,8 +545,7 @@ describe("provider package entrypoints", () => { ).toThrow("Google Vertex Messages does not support API keys") expect(() => Reflect.apply(GoogleVertexChat.model, undefined, [ - "deepseek-ai/deepseek-v3.2-maas", - { apiKey: "fixture", project: "vertex-project" }, + packageInput("deepseek-ai/deepseek-v3.2-maas", { apiKey: "fixture", project: "vertex-project" }), ]), ).toThrow("Google Vertex Chat does not support API keys") expect(() => @@ -342,8 +555,7 @@ describe("provider package entrypoints", () => { ).toThrow("Google Vertex Chat does not support API keys") expect(() => Reflect.apply(GoogleVertexResponses.model, undefined, [ - "xai/grok-4.20-reasoning", - { apiKey: "fixture", project: "vertex-project" }, + packageInput("xai/grok-4.20-reasoning", { apiKey: "fixture", project: "vertex-project" }), ]), ).toThrow("Google Vertex Responses does not support API keys") expect(() => diff --git a/packages/core/src/aisdk-native.ts b/packages/core/src/aisdk-native.ts index d23424a0d408..9d0174dbcdaf 100644 --- a/packages/core/src/aisdk-native.ts +++ b/packages/core/src/aisdk-native.ts @@ -20,16 +20,48 @@ export interface MapInput { export function map(input: MapInput): Mapping | undefined { const baseSettings = mapBaseSettings(input.settings) switch (input.packageName) { - case "@ai-sdk/anthropic": + 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 } : {}), + ...openAIOptions(input.settings), + }, + ...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}), + } + case "@ai-sdk/anthropic": { + const providerOptions = { + ...(isRecord(input.settings.thinking) ? { thinking: input.settings.thinking } : {}), + ...(typeof input.settings.effort === "string" ? { effort: input.settings.effort } : {}), + } return { package: "@opencode-ai/ai/providers/anthropic", settings: { ...baseSettings, ...mapAPIKey(input.settings), ...(typeof input.settings.authToken === "string" ? { authToken: input.settings.authToken } : {}), - ...mapProviderOptions(input.settings, ["apiKey", "authToken", "baseURL"]), + ...(Object.keys(providerOptions).length === 0 ? {} : { providerOptions }), }, + ...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}), } + } + case "@ai-sdk/openai-compatible": + return typeof input.settings.baseURL !== "string" + ? undefined + : { + package: "@opencode-ai/ai/providers/openai-compatible", + settings: { + ...baseSettings, + ...mapAPIKey(input.settings), + provider: input.providerID, + ...mapOpenAIOptions(input.settings), + }, + ...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}), + } case "@ai-sdk/amazon-bedrock": return { package: "@opencode-ai/ai/providers/amazon-bedrock", @@ -71,10 +103,7 @@ export function map(input: MapInput): Mapping | undefined { ...mapAPIKey(input.settings), ...(typeof input.settings.location === "string" ? { location: input.settings.location } : {}), ...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}), - ...mapGoogleOptions( - input.settings, - isStringRecord(input.settings.labels) ? { labels: input.settings.labels } : {}, - ), + ...mapGoogleOptions(input.settings), }, ...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}), } @@ -97,29 +126,6 @@ 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, ["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, ["apiKey", "baseURL"]), - }, - } case "@openrouter/ai-sdk-provider": return mapOpenRouter(input.settings, baseSettings) case "@ai-sdk/xai": @@ -134,12 +140,6 @@ export function map(input: MapInput): Mapping | undefined { } } -function mapProviderOptions(settings: Readonly>, excluded: ReadonlyArray) { - const options = Object.fromEntries(Object.entries(settings).filter(([name]) => !excluded.includes(name))) - if (Object.keys(options).length === 0) return {} - return { providerOptions: 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" @@ -260,17 +260,26 @@ function bedrockRegion(settings: Readonly>) { } function mapOpenAIOptions(settings: Readonly>) { + const options = openAIOptions(settings) + if (Object.keys(options).length === 0) return {} + return { providerOptions: options } +} + +function openAIOptions(settings: Readonly>) { const options = { + ...(typeof settings.instructions === "string" ? { instructions: settings.instructions } : {}), ...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}), ...(typeof settings.reasoningSummary === "string" ? { reasoningSummary: settings.reasoningSummary } : {}), ...(Array.isArray(settings.include) ? { include: settings.include } : {}), ...(typeof settings.store === "boolean" ? { store: settings.store } : {}), - ...(typeof settings.promptCacheKey === "string" ? { promptCacheKey: settings.promptCacheKey } : {}), ...(typeof settings.textVerbosity === "string" ? { textVerbosity: settings.textVerbosity } : {}), ...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}), + ...(typeof settings.truncation === "string" ? { truncation: settings.truncation } : {}), + ...(isRecord(settings.allowedTools) ? { allowedTools: settings.allowedTools } : {}), + ...(typeof settings.maxToolCalls === "number" ? { maxToolCalls: settings.maxToolCalls } : {}), + ...(typeof settings.parallelToolCalls === "boolean" ? { parallelToolCalls: settings.parallelToolCalls } : {}), } - if (Object.keys(options).length === 0) return {} - return { providerOptions: options } + return options } function mapBaseSettings(settings: Readonly>) { @@ -283,7 +292,7 @@ function mapAPIKey(settings: Readonly>) { return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {} } -function mapGoogleOptions(settings: Readonly>, extra: Readonly> = {}) { +function mapGoogleOptions(settings: Readonly>) { const input = settings.thinkingConfig const thinkingConfig = { ...(isRecord(input) && typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}), @@ -298,7 +307,6 @@ function mapGoogleOptions(settings: Readonly>, extra: Re ...(Array.isArray(settings.safetySettings) ? { safetySettings: settings.safetySettings } : {}), ...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}), ...(Object.keys(thinkingConfig).length > 0 ? { thinkingConfig } : {}), - ...extra, } if (Object.keys(options).length === 0) return {} return { providerOptions: options } diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index a8840a35fcc0..38e0dca5f932 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -1,7 +1,7 @@ export * as ModelResolver from "./model-resolver.js" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" -import { LanguageModel } from "@opencode-ai/ai" +import { LanguageModel, ProviderPackage } from "@opencode-ai/ai" import { Auth } from "@opencode-ai/ai/route" import { Context, Effect, Layer, Schema } from "effect" import { produce } from "immer" @@ -124,7 +124,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct 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 configured = Provider.mergeOverlay(resolved.settings, configuration) ?? {} const mapping = Provider.isAISDK(resolved.package) ? AISDKNative.map({ packageName, @@ -140,7 +140,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct const settings = yield* prepareProviderSettings( resolved, Provider.mergeOverlay(resolved.settings, { - ...nativeCredentialSettings(resolved.package ?? "", credential), + ...legacyCredentialSettings(credential), ...credential?.metadata, ...configuration, }) ?? {}, @@ -157,16 +157,18 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe( Effect.mapError(() => unsupported(resolved)), ) - const settings = { - ...(credential ? withoutNativeAuthSettings(mapped) : mapped), - ...nativeCredentialSettings(specifier, credential), - headers: Provider.mergeHeaders(mapping?.headers, resolved.headers), - body: Provider.mergeOverlay(mapping?.body, resolved.body), - limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output }, - } return yield* Effect.try({ try: () => { - const runtime = module.model(resolved.modelID ?? resolved.id, settings) + const runtime = module.model({ + id: resolved.modelID ?? resolved.id, + settings: mapped, + credential: providerCredential(credential), + defaults: { + headers: Provider.mergeHeaders(mapping?.headers, resolved.headers), + body: Provider.mergeOverlay(mapping?.body, resolved.body), + limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output }, + }, + }) return LanguageModel.update(runtime, { provider: resolved.providerID, compatibility: resolved.compatibility @@ -225,25 +227,23 @@ function unresolvedProviderVariables(model: Info, baseURL: string) { }) } -const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => { +const legacyCredentialSettings = (credential: Credential.Value | undefined) => { if (!credential) return {} if (credential.type === "key") return { apiKey: credential.key } - if ( - specifier === "@opencode-ai/ai/providers/anthropic" || - specifier === "@opencode-ai/ai/providers/anthropic-compatible" - ) - return { authToken: credential.access } - if ( - specifier === "@opencode-ai/ai/providers/google-vertex" || - specifier.startsWith("@opencode-ai/ai/providers/google-vertex/") - ) - return { accessToken: credential.access } return { apiKey: credential.access } } -const withoutNativeAuthSettings = (settings: Record) => { - const { accessToken: _accessToken, apiKey: _apiKey, authToken: _authToken, ...rest } = settings - return rest +const providerCredential = (credential: Credential.Value | undefined): ProviderPackage.Credential | undefined => { + if (!credential) return undefined + if (credential.type === "key" && credential.key.length === 0) return undefined + if (credential.type === "oauth" && credential.access.length === 0) return undefined + if (credential.type === "key") + return { + type: "key", + value: credential.key, + configuration: credential.configuration, + } + return { type: "oauth", accessToken: credential.access } } const unsupported = (model: Info) => diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 0b121785275c..738f1eca07af 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -171,7 +171,7 @@ const importPackage = Effect.fn("Provider.importPackage")(function* ( if (typeof module !== "object" || module === null || typeof (module as { model?: unknown }).model !== "function") { return yield* new LoadError({ package: specifier, - cause: new Error(`Provider package ${specifier} does not export model(modelID, settings)`), + cause: new Error(`Provider package ${specifier} does not export model(input)`), }) } return module as ProviderPackageDefinition diff --git a/packages/core/test/aisdk-native.test.ts b/packages/core/test/aisdk-native.test.ts index f234c28858ab..66e5ebb1e79d 100644 --- a/packages/core/test/aisdk-native.test.ts +++ b/packages/core/test/aisdk-native.test.ts @@ -22,14 +22,12 @@ describe("AISDKNative", () => { settings: { apiKey: "secret", baseURL: "https://api.meta.ai/v1", - providerOptions: { - reasoningEffort: "xhigh", - reasoningSummary: "auto", - include: ["reasoning.encrypted_content"], - instructions: "Follow the repository instructions.", - truncation: "auto", - }, organization: "org", + 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({ diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index 772abab0896b..7d8d0dca4210 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -66,10 +66,6 @@ function withEnv(variables: Record, effect: ) } -function withConfigEnv(env: Record, effect: () => Effect.Effect) { - return effect().pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))) -} - describe("ModelResolver", () => { it.effect("constructs native Azure requests with deployment IDs and projected resource URLs", () => Effect.gen(function* () { @@ -81,6 +77,22 @@ describe("ModelResolver", () => { }), Credential.Key.make({ type: "key", key: "secret" }), ) + const configuredCredential = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@ai-sdk/azure"), { + providerID: Provider.ID.azure, + modelID: "configured-deployment", + settings: { resourceName: "catalog-resource", apiVersion: "catalog-version" }, + }), + Credential.Key.make({ + type: "key", + key: "secret", + configuration: { + resourceName: "configured-resource", + apiVersion: "configured-version", + useDeploymentBasedUrls: true, + }, + }), + ) const chat = yield* ModelResolver.fromCatalogModel( model(Provider.aisdk("@ai-sdk/azure"), { providerID: Provider.ID.azure, @@ -118,6 +130,10 @@ describe("ModelResolver", () => { query: { "api-version": "2025-01-01-preview" }, }, }) + expect(configuredCredential.route.endpoint).toMatchObject({ + baseURL: "https://configured-resource.openai.azure.com/openai/deployments/configured-deployment", + query: { "api-version": "configured-version" }, + }) expect(chat).toMatchObject({ id: "chat-deployment", provider: "azure" }) expect(chat.route.id).toBe("azure-openai-chat") expect(deployment).toMatchObject({ id: "legacy-url-deployment", provider: "azure" }) @@ -139,11 +155,22 @@ describe("ModelResolver", () => { settings: { baseURL: "https://${AZURE_HOST}/openai" }, }), ) + const configured = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@ai-sdk/azure"), { + providerID: Provider.ID.azure, + }), + Credential.Key.make({ + type: "key", + key: "secret", + configuration: { baseURL: "https://${AZURE_HOST}/openai" }, + }), + ) expect(resolved.route.endpoint).toMatchObject({ baseURL: "https://resource.openai.azure.com/openai/v1", query: { "api-version": "v1" }, }) + expect(configured.route.endpoint.baseURL).toBe("https://resource.openai.azure.com/openai/v1") }), ), ) @@ -260,25 +287,33 @@ describe("ModelResolver", () => { }), ) - it.effect("treats an empty configured API key as omitted", () => - withConfigEnv({ 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" }), + it.effect("treats empty configured and selected API keys as omitted", () => + Effect.gen(function* () { + const resolved = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@ai-sdk/openai"), { + settings: { apiKey: "", baseURL: "https://openai.example/v1" }, + }), + ) + const selected = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@ai-sdk/openai"), { + settings: { baseURL: "https://openai.example/v1" }, + }), + Credential.Key.make({ type: "key", key: "" }), + ) + const headers = yield* Effect.forEach([resolved, selected], (model) => + model.route.auth.apply({ + request: LLM.request({ model, prompt: "Hello" }), method: "POST", url: "https://openai.example/v1/responses", body: "{}", headers: Headers.empty, - }) + }), + ).pipe( + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { OPENAI_API_KEY: "environment-key" } }))), + ) - expect(headers.authorization).toBe("Bearer environment-key") - }), - ), + expect(headers.map((item) => item.authorization)).toEqual(["Bearer environment-key", "Bearer environment-key"]) + }), ) it.effect("uses no native API-key auth for an explicitly enabled provider without credentials", () => { @@ -341,7 +376,7 @@ describe("ModelResolver", () => { }) const layer = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk))) - return withConfigEnv({}, () => + return withEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined }, () => Effect.gen(function* () { const resolver = yield* ModelResolver.Service const resolved = yield* resolver.resolveModel(selected) @@ -362,7 +397,7 @@ describe("ModelResolver", () => { }) it.effect("keeps native provider environment auth strict when no API key is configured", () => - withConfigEnv({}, () => + withEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined }, () => Effect.gen(function* () { const resolved = yield* ModelResolver.fromCatalogModel( model(Provider.aisdk("@ai-sdk/google"), { @@ -588,6 +623,43 @@ describe("ModelResolver", () => { }), ) + it.effect("lets the native Anthropic package distinguish key and OAuth credentials", () => + Effect.gen(function* () { + const catalog = model(Provider.aisdk("@ai-sdk/anthropic"), { + settings: { baseURL: "https://anthropic.example/v1" }, + }) + const key = yield* ModelResolver.fromCatalogModel( + catalog, + Credential.Key.make({ type: "key", key: "anthropic-key" }), + ) + const oauth = yield* ModelResolver.fromCatalogModel( + catalog, + Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("device"), + access: "anthropic-token", + refresh: "refresh", + expires: Date.now() + 60_000, + }), + ) + const input = (resolved: LanguageModel) => ({ + request: LLM.request({ model: resolved, prompt: "Hello" }), + method: "POST" as const, + url: "https://anthropic.example/v1/messages", + body: "{}", + headers: Headers.empty, + }) + + const keyHeaders = yield* key.route.auth.apply(input(key)) + const oauthHeaders = yield* oauth.route.auth.apply(input(oauth)) + + expect(keyHeaders["x-api-key"]).toBe("anthropic-key") + expect(keyHeaders.authorization).toBeUndefined() + expect(oauthHeaders.authorization).toBe("Bearer anthropic-token") + expect(oauthHeaders["x-api-key"]).toBeUndefined() + }), + ) + it.effect("uses resolved credentials for bearer auth", () => Effect.gen(function* () { const resolved = yield* ModelResolver.fromCatalogModel( @@ -692,6 +764,24 @@ describe("ModelResolver", () => { }), ) + it.effect("maps flat native OpenAI settings into provider options", () => + Effect.gen(function* () { + const resolved = yield* ModelResolver.fromCatalogModel( + model("@opencode-ai/ai/providers/openai", { + modelID: "gpt-5", + settings: { reasoningEffort: "high", store: true }, + }), + ) + + expect(resolved.route.defaults.providerOptions).toEqual({ + store: true, + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }) + }), + ) + it.effect("does not route native OpenAI-compatible packages to the codex backend", () => Effect.gen(function* () { const resolved = yield* ModelResolver.fromCatalogModel( @@ -778,15 +868,18 @@ describe("ModelResolver", () => { loadPackage: (specifier) => { expect(specifier).toBe("@opencode-ai/ai/providers/custom") return Effect.succeed({ - model: (modelID, settings) => { - expect(modelID).toBe("api-test-model") - expect(settings).toEqual({ - region: "test", - headers: { "x-package": "header" }, - body: { custom: true }, - limits: { context: 100, output: 20 }, + model: (input) => { + expect(input).toEqual({ + id: "api-test-model", + settings: { region: "test" }, + credential: undefined, + defaults: { + headers: { "x-package": "header" }, + body: { custom: true }, + limits: { context: 100, output: 20 }, + }, }) - return LanguageModel.make({ id: modelID, provider: "package-provider", route: native.route }) + return LanguageModel.make({ id: input.id, provider: "package-provider", route: native.route }) }, }) }, @@ -797,7 +890,7 @@ describe("ModelResolver", () => { }), ) - it.effect("maps OAuth credentials to native provider auth settings", () => + it.effect("passes OAuth credentials to native provider packages without interpreting them", () => Effect.gen(function* () { const native = yield* ModelResolver.fromCatalogModel( model(Provider.aisdk("@ai-sdk/openai"), { @@ -812,23 +905,23 @@ describe("ModelResolver", () => { expires: Date.now() + 60_000, }) const packages = [ - ["@opencode-ai/ai/providers/google-vertex", "accessToken"], - ["@opencode-ai/ai/providers/google-vertex/gemini", "accessToken"], - ["@opencode-ai/ai/providers/google-vertex/chat", "accessToken"], - ["@opencode-ai/ai/providers/google-vertex/responses", "accessToken"], - ["@opencode-ai/ai/providers/google-vertex/messages", "accessToken"], - ["@opencode-ai/ai/providers/anthropic", "authToken"], - ["@opencode-ai/ai/providers/anthropic-compatible", "authToken"], + "@opencode-ai/ai/providers/google-vertex", + "@opencode-ai/ai/providers/google-vertex/gemini", + "@opencode-ai/ai/providers/google-vertex/chat", + "@opencode-ai/ai/providers/google-vertex/responses", + "@opencode-ai/ai/providers/google-vertex/messages", + "@opencode-ai/ai/providers/anthropic", + "@opencode-ai/ai/providers/anthropic-compatible", ] as const - yield* Effect.forEach(packages, ([specifier, key]) => + yield* Effect.forEach(packages, (specifier) => ModelResolver.fromCatalogModel(model(specifier, { settings: { apiKey: "configured-key" } }), credential, { loadPackage: () => Effect.succeed({ - model: (modelID, settings) => { - expect(settings).toMatchObject({ [key]: "oauth-token" }) - expect(settings).not.toHaveProperty("apiKey") - return LanguageModel.make({ id: modelID, provider: "package-provider", route: native.route }) + model: (input) => { + expect(input.settings).toEqual({ apiKey: "configured-key" }) + expect(input.credential).toEqual({ type: "oauth", accessToken: "oauth-token" }) + return LanguageModel.make({ id: input.id, provider: "package-provider", route: native.route }) }, }), }), @@ -858,41 +951,41 @@ describe("ModelResolver", () => { "@ai-sdk/anthropic", "@opencode-ai/ai/providers/anthropic", { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, - { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + { providerOptions: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" } }, ], [ "@ai-sdk/openai-compatible", "@opencode-ai/ai/providers/openai-compatible", { reasoningEffort: "high" }, - { reasoningEffort: "high" }, + { provider: "test-provider", providerOptions: { reasoningEffort: "high" } }, ], [ "@ai-sdk/google", "@opencode-ai/ai/providers/google", { thinkingConfig: { thinkingLevel: "high" } }, - { thinkingConfig: { thinkingLevel: "high" } }, + { providerOptions: { thinkingConfig: { thinkingLevel: "high" } } }, ], [ "@ai-sdk/google-vertex", "@opencode-ai/ai/providers/google-vertex", { thinkingConfig: { thinkingLevel: "high" } }, - { thinkingConfig: { thinkingLevel: "high" } }, + { providerOptions: { thinkingConfig: { thinkingLevel: "high" } } }, ], [ "@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", { reasoning: { effort: "high" } }, - { reasoning: { effort: "high" } }, + { providerOptions: { reasoning: { effort: "high" } } }, ], [ "@ai-sdk/xai", "@opencode-ai/ai/providers/xai", { reasoningEffort: "high" }, - { reasoningEffort: "high" }, + { providerOptions: { reasoningEffort: "high" } }, ], ] as const - yield* Effect.forEach(packages, ([catalogPackage, nativePackage, sourceOptions, providerOptions]) => + yield* Effect.forEach(packages, ([catalogPackage, nativePackage, sourceOptions, mappedSettings]) => ModelResolver.fromCatalogModel( model(Provider.aisdk(catalogPackage), { modelID: "api-model", @@ -905,17 +998,19 @@ describe("ModelResolver", () => { loadPackage: (specifier) => { expect(specifier).toBe(nativePackage) return Effect.succeed({ - model: (modelID, settings) => { - expect(modelID).toBe("api-model") - expect(settings).toMatchObject({ - apiKey: "secret", + model: (input) => { + expect(input.id).toBe("api-model") + expect(input.settings).toMatchObject({ baseURL: "https://provider.example/v1", + ...mappedSettings, + }) + expect(input.credential).toEqual({ type: "key", value: "secret" }) + expect(input.defaults).toEqual({ headers: { "x-provider": "header" }, body: { custom: true }, limits: { context: 100, output: 20 }, - providerOptions, }) - return LanguageModel.make({ id: modelID, provider: "native-provider", route: native.route }) + return LanguageModel.make({ id: input.id, provider: "native-provider", route: native.route }) }, }) }, @@ -939,11 +1034,7 @@ describe("ModelResolver", () => { ["@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/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"], @@ -961,7 +1052,8 @@ describe("ModelResolver", () => { loadPackage: (specifier) => { expect(specifier).toBe(nativePackage) return Effect.succeed({ - model: (id) => LanguageModel.make({ id, provider: "native-provider", route: OpenAIChat.route }), + model: (input) => + LanguageModel.make({ id: input.id, provider: "native-provider", route: OpenAIChat.route }), }) }, loadAISDK: () => Effect.die(`AI SDK loader called for ${catalogPackage}`), @@ -997,10 +1089,9 @@ describe("ModelResolver", () => { loadPackage: (specifier) => { expect(specifier).toBe("@opencode-ai/ai/providers/google-vertex/messages") return Effect.succeed({ - model: (modelID, settings) => { - expect(modelID).toBe("claude-sonnet-4-6") - expect(settings).toMatchObject({ - accessToken: "vertex-token", + model: (input) => { + expect(input.id).toBe("claude-sonnet-4-6") + expect(input.settings).toMatchObject({ location: "eu", project: "vertex-project", providerOptions: { @@ -1008,7 +1099,8 @@ describe("ModelResolver", () => { effort: "high", }, }) - return LanguageModel.make({ id: modelID, provider: "native-provider", route: native.route }) + expect(input.credential).toEqual({ type: "oauth", accessToken: "vertex-token" }) + return LanguageModel.make({ id: input.id, provider: "native-provider", route: native.route }) }, }) }, @@ -1035,16 +1127,16 @@ describe("ModelResolver", () => { { loadPackage: () => Effect.succeed({ - model: (modelID, settings) => { - expect(settings.headers).toEqual({ + model: (input) => { + expect(input.defaults.headers).toEqual({ "HTTP-Referer": "https://opencode.ai", "X-OpenRouter-Title": "Custom", }) - expect(settings.body).toEqual({ + expect(input.defaults.body).toEqual({ transforms: ["middle-out"], provider: { sort: "price", only: ["anthropic"] }, }) - return LanguageModel.make({ id: modelID, provider: "openrouter", route: OpenAIChat.route }) + return LanguageModel.make({ id: input.id, provider: "openrouter", route: OpenAIChat.route }) }, }), },