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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 40 additions & 6 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1405,16 +1405,50 @@ const SLUG_OVERRIDES: Record<string, string> = {
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<string, any>, 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:
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
107 changes: 107 additions & 0 deletions packages/opencode/test/provider/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading