docs/ai-gateway-provider-selection.md
How the AI gateway picks which attached provider handles a request when an inference resource has more than one AI provider.
Code:
server/routers/aiGateway/createAiGatewayRouter.tsserver/routers/aiGateway/pipeline.ts (selectProvider)server/routers/aiGateway/v1Models.ts and
server/lib/aiModelDiscovery.tsserver/lib/aiProviderSelection.tsserver/lib/aiModelKeyMatch.tsserver/lib/aiModelCatalog.tsserver/lib/aiProviderDefaults.tsOverlapping model allows are permitted at save time. Selection happens at
request time. If the algorithm cannot confidently pick one provider, the
gateway returns 403 with an ambiguous-provider error.
Every gateway request runs through these steps in order. Each step narrows the candidate set. Later steps only run when more than one provider remains.
1. Capability filter
2. Allow / block lists
3. Most specific allow pattern
4. Catalog ownership
5. Provider class preference
6. Ambiguous → error
The incoming path selects a capability before any provider logic runs.
| Path | Capability |
|---|---|
POST /v1/chat/completions | openai_chat |
POST /v1/responses | openai_responses |
POST /v1/messages | anthropic_messages |
GET /v1/models, GET /v1/models/{id} | v1_models |
| Gemini / Vertex / Bedrock routes | their respective capability ids |
Only attached providers that advertise that capability stay in the candidate set. Default capabilities do not overlap for native OpenAI vs Anthropic:
| Provider type | Default capabilities |
|---|---|
openai | openai_chat, openai_responses |
anthropic | anthropic_messages, v1_models |
openRouter | openai_chat |
vercelAiGateway | openai_chat, openai_responses |
microsoftFoundry | openai_chat, openai_responses, anthropic_messages, v1_models |
custom | whatever was configured |
For each remaining provider, the gateway resolves the effective allow and block patterns:
inherit: use the provider's own model listsselect: use the resource-selected subset of those listsA candidate is kept only if isAllowedByLists(requestedModel, allows, blocks)
passes:
Patterns support * and ? globs (gpt-*, claude-3-5-sonnet-?).
Among providers that allow the model, keep those whose matching allow pattern is most specific:
Example: gpt-4o beats gpt-* beats *.
When specificity is tied (common with multiple * allows), score each
provider against the known model catalog:
| Score | Meaning |
|---|---|
| 2 | Typed provider whose catalog contains the model (openai → openai catalog, anthropic → anthropic, etc.) |
| 1 | Aggregator or custom (openRouter, vercelAiGateway, custom) and the model exists somewhere in the catalog |
| 0 | No ownership signal (typed catalog miss, or unknown model on aggregator/custom) |
Model id lookup tries the raw id, then a stripped vendor/model form
(e.g. openai/gpt-4o → also try gpt-4o).
Typed providers map to catalog providers as:
| Provider type | Catalog |
|---|---|
openai | openai |
anthropic | anthropic |
googleGemini | gemini |
vertexAi | vertex |
bedrock | bedrock |
microsoftFoundry | azure |
openRouter / vercelAiGateway / custom | none (aggregator/custom path) |
If catalog ownership is still tied, prefer:
| Rank | Class |
|---|---|
| 2 | Native typed provider (openai, anthropic, googleGemini, …) |
| 1 | Aggregator (openRouter, vercelAiGateway) |
| 0 | custom |
If more than one distinct provider remains after all steps, the gateway rejects the request:
Model "<id>" is ambiguous across multiple AI providers on this resource
Typical remaining ties: two OpenAI-type providers both with *, or two
customs advertising the same capability for an unknown model.
GET /v1/models and GET /v1/models/{id} (v1_models) skip steps 3-6
entirely. There is no requested model to disambiguate on, so the gateway does
not pick one provider - it returns the union of what every attached
provider advertising v1_models would accept, deduplicated by model id
(lowest providerId wins a collision).
Discovery is answered from the gateway's own view of the allow/block lists,
never proxied upstream. Providers that expose no /v1/models endpoint of their
own still get a working listing, and a model an allow/block list forbids is
never advertised.
Each provider's candidate ids come from two places:
| Source | Contributes |
|---|---|
| Exact (non-wildcard) allow entries | the model key itself |
| The model catalog for the provider's type | every catalog id matching an allow pattern |
Both sources are then filtered through the same
isAllowedByLists(id, allows, blocks) check step 2 applies, so a block pattern
hides a model from discovery exactly as it would reject it at request time.
The catalog source is what makes a wildcard allow such as claude-*
enumerable. Provider types with no catalog mapping (openRouter,
vercelAiGateway, custom) have nothing to expand against, so a wildcard
allow on those types lists nothing - add exact allow entries to make their
models discoverable.
Token limits and capability flags can't be derived from an allow/block list.
They come from the model catalog (server/lib/aiModelCatalog.ts), which the
Fossorial API builds from LiteLLM:
| Field | Source |
|---|---|
max_input_tokens | catalog limits.input |
max_tokens | catalog limits.output |
capabilities | catalog flags, mapped to the Models API shape by capabilitiesFromCatalog |
display_name | the configured model row's name, else the model id |
created_at | the configured model row's timestamp, else the epoch |
A model the catalog doesn't know (an exact allow entry for a fine-tune, say)
reports null for all three metadata fields. The Models API declares them
nullable, so that is a valid answer rather than a broken one.
The catalog's flags are coarser than the Models API describes: it carries a
single reasoning flag with no way to distinguish adaptive from
budget_tokens-style thinking, and nothing at all for batch, citations, code
execution, PDF input, or context management. Anything it reports as unknown
(null) is surfaced as unsupported rather than invented, so capabilities
understates rather than overstates what a model can do.
The gateway does not query the provider's own /v1/models. Discovery is
answered entirely from local state.
Results are ordered newest-first with the id as tie-break, and paginated with
Anthropic's limit / after_id / before_id semantics (default 20, max
1000).
Assume each provider below is attached and enabled on the same inference resource.
*| Provider | Allow | Capabilities |
|---|---|---|
| OpenAI | * | openai_chat, openai_responses |
| Anthropic | * | anthropic_messages |
Request: POST /v1/chat/completions with model: "gpt-4o"
*Anthropic never reaches pattern or catalog scoring. Capability alone decides.
Request: POST /v1/messages with model: "claude-3-5-sonnet-latest"
*| Provider | Allow | Capabilities |
|---|---|---|
| OpenAI | * | openai_chat, … |
| OpenRouter | * | openai_chat |
Request: POST /v1/chat/completions with model: "gpt-4o"
openai_chat)** vs *)2 (owns gpt-4o); OpenRouter scores 1| Provider | Allow | Capabilities |
|---|---|---|
| OpenRouter | * | openai_chat |
Request: POST /v1/chat/completions with model: "anthropic/claude-3.5-sonnet"
No tie-breaking needed.
gpt-*) + OpenRouter (*)| Provider | Allow |
|---|---|
| OpenAI | gpt-* |
| OpenRouter | * |
Request: model: "gpt-4o" on openai_chat
gpt-* beats OpenRouter's *Catalog scoring is not needed because specificity already unique'd the set.
Someone grants Anthropic openai_chat as well (non-default).
| Provider | Allow | Capabilities |
|---|---|---|
| OpenAI | * | openai_chat, … |
| Anthropic | * | anthropic_messages, openai_chat |
Request: POST /v1/chat/completions with model: "gpt-4o"
*2, Anthropic 0 (gpt-4o is not in the anthropic catalog)| Provider | Allow |
|---|---|
| OpenRouter | * |
| Vercel AI Gateway | * |
Request: model: "gpt-4o" on openai_chat
1 (known model, no typed owner in the set)1) → still tiedAttach a native OpenAI provider (or narrow one aggregator's allow list) to make this determinable.
*| Provider | Type | Allow |
|---|---|---|
| OpenAI Prod | openai | * |
| OpenAI Staging | openai | * |
Request: model: "gpt-4o"
1–5 all leave both candidates (same capability, same specificity, same catalog ownership, same class).
Result → ambiguous error
Disambiguate with different allow patterns, disable one attachment, or split across resources.
| Provider | Allow |
|---|---|
| OpenAI | * |
| OpenRouter | * |
Request: model: "my-fine-tune-v3" (not in catalog)
0 (typed miss + unknown aggregator model)2) beats OpenRouter (1)* is safe. Different default APIs never
collide.* is usually fine for catalog-known OpenAI
models. Native wins.gpt-4o, gpt-*) when two providers share
a capability.* will stay ambiguous. Narrow
at least one allow list.