From b08dd5f8c1f5e19484d2efbc5ff2444acd5a0b2b Mon Sep 17 00:00:00 2001 From: Cody Landry Date: Tue, 8 Sep 2026 11:42:14 -0400 Subject: [PATCH] fix(provider): add explicit escape hatch for gateway providerOptions namespacing ProviderTransform.providerOptions() buckets every non-`gateway` top-level option under a single key derived from the gateway model-ID prefix (`model.api.id.split("/")[0]`). When that prefix is an internal routing alias rather than a recognized AI SDK provider slug, a caller has no way to address a specific upstream provider's providerOptions namespace directly (e.g. `openai: { store: false }` or `anthropic: { thinking: {...} }`) -- it always gets folded into the alias slug's bucket instead, so the AI SDK provider package reading `providerOptions.openai` (or `.anthropic`) never sees it. Add a reserved `options.providerOptions` wrapper. Only a plain-object value under that key is treated as the explicit escape hatch: it's extracted before any legacy bucketing logic runs and merged into the final result verbatim (keyed by whatever name the caller supplies, not limited to a fixed provider registry) after the legacy result is built. A non-object value under the same key -- or no `providerOptions` key at all -- leaves the legacy options bag completely untouched, so every other key (including one that already happens to share a name with an upstream provider, or even a non-object value stored under the literal name `providerOptions` itself) keeps 100% byte-for-byte identical legacy bucketing behavior. This was chosen over two alternatives: (1) a key-name/value-shape heuristic across the existing untyped options bag, rejected because that bag is a public Record end-to-end with no structural way to distinguish caller intent from a coincidental key name; (2) a sibling `model.providerOptions` field threaded through the Provider.Model type, the config schema, and every caller that assembles options -- rejected as disproportionate surface area for a single-purpose namespace-bucketing fix. The reserved-key reservation is an accepted, documented compromise: it assumes no caller uses the literal key `providerOptions` for anything other than this escape hatch, which is true of every existing caller and documented config example today. No behavior change for any existing caller: all 561 pre-existing tests in packages/opencode/test/provider/transform.test.ts pass unmodified. Added 7 tests: legacy same-named flat option unchanged, explicit openai/anthropic namespace passthrough, merge-not-clobber with a same-named legacy slug, canonical (non-aliased) recognized-slug behavior unchanged, and scalar/array values under the reserved key falling back to legacy bucketing instead of being silently dropped. --- packages/opencode/src/provider/transform.ts | 46 +++++++- .../opencode/test/provider/transform.test.ts | 107 ++++++++++++++++++ 2 files changed, 147 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index f0be2d153751..ba5e409e2f1f 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1405,16 +1405,50 @@ const SLUG_OVERRIDES: Record = { amazon: "bedrock", } +// Reserved escape hatch: a caller that already knows exactly which upstream +// provider namespace(s) it's targeting can nest them under this key instead +// of relying on model-ID-prefix-derived slug inference. Consumed and +// stripped before any legacy bucketing logic runs, so the legacy `options` +// bag keeps byte-for-byte identical bucketing semantics for every other +// key -- including one that happens to share a name with an upstream +// provider (e.g. a flat `openai` option would still bucket under the +// model-derived slug exactly as before). Values here are merged into the +// final result verbatim, keyed by whatever name the caller supplies (not +// limited to a fixed provider registry), after the legacy result is built. +const EXPLICIT_PROVIDER_OPTIONS_KEY = "providerOptions" + +function mergeExplicitProviderOptions(result: Record, explicit: JsonRecord | undefined) { + if (!explicit) return result + for (const [k, v] of Object.entries(explicit)) { + result[k] = isPlainObject(result[k]) && isPlainObject(v) ? { ...result[k], ...v } : v + } + return result +} + export function providerOptions(model: Provider.Model, options: { [x: string]: any }) { + // Only a plain-object value under the reserved key counts as the explicit + // escape hatch -- everywhere else in this file, a providerOptions + // namespace IS an options bag, so a non-object value here can only be an + // unrelated flat legacy option that happens to share the reserved name. + // Leave it in legacyOptions untouched so it still gets bucketed exactly + // like any other flat option, instead of being silently dropped. + const rawExplicit = options[EXPLICIT_PROVIDER_OPTIONS_KEY] + const explicitProviderOptions = isPlainObject(rawExplicit) ? rawExplicit : undefined + const legacyOptions = + explicitProviderOptions === undefined + ? options + : Object.fromEntries(Object.entries(options).filter(([k]) => k !== EXPLICIT_PROVIDER_OPTIONS_KEY)) const usesOpenAIReasoningGate = model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure" || model.api.npm === "@ai-sdk/amazon-bedrock/mantle" const normalized = usesOpenAIReasoningGate && - (model.capabilities.reasoning || options.reasoningEffort !== undefined || options.reasoningSummary !== undefined) - ? { ...options, forceReasoning: true } - : anthropicBlockBinding(model, options) + (model.capabilities.reasoning || + legacyOptions.reasoningEffort !== undefined || + legacyOptions.reasoningSummary !== undefined) + ? { ...legacyOptions, forceReasoning: true } + : anthropicBlockBinding(model, legacyOptions) if (model.api.npm === "@ai-sdk/gateway") { // Gateway providerOptions are split across two namespaces: @@ -1443,7 +1477,7 @@ export function providerOptions(model: Provider.Model, options: { [x: string]: a } } - return result + return mergeExplicitProviderOptions(result, explicitProviderOptions) } // AI SDK packages that resolve providerOptionsName by splitting the @@ -1460,9 +1494,9 @@ export function providerOptions(model: Provider.Model, options: { [x: string]: a // providerOptions["openai"], but OpenAIResponsesLanguageModel checks // "azure" first. Pass both so model options work on either code path. if (model.api.npm === "@ai-sdk/azure") { - return { openai: normalized, azure: normalized } + return mergeExplicitProviderOptions({ openai: normalized, azure: normalized }, explicitProviderOptions) } - return { [key]: normalized } + return mergeExplicitProviderOptions({ [key]: normalized }, explicitProviderOptions) } export function maxOutputTokens(model: Provider.Model, outputTokenMax = OUTPUT_TOKEN_MAX): number { diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 2f3e4a10c646..6493c8feff13 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1364,6 +1364,113 @@ describe("ProviderTransform.providerOptions", () => { groq: { reasoningFormat: "parsed" }, }) }) + + describe("explicit options.providerOptions escape hatch", () => { + // The model-ID prefix here ("acme") stands in for any internal routing + // alias that is not a recognized AI SDK provider slug (e.g. a + // centrally-configured virtual model catalog id). + const aliasModel = (apiId: string) => + createModel({ + providerID: "vercel", + api: { id: apiId, url: "https://ai-gateway.vercel.sh/v3/ai", npm: "@ai-sdk/gateway" }, + }) + + test("a legacy flat option named openai still buckets under the model slug, unchanged", () => { + // Byte-for-byte legacy semantics: a plain (non-reserved-key) option + // that happens to share a name with an upstream provider is NOT + // treated specially. It bucket exactly like any other flat option + // did before this feature existed. + const model = aliasModel("acme/gpt-5.1") + expect( + ProviderTransform.providerOptions(model, { + gateway: { zeroDataRetention: true }, + openai: { store: false }, + }), + ).toEqual({ + gateway: { zeroDataRetention: true }, + acme: { openai: { store: false } }, + }) + }) + + test("options.providerOptions.openai passes through at the top level under an unrecognized alias slug", () => { + const model = aliasModel("acme/gpt-5.1") + expect( + ProviderTransform.providerOptions(model, { + gateway: { zeroDataRetention: true }, + providerOptions: { openai: { store: false } }, + }), + ).toEqual({ + gateway: { zeroDataRetention: true }, + openai: { store: false }, + }) + }) + + test("options.providerOptions.anthropic passes through at the top level under an unrecognized alias slug", () => { + const model = aliasModel("acme/claude-sonnet-5") + expect( + ProviderTransform.providerOptions(model, { + gateway: { zeroDataRetention: true }, + providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: 4000 } } }, + }), + ).toEqual({ + gateway: { zeroDataRetention: true }, + anthropic: { thinking: { type: "enabled", budgetTokens: 4000 } }, + }) + }) + + test("merges with, rather than clobbers, a same-named legacy-bucketed slug", () => { + const model = aliasModel("acme/gpt-5.1") + expect( + ProviderTransform.providerOptions(model, { + gateway: { zeroDataRetention: true }, + reasoningEffort: "high", + providerOptions: { acme: { extra: "flag" } }, + }), + ).toEqual({ + gateway: { zeroDataRetention: true }, + acme: { reasoningEffort: "high", extra: "flag" }, + }) + }) + + test("preserves canonical (non-aliased) recognized-slug behavior unchanged", () => { + const model = aliasModel("openai/gpt-5.1") + expect( + ProviderTransform.providerOptions(model, { + gateway: { zeroDataRetention: true }, + providerOptions: { openai: { store: false } }, + }), + ).toEqual({ + gateway: { zeroDataRetention: true }, + openai: { store: false }, + }) + }) + + test("a scalar value under the reserved key is not silently dropped, and still buckets under the model slug like any other flat option", () => { + const model = aliasModel("acme/gpt-5.1") + expect( + ProviderTransform.providerOptions(model, { + gateway: { zeroDataRetention: true }, + providerOptions: "not-an-options-object", + }), + ).toEqual({ + gateway: { zeroDataRetention: true }, + acme: { providerOptions: "not-an-options-object" }, + }) + }) + + test("an array value under the reserved key is not silently dropped, and still buckets under the model slug like any other flat option", () => { + const model = aliasModel("acme/gpt-5.1") + expect( + ProviderTransform.providerOptions(model, { + gateway: { zeroDataRetention: true }, + providerOptions: ["not", "an", "options", "object"], + }), + ).toEqual({ + gateway: { zeroDataRetention: true }, + acme: { providerOptions: ["not", "an", "options", "object"] }, + }) + }) + }) }) describe("ProviderTransform.schema - gemini array items", () => {