provider.only / provider.ignore are now enforced — the data-protection control that did nothing

2026-08-08

routercompletions-apiexplain

LOCKSTEP TRACEABILITY MATRIX --- api_endpoints: ["POST /v1/chat/completions", "POST /v1/messages", "GET /v1/explain/{request_id}"] sdk_methods_updated: ["none (ProviderFilter type docs + capability errors[] only)"] mcp_tools_updated: ["none"] ---

What We Built

provider.only and provider.ignore in the chat-completions body were strictly validated — an unknown provider name returned a 400 with a did-you-mean list — and then silently discarded. Prompt content was transmitted to processors the caller had explicitly excluded, with HTTP 200 and no warning. Reproduced live, cache-miss, in both directions: only: ["anthropic"] on an OpenAI model served OpenAI 3/3; only: ["google"] served openai/gpt-4.1-nano 3/3.

The mechanism was a name mismatch. src/router/provider-filter.ts already exported applyProviderFilter — a complete enforcement implementation with zero production callers. body.provider reached routing only through the routeOverrides bag, which reads the different key names providerAllow / providerDeny. Validated under one name, consumed under another, so nothing constrained anything.

The filter is now an inviolable constraint, not a preference: it is resolved once at the route, threaded into route.providerAllow, enforced at selection, honored through the BYOK fallback cascade, and re-asserted against the endpoint that is actually called. A request that cannot be served inside its filter FAILS.

Why It Matters

A production review scored data protection 2/10 on this single defect. A control that looks like it works — it validates! it has a nice error message! — but silently does nothing is worse than an absent control, because callers build compliance posture on it. The same class of bug had already been found and fixed for provider.zdr, whose refusal message then told callers to "restrict routing explicitly with provider.only" — recommending the broken control. That text is corrected too.

How It Works

Four enforcement points, one shared implementation:

  1. Route resolution. resolveProviderFilterEnforcement (a thin wrapper over the

existing applyProviderFilter) intersects the request with the providers the registry can actually reach and collapses ignore into its complement, so there is one allow-list shape to enforce and one to evidence.

  1. Selection. The resolved allow list becomes route.providerAllow, which

checkEndpointConstraints already treats as a hard constraint the relaxation path never drops. The auto-selector's candidate pool is narrowed by the same set (getExcludedEndpointIds), so auto picks an in-filter model rather than being refused at the boundary.

  1. BYOK cascade. findByokAlternativeEndpoint filters candidates through the same

constraints; when the tenant's only usable key belongs to an excluded provider, it returns no alternative and the BYOK error propagates.

  1. Execution boundary (B3). assertEndpointEligible re-checks the endpoint that

will actually be called, so any future recovery path fails closed (ProviderFilterViolationError, 403) instead of leaking.

Empty intersection is a refusal, never a fallback: 400 provider_filter_unsatisfiable naming the requested and available providers. Agentic and swarm modes assemble their own model calls and cannot honor the filter, so they are refused (400 provider_filter_unsupported_mode) rather than run with it ignored.

Evidence, because a control that cannot be proven is indistinguishable from one that was discarded: the enforced set is echoed on X-BR-Provider-Filter, recorded on the decision trace (content-free — provider ids only), and surfaced as provider_filter on GET /v1/explain/{request_id}.

The Numbers

  • 3 leak paths closed (selection, BYOK cascade, execution boundary).
  • 3 new declared error codes on completions.create.
  • 39 new tests across 4 files; every behavioral assertion verified to fail with the

fix reverted, each paired with a positive control.

Competitive Edge

OpenRouter's provider.only is a routing preference. Here it is a contractual constraint with a refusal path and an audit trail — the request fails rather than degrading, and /v1/explain can prove after the fact which processors were permitted to see the prompt.

Preventing the class

Validation and enforcement were coupled by hand, per field, so any new field could repeat this. PROVIDER_FILTER_FIELDS is a Record runtime witness: the compiler rejects a new field that is not listed, and provider-filter-contract.test.ts iterates those keys and requires a positive-control probe proving each one observably changes behavior. A validated-but-inert field can no longer be added without a test failing.

Lockstep Checklist

  • [x] API Routes: src/api/routes/completions/index.ts +

src/api/capabilities/completions/completions.ts (errors[] now declares the three new failure modes; regenerated docs/openapi.yaml).

  • [x] TS SDK: packages/sdk-ts/src/types.tsProviderFilter documents enforced

semantics and the new error codes. No method change: errors surface generically through ApiError, and provider is passed in the request body.

  • [x] Python SDK: verified no change needed —

generated/resources/completions.py takes body: Dict[str, Any] (untyped passthrough, auto-generated, marked do-not-edit) and errors are raised generically by _resource.py.

  • [x] MCP Schemas: not agent-facing — no MCP tool wraps chat completions'

provider field.

  • [ ] Master Record: not updated — this is a defect fix to an existing documented

capability, not a new one.