Skip to content

fix(provider): explicit escape hatch for gateway providerOptions namespacing - #47985

Open
codylandry wants to merge 1 commit into
anomalyco:devfrom
codylandry:fix/gateway-provider-options-namespace
Open

fix(provider): explicit escape hatch for gateway providerOptions namespacing#47985
codylandry wants to merge 1 commit into
anomalyco:devfrom
codylandry:fix/gateway-provider-options-namespace

Conversation

@codylandry

@codylandry codylandry commented Sep 8, 2026

Copy link
Copy Markdown

Issue for this PR

Closes #47987

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Fixes #47987: ProviderTransform.providerOptions() (in the @ai-sdk/gateway branch) buckets every non-gateway top-level option under a single key derived from the gateway model-ID prefix (model.api.id.split("/")[0], via SLUG_OVERRIDES).

When that prefix is an internal routing alias rather than a recognized AI SDK provider slug (e.g. a centrally-configured virtual-model-catalog ID that isn't literally "openai" or "anthropic"), a caller has no way to address a specific upstream provider's providerOptions namespace directly. An explicit, already-correctly-namespaced option like:

{ gateway: { zeroDataRetention: true }, openai: { store: false } }

gets folded entirely into the alias slug's bucket instead — so the AI SDK provider package that reads providerOptions.openai never sees it. This breaks any setup that needs OpenAI-specific options (e.g. store: false for zero-data-retention / stateless-reasoning workflows) on a gateway model whose ID isn't already prefixed with a recognized provider slug.

Fix: add a reserved options.providerOptions wrapper as an explicit escape hatch:

{
  gateway: { zeroDataRetention: true },
  providerOptions: { openai: { store: false } },
}
  • Only a plain-object value under the reserved key counts as the explicit form. It's extracted before any legacy bucketing logic runs, and merged into the final result after the legacy result is built (merged into, not clobbering, any same-named legacy-bucketed slug).
  • 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 a non-object value literally named providerOptions) keeps 100% byte-for-byte identical legacy bucketing behavior.
  • Namespace keys under the wrapper are not restricted to a fixed provider registry — whatever name the caller supplies passes through, so this also covers providers this file doesn't special-case.

Design alternatives considered:

  1. Key-name/value-shape heuristic across the existing options bag (treat any key matching a known provider name, if object-valued, as explicit). Rejected: options is a public, fully untyped Record<string, any> bag end-to-end, so a heuristic based on a coincidental key name can't be made fully unambiguous.
  2. Sibling model.providerOptions field, structurally separate from model.options, threaded through the config schema and model type. This is the strictly collision-free option, but requires changing the Provider.Model type, the config schema, and every caller that assembles the options object — a much larger surface than this bug warrants.

The reserved-key wrapper (this PR) is a deliberate, documented compromise: it reserves the literal key name providerOptions within the existing bag. Repo-wide search found zero existing callers, tests, or documented config examples using that key name today, so the reservation is currently uncontested.

How did you verify your code works?

  • Added 7 unit tests to packages/opencode/test/provider/transform.test.ts covering: a legacy flat option named openai (no wrapper) still buckets under the model slug unchanged; options.providerOptions.openai/.anthropic pass through at the top level under an unrecognized alias slug; explicit namespace merges with (not clobbers) a same-named legacy-bucketed slug; canonical (non-aliased) recognized-slug behavior stays unchanged; and a scalar/array value under the reserved key falls back to legacy bucketing instead of being silently dropped.
  • Verified red against the true pre-fix dev source (4/7 new tests fail, reproducing the exact bug; 3/7 correctly pass as pins on unchanged behavior) and green after the fix (bun test test/provider/transform.test.ts → 568 pass, 0 fail, including all 561 pre-existing tests unmodified).
  • tsgo --noEmit across the full workspace passes (ran automatically via the repo's pre-push hook, 30/30 tasks).

Screenshots / recordings

N/A — pure function change, no UI.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

…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<string, any> 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.
@github-actions github-actions Bot added needs:compliance This means the issue will auto-close after 2 hours. needs:issue labels Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Thanks for your contribution!

This PR doesn't have a linked issue. All PRs must reference an existing issue.

Please:

  1. Open an issue describing the bug/feature (if one doesn't exist)
  2. Add Fixes #<number> or Closes #<number> to this PR description

See CONTRIBUTING.md for details.

@github-actions github-actions Bot removed needs:issue needs:compliance This means the issue will auto-close after 2 hours. labels Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Thanks for updating your PR! It now meets our contributing guidelines. 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: gateway providerOptions drops explicit provider namespace under an unrecognized model-ID alias

1 participant