Rolter sits between your application and one or more AI backends. You teach it where to forward traffic by declaring providers (the upstream services) and routes (the rules that decide which provider handles each model request). Together they give you a clean public API surface — your code always calls the same model name — while the gateway handles routing, failover, and load balancing behind the scenes.
Providers#
A provider is an upstream AI service that Rolter can forward requests to. Each provider entry names the service, tells Rolter which wire protocol it speaks (kind), and supplies the base URL and API key.
| Field | Required | Description |
|---|---|---|
name | ✅ | Unique identifier referenced by routes and targets |
kind | ✅ | Wire protocol: openai, anthropic, or openai_compatible |
api_base | ✅ | Base URL without a trailing slash, e.g. https://api.openai.com |
api_key_env | — | Name of an env var holding the upstream API key (preferred over api_key) |
api_key | — | Inline API key — avoid in committed config files |
egress_proxy | — | Optional outbound proxy URL (http://, https://, socks5://, or socks5h://) |
Providers, provider groups and routes created through the dashboard or the API belong to an organization. Only that organization’s virtual keys can use them — by route name, as provider-slug/model, or through a group as group-slug/model — and a route target or group member must be a provider of the same organization. Route names and provider names are unique across the whole deployment for now, and provider slugs and provider group slugs share one namespace, so a provider cannot take a slug a group already has and the other way round. A name another organization already uses is refused with 409 Conflict, without saying which organization holds it. A route name may contain /, as in Qwen/Qwen2.5-7B, but not as another organization’s provider-slug/… or group-slug/… address, and fake-llm is reserved for the built-in model. The address check is made when the route is created: a slug created afterwards is not refused, and keys from a config file, which carry no organization, then reach the route rather than the new address.
Only the readonly rows of a file a process loads directly are deployment-wide: a gateway’s --config, and the control plane’s bootstrap --config (flat [[providers]], [[routes]], [[provider_groups]] and the *.readonly tiers). Entries under [[providers.default]], [[provider_groups.default]] and [[models.default]] are seeded into the default organization, skipping any whose name another organization already uses, and rolter-seed --import writes into the organization it imports into (default unless you name another). Like dashboard-created rows, only that organization’s keys can use them.
Project-scoped providers#
A provider or provider group is org-wide by default: every project of the organization can use it, so projects can share one as a fallback or an option. Give it a project and it becomes project-scoped: only virtual keys minted in that project can reach it, through a route or as provider-slug/model or group-slug/model. A key from another project of the organization, or a key with no project, gets 404 model_not_found, as if the provider did not exist, and GET /v1/models does not list it. Existing providers stay org-wide.
In the dashboard, the provider and provider group sheets have a Scope field: Whole organization (the default) or one project of the organization. The Providers and Provider Groups lists show it as a Scope column (Project: Gateway), and a route’s target picker offers only its own project’s providers and the org-wide ones. Someone who is not an organization admin can only pick their own project, cannot change the scope of an existing provider or group, and cannot set api_key_env on a project-scoped provider.
| Where it is used | What is allowed |
|---|---|
| A route in project P | P’s own providers and org-wide ones. A provider scoped to another project: 409 |
| A group scoped to project P | P’s own providers and org-wide ones |
| An org-wide group | Org-wide providers only: a scoped member would be reachable by every project through it |
| Scoping an org-wide provider that is in use | Refused with 409 while a route of another project or a group not in P uses it |
Set it with project_id on POST /api/v1/orgs/{org_id}/providers and /provider-groups, or change it later with PUT; send "project_id": null to make it org-wide again. The project must belong to the organization. A project admin can create and delete their own project’s providers; making one org-wide or moving it, and reading a credential from an environment variable (api_key_env), take an organization admin. Project members see their project’s providers and the org-wide ones when they list. A project that still owns a provider or group cannot be deleted until they are removed or made org-wide, so deleting a project never widens access silently.
Rows that break these rules, written around the API with SQL, are left out of the gateway’s snapshot one target at a time rather than failing it, and listed by GET /api/v1/config/problems.
A route whose stored params, param_policy or advanced settings cannot be read, which only a row written around the API with SQL can cause, is left out of the snapshot whole and its model is not served, rather than served without the visibility restrictions or parameter defaults it was saved with. GET /api/v1/config/problems names the route and the column; repair the value or delete the route.
A provider whose stored egress_proxies is not a list of proxy URLs, which only a row written around the API with SQL can cause, is left out of the snapshot, with every route target and group member that named it, rather than served on a direct connection around the proxy pool you configured. GET /api/v1/config/problems names the provider; set the pool again or delete the provider. A route in your rolter.toml that names such a stored provider loses that target the same way, and the Providers screen still lists the provider so you can fix its pool. See Upgrading.
A provider whose own definition is invalid, such as an api_base that is not an http:// or https:// URL or a hosted adapter without api_key_env, is left out of the snapshot, and so is every reference to it. The route targets and group members that named it go, and a route left with none stops being served. A virtual key whose providers list names only that provider is denied rather than opened to every provider, since an empty list means all of them; a key that also lists others stays limited to those. A route’s semantic cache that used it as the embeddings provider is switched off while the route keeps serving. GET /api/v1/config/problems lists each of these; fix or delete the provider.
Provider kinds#
| Kind | Use case |
|---|---|
openai | OpenAI’s native Chat Completions API |
anthropic | Anthropic’s native Messages API |
openai_compatible | Any OpenAI-compatible endpoint: vLLM, TGI, Ollama, and similar |
Use api_key_env to reference a secret by environment variable name — never commit plaintext API keys to your config file.
Provider config example#
Routes#
A route maps a public model name — what your application actually sends in the "model" field — to one or more upstream targets plus a load-balancing strategy. When a request arrives for "gpt-4o", Rolter looks up the matching route and forwards it to the appropriate provider.
| Field | Description |
|---|---|
model | Public model name clients request (must be unique across the deployment) |
strategy | Load-balancing strategy (see Load Balancing) |
[[routes.targets]] | One or more upstream targets to balance across |
targets[].provider | Name of a declared provider |
targets[].model | Upstream model identifier; omit to forward the public name as-is |
targets[].weight | Relative traffic share for weighted and pipeline strategies (default: 1) |
Basic route example#
Inference params#
You can inject admin-controlled default inference parameters into every request on a route using [routes.params]. Any parameter key the upstream model accepts is valid — temperature, max_tokens, stop, top_p, and so on.
When a caller omits a parameter, the admin default is injected. When a caller sends a value, the param policy decides whether their override stands or the admin default silently wins.
Param policy#
[routes.param_policy] has two modes:
allow(default) — callers may override any default except those listed indenydeny— callers may override no default except those explicitly listed inallow
Use mode = "deny" with an empty allow list to fully pin all parameters — useful for compliance or cost-control scenarios where you need exact, reproducible outputs.
Per-model advanced settings#
Beyond params, a route carries an advanced block: catalog metadata, request settings the gateway applies to every call the route makes, and catalog visibility. In the dashboard these are the “Limits & network”, “Custom request headers” and “Access & permissions” sections of a model’s sheet, and saving the sheet writes them back to the route. In a rolter.toml they sit under [routes.advanced].
What the gateway applies. Each of these changes what the gateway does for the route, and nothing else in the block does:
| Field | Notes |
|---|---|
limits.timeout_secs | Whole seconds to wait for the upstream’s response headers, 1–10000000. Replaces [timeouts].request_secs for this route, even where that is 0. A streamed response is never cut short by it. The dashboard’s field is in milliseconds and rounds to the nearest second, never below one |
limits.retries | 0–10: extra attempts after the first, each on another target (or another of the provider’s keys after a 429 or 401). Replaces [retry].max_retries for this route, and 0 turns retries off. Leave blank to inherit |
limits.output_tokens | 1–10000000. A request whose max_tokens, max_completion_tokens or max_output_tokens is above it is refused with 400 and code max_tokens_exceeded |
limits.rpm | 1–10000000 requests per minute the route admits, counted across every caller and every gateway replica that shares the deployment’s Redis. The request over the cap is refused with 429 and Retry-After. Without Redis it is not enforced. See Route limits |
limits.tpm | Tokens per minute the route admits, counted from the usage of finished responses, so a burst already in flight can overshoot it. Refused with 429 once the window holds the cap. Shared through Redis like rpm; without Redis it is not enforced |
limits.concurrency | 1–10000000 requests the route serves at once on one gateway replica, a streamed response included until its last byte. The request over the cap is refused at once with 429 and Retry-After: 1. Counted in the gateway, so it works without Redis |
headers / locked_headers | Static headers sent to the upstream on every call the route makes, to every one of its targets. See Route headers |
visibility.minimum_role | Lowest role allowed to call the model |
visibility.project_only | true serves the model only to keys minted in the route’s own project. Omitted (the default), every key in the organization may use it |
visibility.allowed_team_ids / allowed_key_ids / allowed_user_ids | Restrict the model to specific teams, virtual keys or users. Each entry is an id, not a name |
timeout_secs, retries, headers, rpm, tpm and concurrency apply to a request that names the route, on /v1/chat/completions, /v1/responses, the other JSON endpoints and the multipart audio uploads alike. They do not apply to a request addressed as provider-slug/model or group-slug/model, which has no route, nor to health probes. A Realtime session is one request for rpm, holds a concurrency slot until it closes, and its turns count towards tpm; timeout_secs, retries and headers do not apply to it.
Catalog metadata. model_type, capabilities and context_length (the context window in tokens, at least 1) are labels the dashboard reads back and GET /v1/models serves on the route’s entry when they are set, so an API client can learn what a model is for and how large its context is. description is a note for your team that the routes API returns; it is never sent to API clients. The gateway enforces none of them, so a request is forwarded whether or not a capability flag is set or the prompt fits context_length.
Route limits#
limits.rpm, limits.tpm and limits.concurrency protect one expensive model without limiting the keys that call it. They belong to the route, whoever calls: every key, team and project shares them, and they sit on top of the limits on a key, team, project or organization, which are checked first. A request over either is refused with the same 429.
- A refusal is a
429with coderate_limit_exceededand aRetry-Afterheader, in the same error envelope on/v1/chat/completions,/v1/messagesand the other endpoints.rpmandtpmsay how long until the window frees capacity;concurrencysays1. A refused request never reaches an upstream. Nothing is queued: a client that wants to wait retries. rpmandtpmare shared by the fleet. The counters live in the Redis the other throughput limits use, on a key of the route’s own organization and name, so two replicas count one total and the same route name in two organizations is two counters. They use the same sliding one-minute window as every other limit.tpmis recorded after the response, as for the other scopes. A request is refused when the window already holds the cap, so requests in flight together can take it over by their own usage.concurrencyis per replica. Each gateway counts the requests it is serving, so three replicas withconcurrency = 4serve up to twelve at once. It needs no Redis. A request holds its slot until its response has been delivered, which for a stream is the last event, and a client that disconnects mid-stream frees it.- Without Redis (no
redis_url, or Redis unreachable)rpmandtpmare not enforced, the same fail-open every other throughput limit has, and therolter_fail_open_totalcounter says so.concurrencykeeps holding. - A refusal costs the caller a request. The caller’s own
rpmis charged before the route is known, so a request the route refuses has still taken a slot of the key’srpm. - A route addressed through a complexity tier is limited by the route that serves it, not by the one the client named.
- Refusals are counted in
rolter_route_limit_blocks_total{route,kind}(see Metrics) as well as inrolter_rate_limit_blocks_total.
The built-in fake-llm model has no route and no limits.
Route headers#
headers is a map of header name to value, sent with every upstream call the route makes. A header the deployment forwards from the caller (forwarded_headers under Client settings; a caller cannot send any other header upstream) is the caller’s to choose unless you list its name in locked_headers:
- Unlocked (the default): the route’s value is a default, and a caller’s header of the same name replaces it.
- Locked: the route’s value replaces a caller’s header of the same name.
A route header also replaces a deployment-wide injected header of the same name.
Credentials belong to the provider. A route may not set authorization, proxy-authorization, x-api-key, api-key or x-goog-api-key, nor the headers that frame the request (host, content-length, content-type, transfer-encoding, connection and the other hop-by-hop headers). The routes API refuses one with 400, a rolter.toml that sets one fails rolter check, and a stored row that carries one anyway (written around the API) has the header left out of the snapshot while the route keeps serving; GET /api/v1/config/problems names it. The same applies to a name that is not a valid header name or a value that is not visible ASCII on one line.
Settings that never took effect#
Earlier releases stored, validated and displayed several more settings that the gateway did not read, and the dashboard and these docs described them as working. They are gone from the model sheet and the API, and Rolter now ignores them:
| Setting | What to do instead |
|---|---|
base_url (a per-model endpoint) | Requests always went to the provider’s api_base. A second endpoint is a second provider: add one with that api_base and point the route’s target at it. The credential belongs to the provider, so a route cannot send it somewhere the provider’s owner did not choose |
insecure_tls | The gateway never skipped certificate verification for a route, and does not offer to. To trust a private or self-signed CA, add its PEM file to [tls].ca_bundles, or to a provider’s own ca_bundles (see ROLTER_CA_BUNDLE) |
limits.context_window | Nothing read it. The model’s context window is the upstream’s to enforce |
pricing.image_per_unit, audio_input_per_minute, audio_output_per_minute, cache_write_per_mtok | Never read from a route. A model billed per image, per minute of audio or per character of speech is priced on its price row (image_per_unit, audio_input_per_minute, speech_per_mchar; see Images, audio and speech), and so are the token rates, including what a cache write costs, which the Pricing override section of the sheet writes (see Cache writes). There is no rate for the length of generated audio |
A route that still carries one of these keeps loading, and requests are served exactly as they always were. Nothing is migrated. The routes API accepts them from an older client and drops them instead of storing them, saving a model’s sheet in the dashboard sheds them from the route, and a rolter.toml that sets one is reported as an unknown key at startup and by rolter check. To find the routes that still carry one before you rely on any of this, see Upgrading.
A route is only ever served to keys of the organization it belongs to. For a
key from another organization the route does not exist: the key gets 404 with
code model_not_found, as for a model nobody configured, and does not see it in
GET /v1/models. The route does not hide anything of that key’s own either, so
the key’s own edge provider still answers edge/gpt-4o when another
organization has a route by that name. Within the organization, the sheet’s
Access & permissions → Visibility control narrows it further:
- Whole organization (the default) — any key in the organization.
- This project — only keys minted in the project the route lives in
(
visibility.project_only). This narrows the route, not the provider behind it. To keep a provider to one project, scope the provider itself, see Project-scoped providers. - Restricted — only the teams, virtual keys and users you list.
The gateway enforces the organization boundary, project_only,
allowed_team_ids and allowed_key_ids on every endpoint that names a model,
including audio uploads and Realtime sessions. A key outside the organization
or outside a This project route gets 404 with code model_not_found; a
key the allowed teams or keys leave out gets 403 with code
model_not_allowed. allowed_user_ids and minimum_role apply in the
dashboard, since a virtual key carries no user. Keys defined in the gateway’s
own config file belong to the operator rather than to an organization and are
not narrowed. See
the same checks on every endpoint.
The control plane validates the whole block before storing it and refuses the save with the offending field named, so an out-of-range limit or a malformed header name never reaches the gateway. The dashboard reports that refusal on the sheet, with the rest of the model already saved.
The advanced block is stored per route and travels in the config snapshot, so a change takes effect without restarting the gateway.
The block used to carry an additional_fields map. It has been removed: nothing
ever read it, and it could not do what its name suggested either — a request
field Rolter does not model is already forwarded to the upstream as-is, so there
was nothing for it to gate. The “Allow additional fields” switch that stood in
for an editor went first, in #1271, because it had no field of its own on the
wire.
If a route of yours still has the key, nothing breaks: the stored block loads as
before, every other setting in it is unaffected, and the key is simply ignored.
Rolter never writes it back out itself. In a rolter.toml it is now reported by
the unrecognised-key warning at startup, which is a warning and not a refusal to
start — delete the line to silence it.
Variants#
Variants let you run A/B tests, canary deployments, and per-key traffic splits under a single route. Instead of defining a flat targets pool, you define named variant buckets, each with its own weight, targets, and optional param overrides.
Request arrives
Rolter samples one variant proportionally by weight — this is the primary variant.
Primary attempted
Rolter forwards the request through the primary variant’s targets.
Fallback on failure
Within a variant, the route’s strategy picks the leading target. If the primary variant fails, Rolter tries the remaining variants in declared order until one succeeds.
Attribution
The chosen variant name is written to the variant column in request logs for analysis. On Routing Rules, a route with variants lists them on its card in fallback order with the share each weight gives, and says that its own targets are not used while the variants exist. The route sheet’s Variants section shows each saved variant’s observed share of that traffic beside its configured split, with requests, errors and p50/p95 latency, over a window you pick (7 days by default); the same numbers come from GET /api/v1/analytics/by-variant?model=<route>. A deployment without ClickHouse sees a note instead.
Each variant can carry its own params table. Variant params layer over the route-level params (variant wins on collision), and the route’s param_policy governs both.
The weight on each variant controls proportional sampling — 9:1 sends 90 % of requests to control and 10 % to canary. A route uses either [[routes.variants]] or a [[routes.targets]] pool, not both.
A route created in the dashboard or through the API can carry variants too: open the route’s sheet and use its Variants section, which previews the traffic split as you set the weights, or call PUT /api/v1/routes/{id}/variants. rolter-seed --import stores a file route’s variants in the database and rolter config export writes them back, so a route keeps its split when it moves between the file and the control plane.